chore: sync ammo readout with upstream dev (#552)
This commit is contained in:
222 files changed
+10419
-2026
No files matched your search
@@ -0,0 +1,46 @@
|
||||
name: PR title labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Run only this trusted script; never check out or execute PR head code.
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
if (context.payload.action === 'edited' && !context.payload.changes?.title) return;
|
||||
|
||||
// Fetch current metadata so delayed events do not use an outdated title or labels.
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const match = /^(fix|bug|docs|feat|feature)(?:\([^()\r\n]+\))?[::]/i.exec(pr.title.trim());
|
||||
if (!match) return;
|
||||
|
||||
const label = {
|
||||
fix: 'bug', bug: 'bug', docs: 'documentation', feat: 'enhancement', feature: 'enhancement',
|
||||
}[match[1].toLowerCase()];
|
||||
if (pr.labels.some(({ name }) => name.toLowerCase() === label)) return;
|
||||
|
||||
// Only use labels already defined in this repository.
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...context.repo, name: label });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
core.info(`Skipping missing repository label: ${label}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [label],
|
||||
});
|
||||
@@ -11,6 +11,9 @@ public/assets/
|
||||
public/fonts/
|
||||
# tools/local-extract/extract.py → public/assets/local/** and this per-machine manifest
|
||||
# (optional: the client and the tests work without it; docs/DEPLOY.md §6 lists what falls back)
|
||||
# Battlefield scenes (0.2.5): extracted files live under public/assets/local/scene/ (already ignored by
|
||||
# public/assets/ above). Downloaded bundles live in .cache/official-scenes/ (already ignored by .cache/ below).
|
||||
# The repository never stores those official files.
|
||||
data/local-assets.json
|
||||
# download caches, upstream tables, ledgers, reports, setup state
|
||||
.cache/
|
||||
@@ -43,3 +46,5 @@ desktop.ini
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
# tools/sim-check.mjs: the corpus and its Node traces for /dev/sim-check.html
|
||||
public/dev/simcheck/data/
|
||||
+44
-1
@@ -1,4 +1,47 @@
|
||||
# 更新记录
|
||||
## 0.2.5 — 2026-10-12
|
||||
|
||||
### 新功能
|
||||
- 3D 棋盘按战场显示官方场景:提取了官方场景时,11 个战场显示各自的沙地、叙拉古街区或工业风场景,没有场景时仍用金属地块,装置、出入口和地形叠加照常显示(#532、PR #533,感谢 @andrew05060414)。完整包附带提取好的场景;精简包和源码可以用 `node tools/setup.mjs --scenes` 在本机下载并提取,提取依赖缺失时继续用金属地块。仓库本身不存放官方文件。
|
||||
- 设置新增「官方伤害数字」(默认关闭):打开后只按 PRTS「伤害红字(暴击)」显示红色整数,以及本该出红字却被闪避的 MISS;关闭时仍显示每一次命中(#412,感谢 @ZizhaoNi、@andrew05060414)。
|
||||
- 标题画面页脚新增「项目仓库」链接,在新标签页打开本项目仓库(#543)。
|
||||
|
||||
### 规则(按官方)
|
||||
- 补齐司霆惊蛰二技能、斩业星熊三技能对空及予愿安洁莉娜三技能周身范围的自动开技判定。
|
||||
- 首领配对改为每局随机,准备期至开战固定,隐藏首领沿用同局分组;客户端棋盘和队友框同步实际配对。
|
||||
- 修复联防阿戈尔继承残血后再次结算开场吞噬、误触发复活的问题,保留准确生命比例和技力。
|
||||
- Touch 外勤医疗按本局团队权益保留,持有者淘汰或退出后不再使下一场医疗消失(维护者已确认保留,原作寿命规则仍待实测确证)。
|
||||
- 固定赠品不再被共享库存阻断(卡涅利安、蜜蜡、缪尔赛思、耶拉、诗怀雅〈获得时〉的赠品,以及变形者集群发放的同一件装备):仍计入库存,达到上限也会继续发放(#488、#491、#495,思路来自 PR #496,感谢 @2321Robin);洛洛、焰尾〈获得时〉的子池按路标月报#2 的实测权重抽取(PR #497,感谢 @2321Robin)。
|
||||
- 已展示的干员商店牌和奖励牌不再因随后库存耗尽而领取失败;道具购买与奖励保留库存检查(#498,也感谢 @YinqiMargatroid 的 PR #529)。
|
||||
- 修复克莱门莎三技能被友方侵蚀爆条触发、号角不攻击脚下不可阻挡敌人、自制投石机溅射半径;忍冬第一次造成伤害时也打出追凶的附加法伤(PR #502,感谢 @Xinglan233)。
|
||||
- 谢拉格不融冰、催泪瓦斯改为造成伤害时判定,包含阿斯卡纶的持续伤害(#489,也感谢 @Siornya 的 PR #526)。
|
||||
- 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定(#510)。
|
||||
- 外勤医疗的预备地图角色在开战时保持比普通初始干员更早的索敌顺序(预备干员-医疗与 Touch 都是;[ASSUMED],维护者 2026-10-12 决定按作者描述实现);准备阶段可以点开该角色的详情和攻击范围(PR #523,感谢 @YinqiMargatroid)。
|
||||
- 萨卡兹枯朽战士和组长在已经沉默时被击倒,不留下污染秽蚀。维护者 2026-10-12 的实机回忆是沉默能阻止这团雾,这与 PRTS / 游戏内文字「不可沉默」相反;同一死亡能力只有这两名敌人,其他枯朽敌人的沉默规则不变(#470)。0.2.0 起行为已是如此,这次记下的理由改为上述回忆。
|
||||
- 调度中心 4 级的五阶晋升奖励可以出现五阶自选,5 级的六阶晋升奖励可以出现六阶自选;普通商店仍在 5 / 6 级才刷出;不保证出现;禁用和库存照旧(#515,感谢 @SpiritedAwayCN;同样的修复见 PR #544,感谢 @Siornya)。
|
||||
- 驭械术师未释放的浮游改为本体攻击再加一份附加伤害(初始相当于攻击力 20%,同一目标每次 +15,上限 110%);技能放出的浮游仍造成技能伤害(PR #537,感谢 @Xinglan233)。
|
||||
- 只改普攻的技能保留干员自己的那一击。耶拉二技能(基础与隐藏)放出浮游时仍打出 100% 的普通攻击,寒冷对自身和浮游各判一次;荒芜拉普兰德一技能开启后仍是本体一击加两份未释放浮游。只由浮游出手的技能不另加这一击。
|
||||
- 干员再部署时浮游倍率回到初始值,并清掉按目标记住的倍率;自由移动仍保留。已经飞出的浮游按命中时的倍率结算(PR #536,感谢 @Xinglan233)。
|
||||
- 洛洛二技能放出的浮游在目标离开攻击范围后继续锁定,只在击杀或技能结束时返回;技能期间不再叠加未释放浮游的附加伤害(PR #535,感谢 @Xinglan233)。
|
||||
- 受击格按官方四舍六入五成双取整,被挡在场地边缘的敌人留在边缘那一格(PR #520,感谢 @Cloudnyco)。
|
||||
|
||||
### 界面
|
||||
- 补齐回合开始、机变、休整期和战斗中的盟约叠层提示音;同类最多播放一路,新触发替换旧声音,重连和结算回填不补播(#482)。
|
||||
- 生命值在结算前保留当前数值,旁边标出待扣的 −N(#479、PR #504,感谢 @robll-v1);刷新请求返回前不会重复发送(#501、PR #507,感谢 @robll-v1)。
|
||||
- 盟约提示框旁列出本局被禁用的成员(#484、PR #494,感谢 @YuriRestia);维多利亚的英文效果改为按装备逐件加成的表述(PR #499,感谢 @YuriRestia);韩语界面修正(PR #490,感谢 @D-Roote)。
|
||||
- 只修改自选潜能 / 练度时也能导出调配(PR #503);观战时的盟约成员名单带上自选干员(PR #505);干员调配同步在等待游戏数据时,恢复默认或之后的修改会使仍在准备的旧请求作废,避免旧技能写回服务器(PR #540);导入带潜能 / 练度的调配文件时,若缺少自选编队所需的游戏数据,整份导入会被拒绝,不再清掉已保存的练度(PR #538)。感谢 @Xinglan233。
|
||||
- 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式(#506)。
|
||||
- 敌方血条的头顶高度改按原点以上的高度计算,原点在身体中部的敌人不再把血条顶到上限(PR #524,感谢 @xcdoge)。
|
||||
- 修复 AI 托管遮罩挡住「在此页面继续」按钮(PR #511,感谢 @RainoutOvO)。
|
||||
|
||||
### 人机
|
||||
- AI 选开局策略和构筑重心时,会软性让开仍在场队友已经选择的阵营;队友淘汰后取消。不锁库存,也不阻止立刻合成(#514、PR #518,感谢 @IceCodeNew)。
|
||||
|
||||
### 工具与开发
|
||||
- 玩家包里的 package.json 只保留指向包内文件的脚本;`npm test` 不再在没有测试的情况下显示通过。打包进去的说明里,指向未随包发布的文件的链接改为该版本标签的 GitHub 地址(#531、#530,感谢 @modusensus)。
|
||||
- 本地提取按 Unity 的方式解法线贴图,BC5 和 DXT5nm 两种存法都能得到正确结果,修复 3D 金属棋盘发灰;2026-10 客户端更新之前提取过素材的,需要重新提取一次 map/autochess(PR #534,感谢 @andrew05060414)。
|
||||
- Windows 启动脚本的 Node 版本检测在 Volta 等版本管理器下不再误报版本过旧(#539、PR #541,感谢 @BowenJXD)。
|
||||
- 增加开发用模拟一致性检查页 `/dev/sim-check.html`(PR #492);powi 对 ±0 的结果与 Math.pow 一致(PR #519)。感谢 @Cloudnyco。
|
||||
- PR 标题以 fix / feat / docs 开头时自动加上类型标签(PR #517,感谢 @Xinglan233)。
|
||||
|
||||
## 0.2.4 — 2026-10-11
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染
|
||||
SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里的战斗模拟
|
||||
```
|
||||
|
||||
改了模拟(`server/sim/`)之后,可以检查各浏览器算出的战斗和服务器是否仍然逐位一致:`node tools/sim-check.mjs`
|
||||
在 Node 里记录黄金结果的整套战斗,然后在任意设备(包括手机)的浏览器里打开服务器的 `/dev/sim-check.html` 重放比较;
|
||||
加 `--engines chromium,firefox,webkit` 直接在无界面的浏览器里跑(Firefox 和 WebKit 需要先装 playwright)。不一致时
|
||||
页面指出首次检测到差异的区间,以及结果、抽样快照或已记录状态的差别。`--diff` 可定位导出的状态向量字段;仅快照或深层状态差异不保证能定位字段。
|
||||
参考数据含运行代码、数据与场景内容指纹,修改后必须重新生成;空筛选、未完成或运行异常均不算通过。细节见 docs/SIM.md
|
||||
「11. Tools」。
|
||||
|
||||
**黄金结果**([test/golden/README.md](test/golden/README.md)):只重构、不改玩法的提交不能改变
|
||||
`test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,把新文件和改动放在同一个提交里,并在提交说明里写清
|
||||
哪些场景变了、为什么。
|
||||
@@ -110,6 +117,8 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
|
||||
are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome,
|
||||
`CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs
|
||||
`npm run golden:update` and commits the digests with the change, naming the scenarios that moved.
|
||||
After a sim change, `node tools/sim-check.mjs` checks that browsers still give the server's bits: it traces the golden
|
||||
battle corpus in Node; `/dev/sim-check.html` replays it on any device (`--engines chromium,firefox,webkit` headless).
|
||||
- **Fidelity**: the official tables (`.cache/gamedata/excel/`) and PRTS (including the 备注 notes) are the sources;
|
||||
numbers come from the blackboards. Anything no source settles is marked `[ASSUMED]` in the code and the pull
|
||||
request; owner decisions are cited with their date; deliberate deviations are the owner's call and go into the design
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -37,7 +37,7 @@ English summary: [below](#english).
|
||||
|
||||
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
|
||||
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
|
||||
- 当前版本 0.2.4:按官方补齐共享卡池与装备库存,修正突袭复活选敌、克莱门莎、浊心斯卡蒂与坚守等规则,联机首领战突袭可以跳到空半场,并修复了 0.2.3 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
- 当前版本 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
|
||||
## 功能一览
|
||||
|
||||
@@ -54,7 +54,7 @@ English summary: [below](#english).
|
||||
- **最终攻势与隐秘核心**:两人共享一个战场,全队共同削减同一条领袖血条;10 个敌方领袖,巨型领袖约 5×3 格的受击范围,以及官方的限伤规则。
|
||||
- **结算称号**:卫戍之星、不朽盟约、坚若磐石等 6 个称号。
|
||||
- **断线重连**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位,掉线期间按原阵容自动作战,也可以「暂离」交给 AI 托管;独立模拟 24 小时内可以回来继续(同一个浏览器)。
|
||||
- **交互细节**:漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都按地上的方格;购买、升级和机变选卡都需要点两次确认;只有一名玩家时除作战外不计时。
|
||||
- **交互细节**:漏怪时顶栏用红字 + 「−N」标记待扣的目标生命值(结算时才扣除);点选、拖放和配发装备都按地上的方格;购买、升级和机变选卡都需要点两次确认;只有一名玩家时除作战外不计时。
|
||||
- **画面与声音**:真实 Spine 小人、官方 BGM 与音效、表情(6 套 × 6 个)、作战特效;可选的官方 3D 棋盘(需要从本机客户端提取贴图)。
|
||||
- **手机与电脑**:触摸拖拽、长按查看详情,推荐横屏;设置里可以调低画质。
|
||||
|
||||
@@ -64,8 +64,8 @@ English summary: [below](#english).
|
||||
|
||||
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一;已经装好 0.2.x 的,升级时只下载更新包即可:
|
||||
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB):附带全部美术 / 音频(含中文、日文两套干员语音和官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 550 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 550 MB,解压后约 780 MB):附带全部美术 / 音频(含中文、日文两套干员语音、官方 3D 棋盘贴图和各战场的官方场景),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 550 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」;各战场的官方场景可以用 `node tools/setup.mjs --scenes` 在本机下载并提取)。适合下载大文件不方便的情况。
|
||||
- **更新包** `Stronghold-Protocol-v<版本>-update.zip`(0.2.1 起提供,大小看改动多少,通常只有几 MB):只含比之前的 0.2.x 版本改动过的文件,用来把已经装好的 0.2.x(完整包或精简包装的都行)升级到新版本,不用重新下载整个包。用法:先停止服务器(关掉窗口;装了开机自启的运行 `scripts\install-service-windows.ps1 -Stop`),把 zip 里 `Stronghold-Protocol` 文件夹的全部内容合并到安装文件夹、覆盖同名文件(Windows 资源管理器里复制粘贴即可;macOS 不要用访达拖放,它会整个替换文件夹,请用 `unzip -o`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 第 1.5 节),再照常启动。启动时会先核对全部程序文件、删除新版本不再用的旧文件,然后正常运行;如果这个文件夹不是更新包对应的版本(例如 0.1.x,或者程序文件被改过),会提示下载完整包,服务器不启动。全新安装请用完整包或精简包。
|
||||
|
||||
两种包都只含运行和部署需要的文件(服务器、客户端、数据、启动脚本、setup / doctor / 素材下载工具、许可证与说明、[docs/PLAYING.md](docs/PLAYING.md) 和 [docs/DEPLOY.md](docs/DEPLOY.md));测试、开发工具和设计文档只在源码仓库里。
|
||||
@@ -250,7 +250,7 @@ node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧
|
||||
|
||||
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
|
||||
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~505 MB, all the art inside, the Chinese and Japanese operator voices included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~550 MB, all the art inside, the Chinese and Japanese operator voices and the official battlefield scenes included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Languages:** Chinese (the default), English, 日本語, 한국어 and 繁體中文 — switch on the title screen or in Settings. Game texts come from the official clients; the Japanese, Korean and Traditional Chinese interface strings are machine translations (corrections welcome: [docs/I18N.md](docs/I18N.md)).
|
||||
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2498,"translated":2449,"official":2431,"pct":98},"operators":{"texts":876,"translated":854,"official":846,"pct":97.5},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5996,"translated":5688,"official":5680,"pct":94.9},"tokens":{"texts":458,"translated":458,"official":457,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23252,"official":23158,"pct":97.4}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":"ILine truncated
|
||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/corrections-en.json (before the official texts), tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":136,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1081,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2498,"translated":2449,"official":2431,"pct":98},"operators":{"texts":876,"translated":854,"official":846,"pct":97.5},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5996,"translated":5688,"official":5680,"pct":94.9},"tokens":{"texts":458,"translated":458,"official":457,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23252,"official":23154,"pct":97.4}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless UndLine truncated
|
||||
@@ -3,6 +3,11 @@
|
||||
Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`.
|
||||
|
||||
All official game art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
|
||||
|
||||
The UI sound `audio.sfx.ui.bondUp` is the official `ON_ACT1AUTOCHESS_ADD_BOND` bank's
|
||||
`act1autochess_b_ui_buffup`. Live prep/battle layer gains use it as well as bond activation. Its bank settings are
|
||||
`maxSoundAllowed: 1` and `popOldest: true`: one dedicated SFX channel, replacing the previous sound without an extra
|
||||
cooldown. It respects the SFX/mute settings; a stale asynchronous decode cannot restart a replaced cue (#482).
|
||||
All downloaded game assets the client needs are listed in **`data/assets.json`**. Game-art requests must use that manifest.
|
||||
The original project icons below are shipped static files, not downloaded game assets.
|
||||
|
||||
@@ -160,6 +165,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
|
||||
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (`tools/local-extract/extract.py TOKEN_SPINES`, optional); the original overlay is preferred, with any listed web model retained as fallback | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| Official battlefield scenes (one per battlefield: meshes, lightmaps, albedo textures; DESIGN §15, DESIGN §30.21) | the scene bundles `scenes/activities/<season>/level_<stage>/…` (`tools/local-extract/extract.py --only scene`, optional). The client carries them only while the season runs; off season `tools/local-extract/fetch_scenes.py` downloads the bundles of the in-season version `26-04-13-12-31-42_e9b560` from the official CDN into `.cache/official-scenes/` (33 bundles, 22,801,308 bytes compressed on the Android list, md5-checked), and the materials / textures still come from the local client's theme bundles. The 0.2.5 full release zip includes the extracted files under `public/assets/local/scene/` when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. Without a scene the 3D board draws its procedural metal tiles | `local/scene/{stageId}/scene.json`, `scene.bin`, `lm{i}.*`; `local/scene/tex/{texture}.*` (listed in `data/local-assets.json`) |
|
||||
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
|
||||
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
|
||||
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) |
|
||||
|
||||
+13
-3
@@ -112,7 +112,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `poolCopies[tier]` + `poolCopiesOverrides` | 12/14/18/16/8/5 plus measured low-tier identity exceptions (DESIGN §29.8) | single-player copies; `coopPoolMultiplier: 2` doubles shared operators, not private DIY stock |
|
||||
| `itemPoolCopies[tier]` + `itemPoolCopiesOverrides` | 4/6/7/8/7/3 plus eight measured exceptions (DESIGN §29.8) | shared shop-equipment ownership: normal 1, upgraded 2; non-shop effect-only equipment unlimited |
|
||||
| `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | |
|
||||
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; `PlayerState.pushRewardOffer`, user playtest #6 item 19) |
|
||||
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; the player's stocked 自选 of that tier joins the roll, DESIGN §30.23; `PlayerState.pushRewardOffer`, user playtest #6 item 19) |
|
||||
| `handFillOrder` | `"rightToLeft"` | |
|
||||
| `shopOdds` | `{"model":"copyWeighted",…}` | [ASSUMED] roll model |
|
||||
| `borrowCount`, `fallbackBondId`, `defaultBandId`, `defaultStartLp` | `20`, `"emptyShip"`, `"band_bldsk"`, `28` | |
|
||||
@@ -642,8 +642,9 @@ prototype. Built by `tools/build-data.mjs buildBackups`; `shared/standIn.js` com
|
||||
`shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12). Played since 0.2.0: the player fills
|
||||
the four slots on the 自选编队 tab (`room.diy { picks }`, docs/PLAYING.md §3; shared/protocol.js checkDiyPicks keeps the
|
||||
legal picks), the match takes the picks the seat had at its start (`PlayerState.diy`), and each slotted piece is sold in
|
||||
that player's shop only — its own stock (the tier's pool copies, 8 / 5 [ASSUMED]), from the 调度中心 level `shopLevel`,
|
||||
none when every bond of it is banned this match — and is the operator for every rule of that player (its data view:
|
||||
that player's shop only — its own stock (the tier's pool copies, 8 / 5 [ASSUMED]). The ordinary shop draws it from the
|
||||
调度中心 level `shopLevel`; the promotion reward draws 自选 of the reward tier, one shop level earlier (DESIGN §30.23).
|
||||
It has no stock when every bond of it is banned this match, and it is the operator for every rule of that player (its data view:
|
||||
server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约
|
||||
下半/PRTS盟约记录):
|
||||
|
||||
@@ -737,3 +738,12 @@ the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.
|
||||
field), 助战 borrows (`borrowCount` 20), and a player's potential / 练度 per operator (the 干员调配 settings sent with
|
||||
`room.loadout`; the forms are built at full potential and carry the lower ranks, §2.3; 练度 = effects.json
|
||||
`aceffect_char_1…4`).
|
||||
|
||||
### Acquisition subsets corrected in 0.2.5 (§30.2)
|
||||
|
||||
`tools/build-data.mjs` owns the measured Rockrock/Flametail subset weights from
|
||||
[路标月报#2](https://www.bilibili.com/video/BV1eLXXBqEgF/). Rockrock: ordinary hammer 10; tremble, undying, haste,
|
||||
burn hammers 2 each; limited accelerator 1; shield drone and laser emitter 4 each. Flametail: Wild Mane 10,
|
||||
Ashlock 10, Fartooth 3. Rebuild `choices.json` offline; these weights do not exempt draws from shared availability.
|
||||
The runtime derives unlimited fixed-gift families from `SERVER_GAIN_EQUIP`; this is a grant-source exemption,
|
||||
not a zero-occupancy flag or a manually maintained second list in generated data.
|
||||
+7
-1
@@ -26,7 +26,7 @@
|
||||
```
|
||||
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
|
||||
2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 550 MB,解压后约 780 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘、各战场官方场景等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频(含两套干员语音)、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 550 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
|
||||
- **源码**:
|
||||
```powershell
|
||||
@@ -300,6 +300,7 @@ services:
|
||||
| 内容 | 没有本地素材时 |
|
||||
|---|---|
|
||||
| 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 |
|
||||
| 各战场的官方场景(沙地等 11 个战场,0.2.5 素材包;完整包在提取后带上,精简包和源码可在本机下载并提取,见下) | 3D 棋盘用程序生成的金属地块 |
|
||||
| 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 |
|
||||
| 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 |
|
||||
| 39 个召唤物的官方模型(多数自选召唤物,以及凯瑟琳的爬行号·防护单元、凛御银灰的风雪之眼;公开镜像没有) | 召唤物头像(菱形底板) |
|
||||
@@ -310,6 +311,8 @@ services:
|
||||
|
||||
**0.2.0 之前提取过的**:召唤物模型是 0.2.0 新增的提取项,旧的提取结果里没有(`node tools/setup.mjs` 会提示「缺少新版的自选召唤物模型」)。有客户端的电脑运行 `node tools/setup.mjs --local` 重新提取即可,只想补这一项也可以在提取用的 Python 环境里运行 `tools/local-extract/extract.py --only spine/token`(新文件写入 `public/assets/local/spine/token/`,清单里其他条目保持不变)。
|
||||
|
||||
**各战场的官方场景**(0.2.5 素材包,维护者 2026-10-12:可以进完整包;精简包和源码安装可以在本机下载):官方棋盘是每个战场一个做好的场景(11 个战场,其中 9 个是沙地)。场景包只在赛季开放期间随客户端分发,赛季外的客户端里没有。仓库不存放这些官方文件。有客户端的电脑可以 `node tools/setup.mjs --scenes`(询问或强制下载并提取;`--no-scenes` 跳过)。也可以分两步:`python3 tools/local-extract/fetch_scenes.py`(从官方 CDN 下载赛季版本 `26-04-13-12-31-42_e9b560` 的 33 个场景包,共 22,801,308 字节,放进 `.cache/official-scenes/`;`--list` 只看清单不下载),再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(材质和贴图仍取自本机客户端;`--game` 可指定客户端目录)。没有 Python 3.8+,或 UnityPy / lz4 / Pillow 没装好时,setup 只提示并继续,3D 棋盘用金属地块。没有客户端时贴图取不出来,从同一版本的完整包复制 `public/assets/local/` 和 `data/local-assets.json`。作者估计每个场景在进对局时另下载约 2–7 MB,各场景共用贴图约 3 MB;本机没有跑过提取,完整包因此增加的解压体积没有实测。
|
||||
|
||||
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
|
||||
|
||||
## 7. 打包发布(维护者)
|
||||
@@ -323,9 +326,12 @@ npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版
|
||||
npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip
|
||||
```
|
||||
|
||||
完整包在打包机已经提取战场场景时带上 `public/assets/local/scene/`(`--dry-run` 摘要里的 `battlefield scenes` 一行)。还没有提取时完整包仍能打,棋盘用金属地块。要带上 11 个官方场景,先运行 `python3 tools/local-extract/fetch_scenes.py`,再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(或 `node tools/setup.mjs --scenes`),然后再 `npm run package`。精简包不带场景。`.cache/official-scenes/` 在拒绝名单里,不会打进任何包。`tools/local-extract/scenes.py` 和 `fetch_scenes.py` 必须先提交:打包读的是 `git ls-files`,未提交的脚本不会进玩家包。
|
||||
|
||||
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。
|
||||
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
|
||||
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
|
||||
- **玩家包里的脚本和文档链接**:临时目录里的 `package.json` 只留下每条 `node <文件>` 都指向包内文件、并且至少指出一个文件的脚本(`start`、`dev`、`setup`、`doctor`、`launch`、`postinstall`、`vendor`、`assets`)。`test`、`golden`、数据构建、lint、本工具等不留,避免玩家运行 `npm test` 时在没有测试文件的情况下得到「0 个测试、成功退出」。`README.md`、`CHANGELOG.md`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`docs/PLAYING.md` 和本文里,指向包内没有的文件的相对链接改成该版本 GitHub 标签上的同一路径(版本号去掉末尾的 `-dev` 后加 `v`;图片用 `raw`,其余用 `blob`);`../../releases/latest` 改为仓库的 Releases 地址。源码仓库里的这些文件仍是相对链接。
|
||||
- **MANIFEST.json**:三种包的根目录都有(`npm ci` 之后写入):除素材(`public/assets/`、`public/fonts/`、`data/assets.json`、`data/local-assets.json`,归 setup 管)以外每个文件的大小和 sha256,同一版本的三种包内容相同。`npm run doctor` 和更新包的启动检查(`server/update.js`)用它核对安装。
|
||||
- **更新包**(0.2.1 起,每个版本都发):`--from` 后面列出**之前每个 0.2.x 版本的完整包**(Releases 上的 zip,或它解压出来、没动过的文件夹;逗号分隔或写多个 `--from`),例如发布 0.2.2 时 `--from Stronghold-Protocol-v0.2.0.zip,Stronghold-Protocol-v0.2.1.zip`。工具照常构建完整包的临时目录(`npm ci` 等),逐个文件(大小 + sha256)和每个旧版本比较:和任何一个旧版本不同、或旧版本没有的文件都打进去,所以一个更新包能覆盖在列出的每个版本上;旧版本有、新版本没有的文件记进 `UPDATE.json` 的 `removed`(连同各旧版本里的 sha256,玩家那边只删内容一致的文件;只差大小写的同名文件和 `.env` 之类的本机文件名不删,摘要里会列出)。旧版本必须比当前版本旧、带素材(不能是精简包或更新包)、每个版本只给一次,`--dry-run` 只读取并检查旧版本。更新包里还有新版本的 `MANIFEST.json` 和 `UPDATE.json`(适用的旧版本、文件数、字节数、文件列表和 `removed`),同样经过拒绝名单和个人信息检查;摘要列出和每个旧版本相比改动 / 新增 / 删除的文件数和更新包里各类文件的数量。发布时在 Releases 说明里写明更新包适用的版本(即 `--from` 列出的版本)。`--keep-stage` 保留更新包目录和完整包的临时目录(`<目录>/Stronghold-Protocol-v<版本>-update/` 里的 `Stronghold-Protocol/` 和 `.full/`)。
|
||||
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带);更新包还要之前各版本的完整包(工具自己读 zip,不需要 `unzip`;Releases 上可以重新下载)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
|
||||
+2
-1
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
|
||||
|
||||
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
|
||||
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29, the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30, the state described by this document; its rules supersede the corresponding 0.2.4 ones. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,3 +36,4 @@ normative lines it rewrote).
|
||||
| §27 | [history/0.2.2.md](history/0.2.2.md) | 0.2.2 — after the 0.2.1 release (2026-10-09) |
|
||||
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
|
||||
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
|
||||
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5 — after the 0.2.4 release (2026-10-12) |
|
||||
@@ -156,6 +156,11 @@ rebuilt.
|
||||
the source on the record's own ids, then the 下半 season (`act2autochess`). Rich-text tags and placeholders are the
|
||||
same as in Chinese, so `ui/richText.js` renders the English unchanged (a few official EN texts leave a tag unclosed;
|
||||
the parser runs it to the end of the text, as the game does).
|
||||
- **Corrections**: `tools/i18n/corrections-<lang>.json` (the pack's code: `en`, `ja`, `ko`, `zh-TW`; optional) is
|
||||
tried before the official data of that language, for an official translation that contradicts the Chinese text and
|
||||
the engine (exact match on the raw Chinese text; the plain twin is stripped from it; every entry carries a `_why:`
|
||||
note; `_`-keys are never written to the overlay). Only `corrections-en.json` exists. Counted as `correction` in the
|
||||
coverage report.
|
||||
- **Fallbacks**, in order: `tools/i18n/fallback-remake.json` (the remake's own strings: stage-name parts, item notes,
|
||||
the escaped levels' map names, the season name), `tools/i18n/fallback-pr70.json` (PR #70's translations of texts the EN client
|
||||
lacks: modules of CN-only operators, the 甄选 slots, 结算称号 criteria, emote labels), then `public/i18n/en.json`;
|
||||
|
||||
+34
-11
@@ -64,7 +64,7 @@ LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all hum
|
||||
```
|
||||
|
||||
**Deploy field (user playtest #5 item 7).** From the ROUND_START of a boss round (最终攻势 / 隐秘核心) a player deploys on
|
||||
its half of the boss field (`Match.deployFieldOf` → `'bossL'`, or `'bossR'` for the second player of a seat pair — the
|
||||
its half of the boss field (`Match.deployFieldOf` → `'bossL'`, or `'bossR'` for the second player of a drawn pair — the
|
||||
same pairing as the fields, `bossWaves` / `Match.bossGroupOf`, planned in `startRound` BEFORE the players' round start
|
||||
so R14 → R15 never re-checks a boss-half board against the normal field, and re-paired — with an immediate re-check —
|
||||
when a teammate quits before the fight): `board.js buildDeployMap`
|
||||
@@ -411,14 +411,14 @@ keeps it; `PlayerState.pieceRoundCount`; prefix keys with your module too) —
|
||||
first manual refresh that copy witnesses (player feedback after 0.1.0: "获得该干员后该回合的首次刷新" also stacks; a copy
|
||||
bought after selling one this round is a new copy and fires on its own first refresh [ASSUMED]).
|
||||
|
||||
Writes (all validated, never throw on bad input, never make funds / pools negative):
|
||||
Writes (validated, never throw on bad input; funds and available stock stay non-negative; authorized operator overdraw records an internal deficit):
|
||||
|
||||
| helper | effect |
|
||||
|---|---|
|
||||
| `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) |
|
||||
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0 |
|
||||
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0. During ROUND_START / SP_DRAFT / PREP, a positive gain also sends the owner an ephemeral `m.layer {playerId, bondId, add}` audio notification; no replay on resync or SETTLE backfill |
|
||||
| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain. `toTemp` puts it into temp, but a free hand slot pulls it in at the next recompute (temp holds overflow only, `PlayerState._fillHandFromTemp`) |
|
||||
| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item); `toTemp` as for grantChess |
|
||||
| `grantItem(id, { toTemp, requirePool=true })` | acquire an item (merges with an identical normal item); `toTemp` as for grantChess. `requirePool:false` bypasses stock only for fixed `SERVER_GAIN_EQUIP` gift families; every copy still counts in occupancy (§30.1). |
|
||||
| `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) |
|
||||
| `grantFreeRefresh(n)` | free refreshes (stack) |
|
||||
| `modifyPrice(delta)` / `setPrice(v)` | onPrice only: edit `ev.price` |
|
||||
@@ -436,6 +436,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
|
||||
| `offerBountyChoice(cards, sourceItemId)` | 教鞭 only: open a PERSONAL choice of up to three of `cards` (cards.bounty entries) for this player — shuffled once with the meta rng, kept in `m.private.personalChoice` until the owner confirms one (`g.choice { idx, choiceId }`) or the prep's deadline / an AI seat picks (§2.5); returns the intent result (`{ ok }`, or `{ error, detail }` for a phase that is not PREP, a Ready player, a choice already open or no card — before any rng draw or id, so the Art stays in the hand) |
|
||||
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
|
||||
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
|
||||
| `teamMedicGranted()` | Whether this match has a confirmed Touch selection, including an eliminated/departed holder; only the retained 外勤医疗 grant uses this exception ([ASSUMED], §30.14) |
|
||||
|
||||
### 2.5 Items: consume-on-equip and Arts
|
||||
Items whose data `kind` starts with `consume_on_equip` resolve through their `item:` handler's `onEquip` and are
|
||||
@@ -559,9 +560,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
stock (`ps.diyStock`: `poolCopies` of the slot's tier, 8 / 5 [ASSUMED]; none — `diyBanned` — when every bond of it is
|
||||
off this match; `initDiyStock` once the bans are drawn); `poolOf(baseId)` routes its copy accounting (buy, reward
|
||||
picks, merges, promotions, sells, temp, elimination; `invariants.js` checks `left + held = cap` per player). The
|
||||
shop's chess slots and the reward offers' temporary refreshes roll it with the shared pool (`pool.roll({ extra })`,
|
||||
copy-weighted like any chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6) [ASSUMED for the
|
||||
reward offers]; an effect, reward pool or 机变 card that grants the player a random operator draws its stock too
|
||||
shop's chess slots roll it with the shared pool (`pool.roll({ extra: diyRollEntries() })`, copy-weighted like any
|
||||
chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6). The promotion reward's temporary refresh
|
||||
(`pushRewardOffer` with no explicit ids) draws the same stock through `diyStockEntries()` under the reward tier
|
||||
`min(shopLevel + 1, 6)`, so a tier-5 自选 piece can appear when the 调度中心 reaches 4 and a tier-6 piece when it
|
||||
reaches 5 — one shop level before the ordinary shop, and not guaranteed. 卫戍协议/帮助 干员晋升 names one refresh one
|
||||
tier above the 调度中心 and no separate pool; the owner decided on 2026-10-12 that the refresh includes 自选 of that
|
||||
tier (0.2.0 had [ASSUMED] the shop-level gate for the reward too, DESIGN §25; §30.23 reverses it). An effect, reward
|
||||
pool or 机变 card that grants the player a random operator draws its stock too
|
||||
(effectsMeta `rollChess` / `rollPool`, the 驰援 fallback in choices.js: `ps.diyStockEntries()` — the roll's own tier
|
||||
rules, no 调度中心 gate, bonds through the player's view; 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」, 0.2.0), and
|
||||
信标 never sends one to a teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and
|
||||
@@ -674,6 +680,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
|
||||
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
|
||||
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
|
||||
Pairing uses a separate seeded shuffle once per match, before Final Assault prep; `bossPairs` retains partners and
|
||||
sides into Hidden Core. A departure preserves intact pairs and joins the remaining single players. The published
|
||||
`m.public.bossPairs` follows the preparation plan; camera, placement and green team frames use it, never new draws.
|
||||
Pool (`finalAssault.js bossPoolHp` → `GameData.bossPoolShare` → `gamedata.js bossPoolShareOf`, DESIGN §20.10,
|
||||
§25.13.4): one pool shared by every boss field (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"; notice 5114's
|
||||
"敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing it) = `bloodPoint[difficulty]` × the players
|
||||
@@ -731,6 +740,12 @@ smallest stat (坚若磐石 "目标生命值损失最少" = least `lpLost`). doc
|
||||
---
|
||||
|
||||
## 4. 联防 (Unite)
|
||||
|
||||
HP/SP restoration is completed after deployment and all `battleStart` effects. Living carry ratios remain exact,
|
||||
including below 1% or fractional HP under a smaller new maximum. As approved for the reported Egir bug, opening
|
||||
devour rebuilds attributes/mark chains without repeating HP loss or death/revive effects on a carried survivor.
|
||||
Forced-down carry still participates in attribute chains and stays down without using a revive slot. Fresh units
|
||||
without carry and other battle kinds retain normal devour damage; later redeploys do not reuse the old carry.
|
||||
Planned after the normal combats, at the end of the COMBAT_END pause from the players still in (`Match._afterCombat`, so a
|
||||
player who quit during the last field or the pause is neither a helper nor a leaker whose enemies re-enter; unite.js):
|
||||
helpers = `unite.js helperOrder` (research 08 §5, PRTS 卫戍协议/帮助 §联防阶段): ≤ `unite.maxHelpers` (2) perfect players
|
||||
@@ -768,6 +783,8 @@ leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team ro
|
||||
|
||||
## 5. Views (DESIGN §8.2 / §8.3) and deviations
|
||||
|
||||
The client's stored loadout and `m.private.loadout` / `ops` must agree after a successful sync. In `public/js/ui/loadoutSync.js`, edits and `welcome` invalidate preparation still awaiting game data, so an older payload cannot overwrite a newer reset on the session or seat, or enter the next match (DESIGN §16, DESIGN §30.17). Request reply ordering, debounce, close flush and retry/lock handling remain unchanged.
|
||||
|
||||
`m.public` (throttled ≤ 10/s, only sent when it changed) carries the DESIGN fields plus: `drawnDisabledBonds` (the 3+4
|
||||
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
|
||||
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
|
||||
@@ -927,13 +944,19 @@ choices are explicitly [ASSUMED]; DESIGN §28.15–16 records the source and val
|
||||
|
||||
`SharedItemPool` derives equipment availability from current ownership across all players: ordinary 1, upgraded 2.
|
||||
Equips, merges and returns cannot lose reservations. Consumptions/destruction release ownership [ASSUMED removal
|
||||
interpretation]. Shop/reward displays reserve nothing; rejected exhausted purchases retain funds and offers.
|
||||
interpretation]. Shop/reward displays reserve nothing; rejected exhausted **item** purchases retain funds and offers.
|
||||
整备 upgrades the purchased item only when `left` still has the golden's second copy; otherwise the item stays
|
||||
normal and the charge remains.
|
||||
`m.public.sp.cards[].soldOut` disables duplicates when a preceding choice used the final copy; timer/bot choices
|
||||
use the same availability. Non-shop effect-only equipment is unlimited.
|
||||
|
||||
Operator capacities use measured single-player exceptions and twice those counts in co-op; DIY remains private.
|
||||
`take(..., { overdraw: true })` records full occupation of a granted/promoted elite or a Mimic copy beyond capacity;
|
||||
normal takes remain bounded. Negative internal balance is a deficit, never negative available stock. Existing
|
||||
item selection probabilities and explicit grant weights remain unchanged.
|
||||
`take(..., { overdraw: true })` records full occupation, including authorized acquisitions beyond capacity.
|
||||
Already displayed operator shop offers remain purchasable after exhaustion (路标月报#2 2:50–2:57, #498);
|
||||
operator reward offers follow the same rule by the owner's 2026-10-11 decision. Future rolls still use available stock.
|
||||
Negative internal balance is a deficit, never negative available stock; selling first repays the deficit.
|
||||
|
||||
Fixed equipment gifts from `SERVER_GAIN_EQUIP` (卡涅利安、蜜蜡、缪尔赛思、耶拉、诗怀雅) may exceed the pool,
|
||||
including the same families awarded by 变形者. They still occupy ordinary/advanced 1/2 copies and can leave availability
|
||||
at zero after a removal. Ordinary shop, reward and random grants retain their limits. `requirePool:false` is not a
|
||||
universal equipment bypass. Rockrock/Flametail's measured weighted subsets remain subject to shared availability (§30.2).
|
||||
+10
-4
@@ -24,7 +24,7 @@
|
||||
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消;点「准备就绪」时商店自动收起、棋盘放大到全场,取消准备时商店再展开(资金旁的「收起」和商店标签「展开商店」随时可以手动切换)。 |
|
||||
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
|
||||
- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
|
||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值**先不变**(红字 + 「−N」标记待扣的点数,每回合至多 10 点),结算时才显示扣除后的数值(与官方一致,GitHub #479;0.2.4 及之前是立即显示扣除后的数值);联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
| 联防 | 见第 5 节。 |
|
||||
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
|
||||
|
||||
@@ -59,13 +59,15 @@
|
||||
## 3. 招募、整备区与晋升
|
||||
|
||||
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
|
||||
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
|
||||
- **刷新等待**:按下「刷新」后按钮会变暗,本次请求结束(成功或失败)后恢复可点;等待期间再点按钮或按 R 都不会重复发送,避免高延迟时连点多次扣费(GitHub #501)。
|
||||
- **公共卡池**:独立模拟 I–VI 阶的基础副本数分别为 12 / 14 / 18 / 16 / 8 / 5,少数干员有例外;同盟的公共干员库存是独立的两倍,自选干员仍用各自的私人库存。持有的普通/精锐干员占 1 / 3 份,商店展示不占份数。拿光后不能再刷到,但已经展示的干员商店牌和奖励牌仍可领取;超额部分也占库,卖出先抵消超额,淘汰时归还所持份数。
|
||||
- **装备库存**:普通/进阶装备通常占共享库存 1 / 2 份,道具商店和奖励领取时仍检查余量。缪尔赛思、耶拉等固定特质赠品仍占份数,但不会因库存已满停止赠送;拥有量降回上限以下后商店才有余量。商业包装方案的 2 份是同时库存,不是整局限买两次。
|
||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
||||
- **干员调配(技能、模组、潜能与练度)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有三个标签页:「干员调配」「干员持有」「自选编队」。「干员调配」把干员列成一张表,每名干员一行,各列是**技能**、精锐的**模组**和**潜能 · 练度**:直接点行里的技能图标、模组图标(「不装备」在最前,模组只在精锐时生效)或改两个下拉框就设好了;点头像或名字在右边打开详情,依次是潜能与练度、特质、技能(普通 Lv.4 / 精锐 Lv.7 两档数值可切换)、局内数值和模组(窄屏——宽度 1000 像素以下,例如横屏的手机——详情盖住列表,点「干员列表」返回)。**潜能**(1–6)和**练度**(未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级)按干员设置,同一名干员的普通、精锐和自选共用,默认潜能 6、精英阶段2-60级(维护者 2026-10-08 的决定:默认满潜、满加成)。官方规则:潜能取你自己持有的这名干员的潜能;练度是调度手册「加成情况」里的**自持有加成**——未精英化 +0%,精英1 攻击、防御 +5%,精英2 攻击 +10%、防御和生命 +5%,精英2 60 级三项 +10%,单独乘算,不和其他「攻击力 +x%」相加;未持有的特许干员在官方按潜能 1、没有加成,想照官方打就把它设成潜能 1、未精英化。补位的替补干员和自选的原型干员没有潜能和练度,设置对它们不生效。商店卡片、详情卡和「局内数值」都按你的设置显示;同盟模拟中队友的干员按队友的设置。和技能、模组一样是**局外设置**:「确认本局信息」结束前修改对本局生效,之后只保存到下一局。「恢复默认」「全部恢复默认」同时重置潜能与练度,「导出 / 导入」会带上它们,「干员调配」按钮上的数字也算上改过潜能或练度的干员。
|
||||
- **干员持有(补位)**:同一个界面的第二个标签页「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
|
||||
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
|
||||
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同。晋升奖励按奖励自己的阶级抽取,所以该阶自选可以比普通商店早一级出现:调度中心 4 级的五阶奖励可以出现五阶自选,5 级的六阶奖励可以出现六阶自选,不保证刷出;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
|
||||
- 每回合商店会自动刷新未冻结的格子。
|
||||
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;本回合新合成的精锐算作一名新的拉普兰德——即使合成用的几名本回合已经生效,精锐在本回合的下一次主动刷新仍会 +8。
|
||||
|
||||
@@ -124,7 +126,7 @@
|
||||
|
||||
## 7. 最终攻势与隐秘核心
|
||||
|
||||
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
|
||||
- **最终攻势**(第 14 回合;独立标准为第 9 回合):每局开始时随机两两配对(准备期到开战固定,隐秘核心沿用同一分组),每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);超过开战 120 秒后(实际秒数)全队每秒扣 1 点 LP;仅开战时有 AI 队友的队伍延后至超过 150 秒。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
|
||||
- **限伤**(最终攻势与隐秘核心,与官方相同):对领袖的**单次伤害达到 300000 或以上时整次作废**——这一下造成 0 点伤害,不计入领袖血条,也不显示伤害数字;不是截到 300000,而是一点都不算(299999 照常生效)。只针对领袖本体(含两人战场里的另一个领袖),领袖的部件、护卫、无人机和其他小怪不受影响,普通回合和联防也不受影响;部件把伤害等量转给领袖时,这一下也按一次伤害检查;无人机死亡令领袖失去的 2% 血条不算一次伤害,不受限伤影响(隐秘核心终极「假想敌:胄」3 或 4 人存活时每架为 43.2 万 / 57.6 万,按限伤会整次作废,本作仍然计入,属推断)。层数极高时领袖仍可能几秒内倒下(例如每名玩家的两个盟约都在 999 层,或所有激活的盟约都在 999 层),这来自官方按层数计算、没有上限的伤害倍率(谢拉格、奥术、精准等),官方社区也有「boss一秒死」的说法。
|
||||
- 最终攻势 / 隐秘核心的休整期和战斗中一样,**同组两人在同一个关底战场上**:你在自己的半场摆位(右侧玩家看到自己在右边,摆位左右镜像,干员相对领袖的朝向不变),队友当前的阵容显示在另一半场(只能看,随他调整实时更新);左侧成员栏里同组两人的头像带**绿框**,从回合开始一直到战斗结束。观战者查看其中一人时也会看到两人的阵容和站在关底的领袖。
|
||||
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
|
||||
@@ -174,10 +176,14 @@
|
||||
|
||||
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
||||
|
||||
**伤害数字**:标题画面、大厅、房间和对局里的「设置」有「显示伤害数字」(默认打开,每一次命中和治疗都跳出数字)和「官方伤害数字」(默认关闭)。打开「官方伤害数字」后,只在伤害达到 [PRTS 作战机制](https://prts.wiki/w/作战机制)「伤害红字(暴击)」的条件时显示红色整数(最终伤害不低于期望伤害的 1.5 倍;闪避了本应显示红字的一击时显示黄色 MISS)。关掉「显示伤害数字」则什么数字都不显示。无敌时官方客户端会显示红色 0,本作目前不会为这种未击中补一条伤害事件,因此这种红色 0 暂时看不到。
|
||||
|
||||
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
||||
|
||||
## 12. 反馈问题
|
||||
|
||||
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。
|
||||
|
||||
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
|
||||
## 13. 统计(本机)
|
||||
|
||||
+59
-11
@@ -81,7 +81,15 @@ summon that came in during the initial deployment (also one an operator's deploy
|
||||
the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field)
|
||||
the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then
|
||||
the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one
|
||||
field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
|
||||
field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地").
|
||||
|
||||
外勤医疗's prepared slot is a preparation view record, not a combat `Unit`. The `battleStart` handler creates either
|
||||
预备干员-医疗 or Touch after counting deployed elites. [ASSUMED] Its effective targeting order precedes ordinary
|
||||
initial deployments (`preBattleAggroSeq`); its actual `deploySeq` still identifies the later runtime spawn. Prepared
|
||||
slots on a shared field follow player order [ASSUMED]. A later `spawnMapChar` without this option keeps its ordinary
|
||||
summon order (§30.22).
|
||||
|
||||
Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
|
||||
|
||||
**Tick order:** scheduled callbacks → spawns → DP → buffs (+HP regen) → enemies (attack, move, block) →
|
||||
enemy tile index → allies (skill tick, attack) → projectiles → auto-redeploys → boss pool sync → `tick` hook →
|
||||
@@ -164,14 +172,18 @@ the old tile-key order), so a rotated layout plays the same (test/sim/facing-inv
|
||||
directions on an open field). 余 S3's fire wall runs through his tile perpendicular to his direction (his column facing
|
||||
RIGHT / LEFT, his row facing UP / DOWN; fx `firewall.axis` = `'col'|'row'`).
|
||||
`carryState: { hpPct, sp }` restores unite helpers' operators (HP ratio, and the SP = the official 技力, stored charges
|
||||
included: `unitsEnd` reports `skill.spTotal`, reset rebuilds the charges from it, and `skill.setSpTotal` sets it again after
|
||||
the `deploy` hook — PRTS "部署完成后…技力修改至与上一阶段结束时相同", so 独行 / 黄沙罗盘's deploy-time SP does not add to it;
|
||||
included: `unitsEnd` reports `skill.spTotal`; after all initial deployments and `battleStart` hooks, HP is restored
|
||||
against the new maximum and `skill.setSpTotal` restores the charges — PRTS "部署完成后…技力修改至与上一阶段结束时相同",
|
||||
so 独行 / 黄沙罗盘's opening SP does not add to it. Positive ratios are not raised to 1% or 1 HP;
|
||||
later redeploys keep those gifts); `carryState: { sp }` a board summon
|
||||
piece's SP only (PRTS "召唤物仅修改技力"). Nothing else is carried: a skill running at the end of the own combat enters
|
||||
switched off with the SP it had left — spent at its activation, 0 for a one-charge skill (`unitsEnd.skillActive` is
|
||||
reported, never carried; community report #34 — it used to restart for free).
|
||||
Timed deployment skills (`activateOnDeploy`) trigger once from the new deployment, consuming their deployment charge;
|
||||
they do not resume the previous skill state.
|
||||
The approved Egir carry policy rebuilds opening devour attributes while skipping its repeated HP loss on a living
|
||||
carry target; this prevents artificial knock-outs and revival-slot consumption before restoration. A fresh target
|
||||
without a previous state still takes that opening damage. Later combat, HP loss and redeploys follow the normal rules.
|
||||
`carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为
|
||||
退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before
|
||||
`battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after
|
||||
@@ -370,7 +382,7 @@ enemy: 隐匿 kept out, 起飞 not), 【污染秽蚀】 on every ally inside (fl
|
||||
`alliesInRadius`) — through `dealDamage`, so shields absorb a damage
|
||||
tick, damage-taken modifiers scale it and it counts for 受击回复 SP and TAKE_DAMAGE skills like any hit (§4; element fills
|
||||
excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击,
|
||||
萨卡兹枯朽战士's death — none when it dies silenced, its client template checks 沉默, DESIGN §25.18) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 …
|
||||
萨卡兹枯朽战士 / 组长's death — none when either dies while silenced. The owner's official-play recall of 2026-10-12 is that a silenced death creates no fog, which contradicts the PRTS / in-game text 「不可沉默」; only `enemy_1267_nhpbr` and `enemy_1267_nhpbr_2` share this PollutedDie ability, DESIGN §30.23) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 …
|
||||
同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so
|
||||
a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n. It is "可对空,无视无法选择": it also burns an airborne 起飞 ally
|
||||
(`ignoreSelect`), at the low-ground rate on a low tile. The other zones are no exception to 对地规避: a ground enemy's
|
||||
@@ -533,7 +545,8 @@ Getters: `s` (aggregated stats), `maxHp`, `atk`, `hpRatio`, `sp`, `spMax`, `canA
|
||||
flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate` (浮空 "变为空中单位") — never an enemy under 缚地 (flag
|
||||
`groundbind`, "目标变为地面单位") unless a 浮空 lifts it again; movement and pathing read `motion` (a hovering enemy keeps
|
||||
walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`);
|
||||
`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1).
|
||||
`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment and prepared 外勤医疗 map
|
||||
characters, §1).
|
||||
|
||||
克莱门莎 S2's kit stores its cabin in `unit.mem.clemntCabin`. Before boarding each tick, it removes passengers
|
||||
that died, became hidden / uncarryable or cannot path to the next cabin position, removes their carry buff and
|
||||
@@ -541,7 +554,7 @@ returns their current `weight` (including modifiers). The same tick's count and
|
||||
list and budget (DESIGN §28.27); this is local to the kit, not a change to general displacement.
|
||||
|
||||
**Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its
|
||||
position (`round(y)`, `round(x)`) is a range tile, in a radius when its position is (DESIGN §3). A huge enemy (巨型单位:
|
||||
position (`roundHalfEven(y)`, `roundHalfEven(x)` — PRTS 作战机制 "四舍六入五成双"; `Math.round` would take 2.5 to 3) is a range tile, in a radius when its position is (DESIGN §3). An enemy held on the field edge is inset by `FIELD_EDGE_INSET` (1/1048576) [ASSUMED] so that rounding keeps it on the edge tile; movement, knockback and a unit's standing tile for range keys still use `Math.round` (§30.27). A huge enemy (巨型单位:
|
||||
`enemy.hitArea` from data/enemies.json `hitArea` — 假想敌:胄 / 管 / 盐风主教昆图斯 / 阿利斯泰尔 / “萨米的意志”, PRTS
|
||||
"受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0") is hit anywhere on a rectangle `w` × `h` tiles centred on its position
|
||||
moved `dx` columns right / `dy` rows up: it occupies every tile the rectangle overlaps (`bodyKeys` — 5 × 3 tiles; PRTS
|
||||
@@ -891,7 +904,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
| `applyStatus(tgt, key, {duration, source, value, force, point, stackAs})`, `removeStatus(tgt, key)`, `resistOf(unit)` | §3 |
|
||||
| `applyStrongest(tgt, key, {duration, value, mods, source})` | §3 — "同名效果取最高" for a non-catalogue effect |
|
||||
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
|
||||
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
|
||||
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource, preBattleAggroSeq })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); `preBattleAggroSeq` is a negative priority reserved for a prepared stage map character, before ordinary initial units; returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
|
||||
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
|
||||
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, inTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data. A runtime spawn is never `inTotal` (§1.3 — the HUD capsule's own set) unless content passes `inTotal: true` explicitly |
|
||||
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
|
||||
@@ -1065,6 +1078,11 @@ has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnT
|
||||
(token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable
|
||||
skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`.
|
||||
|
||||
忍冬's 追凶 records the per-enemy start time and applies its arts addition in the same `damaged` callback,
|
||||
including the first instance (PRTS talent remarks; DESIGN §30.3). It does not refresh the ten-second window on
|
||||
repeated hits. Its `vulpisHunt` / `addition` tags and re-entrancy guard prevent the rider from feeding itself.
|
||||
`test/content/vulpis-first-hit.test.js` checks actual HP loss and damage events with naturally ready skills.
|
||||
|
||||
Timed deployment kits: 宴 S2 uses `bb.duration` and skill ATK mods, loses current HP once on landing, and converts normal
|
||||
physical attacks to arts only while S2 is active; S1 has no such conversion. 斯卡蒂 S2 uses its own record's
|
||||
`s.bb.duration` / `s.bb.atk`; DRE-Y revival in place does not restart it. 野鬃 S1 uses its record's `duration` /
|
||||
@@ -1114,7 +1132,7 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary:
|
||||
targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly,
|
||||
noRangeExtend /* the skill's range ignores the unit's 攻击距离 (rangeExtend): 信仰搅拌机 S3, PRTS 备注 */,
|
||||
showOwnRange /* rangeGrid only selects targets, no official range change: the card keeps the unit's own range */ },
|
||||
attack: { dmgType, atkScale, healScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul,
|
||||
attack: { dmgType, atkScale, healScale, splashRadius, splashScale, hits, funnelMul, funnelBodyHits, projectile, maxTargets, dmgMul,
|
||||
chain: {count, falloff, radius, sluggish}, heal: {mode, count, falloff, hpAtMost /* targets at or below this HP ratio only */}, onHitStatus: {key, duration, value},
|
||||
onHit(ctx), // once per attack (main target; target may be null for a splash landing)
|
||||
onEachHit(ctx) }, // per victim: main, every splash / chain victim — ctx += { dealt, kind:'main'|'splash'|'chain', isSplash, isChain, main, attackId }
|
||||
@@ -1199,10 +1217,11 @@ chess_char_2_07_a: (bb) => ({
|
||||
```
|
||||
|
||||
**3. Instant burst caster — 至简 `chess_char_3_13_a` 神工意匠** (bb `atk_scale 1.3, ct 2`; next attack 130 % arts, hits
|
||||
twice; 2 charges; the funnel profile already ramps damage on the same target):
|
||||
twice; 2 charges; the funnel profile's unreleased drones are additional damage that ramps on the same target, and this
|
||||
charged attack sets two body hits):
|
||||
```js
|
||||
chess_char_3_13_a: (bb) => ({
|
||||
skill: { kind: 'charges', attack: { atkScale: bb.atk_scale, hits: 2, dmgType: 'arts' } },
|
||||
skill: { kind: 'charges', attack: { atkScale: bb.atk_scale, hits: 2, funnelBodyHits: 2, dmgType: 'arts' } },
|
||||
}),
|
||||
```
|
||||
An AoE instant (德克萨斯 剑雨, bb `stun 2`: two hits of 120 % arts around her + stun) uses `onStart` instead:
|
||||
@@ -1341,7 +1360,7 @@ header of professions.js).
|
||||
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) |
|
||||
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
|
||||
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit |
|
||||
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
|
||||
| funnel | normal attack: one 100 % ATK body hit plus separate drone additional damage, 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max; modules can change init/cap). `funnelMul` holds the drone ramp; `dmgMul` applies to both. Extra drones (`hits`) do not duplicate the body; an unreleased skill attack can set `funnelBodyHits` (至简 S2: two body hits and two additions). Body and additions share an `attackId`, but only the body is `isAttack`; additions carry `addition, funnel`. [ASSUMED] body instances first at the existing impact, one ramp per volley. A skill that only buffs or retargets the normal attack keeps the body; `isSkill` does not remove it. `funnelBodyHits: 0` is drone-only (荒芜拉普兰德 S2). 耶拉 S2 detaches `funnelMul`: one 100 % `isAttack` body in range, the unreleased addition off, and `1 + attack@cnt` arts skill drones (`kjera:drone`, `isSkill`, not `isAttack`) that keep the lock until the enemy dies or the skill ends. [ASSUMED] Those drones use the trait ramp; her blackboard has no drone scale. The base form continues the per-target ramp (one step per volley on a shared target) and writes it back; the hidden form restarts each drone at init and does not write the scales back. [ASSUMED] No flight delay; an untargetable lock is dropped and retargeted; stun and disarm do not pause the drones. 澄闪's skills and 荒芜拉普兰德 S3 stay on their own `dealDamage` paths. A normal deployment resets target, scale and any per-target ramp map to init, including revival against the same living target. A free move (`moveRedeploy`, the same carry as a 秘术师's stored energy and 乌尔比安 S3) keeps the ramp. An in-flight drone recomputes the ramp on impact. 洛洛 S2 detaches this multiplier from new body shots and inherits the trait ramp in an independent skill tick: `rockr:drone`, arts, `isSkill:true`, `isAttack:false`. Older projectiles cannot change its local ramp. A living selectable lock need not remain in body range; the drone returns on a kill or when the skill ends, and ending hands the ramp back and restores the normal multiplier (§30.26) |
|
||||
| mystic | 秘术师 (PRTS 分支特性信息 秘术师, GitHub #181): at its attack check (the attack ready, able to act, not disarmed) with no valid target — no target, or its kit's `canAttack` false (深靛 never picks a bound enemy) — it stores one energy (`storeEnergy`, up to bb times: 3, 深靛's MSC-X 4), an attack action: the attack interval restarts; a full store idles with the attack ready. The energies leave with its next attack that happens (`releaseEnergy` in `performAttack`, after `beforeAttack`: an attack cancelled before its shot keeps them) and land with the main hit, one arts attack hit each (`hitsFn` reads the hit's `info.energy`); a redeployment holds none [ASSUMED]. 维伊 / 黑键 plug their own `storeEnergy` (转置能量, elite energies) into the same check |
|
||||
| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every selectable enemy on its range at once (blocked enemies included; a stealthed one only when revealed or blocked), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (secondary: Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; supporting: PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill several — the same 锁定攻击范围 shape as the 轰击术师, and PRTS 溅射半径一览 documents no splash radius for it (it omits the 撼地者 too, so this is not proof); no primary source states a target cap; community report E3: it used to be one bolt + a 1.1 splash). 卡涅利安's charged S1 keeps the skill-off trait, 不攻击 included (kit `canAttack`, PRTS 备注) |
|
||||
| physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) |
|
||||
@@ -1486,6 +1505,11 @@ objects), and call `checkInvariants` at the end. Run: `node --test test/sim/*.te
|
||||
|
||||
## 11. Tools
|
||||
|
||||
Equipment/fortress corrections (§30.3): Kjerag Ice and Tear Gas use actual enemy damage events, including DoT,
|
||||
not attack declarations or element-gauge/HP-loss events. Fortress fallback targeting and skill automation include
|
||||
the operator's own tile after blocked/ranged candidates; only explicitly enabled skills retain melee splash.
|
||||
`performAttack` captures each shot's `fortressMelee` flag for impact resolution, independent of later target changes.
|
||||
|
||||
`node tools/simrun.mjs --mode mode_multi_normal --round 5 --stage act2autochess_m01 --lineup "角峰@9,7 隐现@10,4 艾丝黛尔*@12,5" --ascii 10`
|
||||
prints a per-unit damage/DPS/kills/heal table (+ ASCII frames: letters = operators, digits = ground enemies per tile,
|
||||
`^` = flyers, `■` = crates). `--wave <template>`, `--seed`, `--content generic`, `--hpMul/--atkMul/--speedMul`,
|
||||
@@ -1496,6 +1520,30 @@ skill × module, every bond at its threshold and at 999 layers, every leader fie
|
||||
reduced to digests in `test/golden/*.json`; `test/golden.test.js` fails, naming the scenario and the field, when a
|
||||
digest moves. A refactor must leave them unchanged; `npm run golden:update` records an intended gameplay change.
|
||||
|
||||
Cross-engine check (`tools/sim-check.mjs`, `public/dev/sim-check.html`): a browser simulates its battles and the server
|
||||
re-simulates them (SP_VERIFY, takeovers), so every engine must give the bits Node gives. `node tools/sim-check.mjs`
|
||||
builds the golden battle corpus (roster, bonds, fields, standins, diy — 267 BattleSpecs) and traces it in Node into
|
||||
`public/dev/simcheck/data/` (git-ignored, about a minute): after every tick the state (battle time, RNG state and per
|
||||
unit alive / removed / deployed, x, y, hp, atkCd, SP, the five element gauges, damage dealt / taken, healing, blocking
|
||||
count) hashed to the exact IEEE-754 bits, one hash per 30 ticks; every 30 ticks `b.snapshot()` (what viewers are sent);
|
||||
every 150 ticks a bounded traversal of unit-reachable state (depth 8; definitions, profile/kit, battle pointers, functions,
|
||||
accessors and `seq` left out); and `resultDigest(b.result())`. `/dev/sim-check.html` — on any device that reaches the server, a phone
|
||||
included — runs the corpus (or its fast subset, or a family) with the client's own loader (`loadBrowserSim`) and
|
||||
compares block by block: a battle that parts is reported with the first interval where a difference was detected, the level (the outcome, the
|
||||
snapshot, the state, or the hidden state only), and a download of that block's state vectors;
|
||||
`node tools/sim-check.mjs --diff <file>` replays the same ticks in Node and names differing recorded vector entries
|
||||
(with both values' bits). It cannot locate view-only/deep-only differences absent from those vectors.
|
||||
Special float values, including negative zero, survive JSON export. `--engines chromium,firefox,webkit [--subset fast|all|<family>]` runs the page headless after
|
||||
the trace (Firefox and WebKit through the `playwright` package when it is installed, `npx playwright install firefox
|
||||
webkit`; Chrome through puppeteer-core otherwise) and exits 1 on a difference. The tracing, comparison and page
|
||||
(`public/dev/simcheck/core.js`, `ui.js`, `node.mjs`) have no dependency on this repository; `stronghold.js` is the part
|
||||
that knows the sim. `--engines chrome` uses the local executable selected by `CHROME_PATH` (including Edge).
|
||||
The reference, corpus and dump share a content identity over scenarios, served sim/shared code and data. Dirty changes
|
||||
invalidate the reference. The page rechecks inputs before runs/dumps; `--diff` rejects stale or mismatched inputs.
|
||||
An empty/unknown selection, max-tick truncation or runtime exception is an error, never a zero-battle success.
|
||||
All-green means the recorded fields, sampled snapshots and result hashes agree: it does not prove equality of every
|
||||
internal field, event, projectile queue or rendered frame. Verification results belong to the tested revision.
|
||||
|
||||
## 12. Data notes (simdata.js)
|
||||
|
||||
Defs are normalised from data/*.json (DATA.md) with research JSON as a fallback. Chess: `stats {maxHp, atk, def, res, aspd,
|
||||
|
||||
+38
-1
@@ -4,6 +4,12 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 9. Client rendering contract (public/js/render)
|
||||
|
||||
Bond-layer audio (#482): live own preparation gains arrive as `m.layer`; the displayed battle's `layer` tuples
|
||||
play the same `bondUp` cue. A gain of several layers plays once, including gains to inactive bonds. The official
|
||||
`ON_ACT1AUTOCHESS_ADD_BOND` bank allows one sound and replaces the oldest; there is no added cooldown. Existing
|
||||
bond activation cues use this same channel. State snapshots, settlement backfill and buffered field-entry events
|
||||
do not generate layer cues. Field entry, leaving the game and muting cancel playing or still-decoding layer audio.
|
||||
|
||||
`import { createFieldView } from './render/app.js'`
|
||||
|
||||
```js
|
||||
@@ -55,6 +61,12 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads
|
||||
|
||||
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
|
||||
|
||||
The owner's `prepMapChars` (外勤医疗's 预备干员-医疗, or another displayed map-character variant) are read-only
|
||||
preparation views: tapping one opens its existing operator-style detail card and highlights its attack range from
|
||||
its board-space position and facing, including on a mirrored boss half. They cannot be dragged, sold or retreated
|
||||
from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates
|
||||
the selected variant at battle start (§30.22).
|
||||
|
||||
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
|
||||
|
||||
---
|
||||
@@ -62,7 +74,8 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
## 13. Local-client art (optional, host-side)
|
||||
|
||||
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
|
||||
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the sand-theme variant.
|
||||
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the textures and materials of the sand theme (the theme of nine of the eleven battlefield scenes, §15).
|
||||
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.21): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
|
||||
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
|
||||
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
|
||||
@@ -77,6 +90,10 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
|
||||
Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; the official hot-update list contains none). The client **builds the board at runtime** from the stage grid using the map theme `arts/maps/map_autochess` (materials `MT_autochess` (albedo `TX_autochessi_D` 2048², normal `_N`, metallic/gloss `_M`, emission `_E`), `MT_autochess_common` (`common_D/_E`: device/gate icons), `MT_autochess_Transparent` (`Transparent_D/N/M/E`), `MT_autochessi_BG` (`TX_autochessi_BG`)), the background meshes `arts/maps/map_autochess/bkg_mesh.ab` (`S_Background_common`, `S_Background_shadow`) and `arts/maps/common/meshes/s_background_common.ab`, device meshes (`s_common_box_01` crate, `s_wind_device` blower, …), plus the standard gate/objective effects. The level's `options.configBlackBoard` carries the official camera framing (`default_pos_x/y`, `default_size`, `enemy_pos_x/y`, …).
|
||||
|
||||
Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`.
|
||||
|
||||
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
|
||||
|
||||
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
|
||||
- Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects.
|
||||
- One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board.
|
||||
@@ -155,3 +172,23 @@ It does not diagnose a particular user's GPU or persist a disabled renderer pref
|
||||
Community UI and recovery (§29.6): DIY reuses roster stats and skill previews; Swire's coins show current/cap. The lobby player chip opens statistics and the responsive header keeps optional controls from overlapping its title. Settlement's summary scrolls separately from Return. Failed board art can retry, and failed three.js imports use two bounded fresh URLs. Failed engine initialization can remount on online/visibility, at most twice consecutively, cleaning up each previous view; explicit fallback/2D stays selected.
|
||||
|
||||
Replacing a field renderer also resets the game screen's entered-view identity. Frames are buffered while the replacement loads; after subscribing, the local runner re-shows full current metadata/snapshot, or the server resends the watched field. Real local and server-streamed battles preserve the same simulation object and clock through recovery, including enemies spawned before the new renderer existed.
|
||||
|
||||
§30.4: the HP tower retains current HP until settlement and shows pending loss separately. Shop refresh is single
|
||||
flight per client until its promise settles (success or error); the button and R key share that state. DIY-only
|
||||
potential/cultivation changes are exportable; spectator member resolution includes the owner's DIY selection.
|
||||
Bond tooltips show banned visible members in one clamped scrollable compound box, stacked on narrow screens.
|
||||
An open list updates on member changes; long-press lists persist for reading and close on outside tap/Escape.
|
||||
The developer simulation checker and its bounded verification claims are specified in SIM §11 and history §30.5.
|
||||
|
||||
Operator attack clips play once per actual attack (§30.7), retaining their interval-based speed. A prepared wind-up
|
||||
is consumed without replay; a later queued attack may start during the previous recovery. Without another attack
|
||||
the actor returns to base/skill idle. Late operator events still start at the strike frame; no sim timing changes.
|
||||
|
||||
Connection recovery sits above the AI-away overlay and below global modals (§30.8), so a replaced session can
|
||||
be reclaimed by pointer while away. This does not change the stacking behavior of modals nested inside a screen.
|
||||
|
||||
§30.20: 设置 →「官方伤害数字」 defaults off. Off, damage numbers stay the typed every-hit popups. On, and only
|
||||
while 「显示伤害数字」 is also on, the field draws a red integer for a `dmg` tuple marked `'r'` and a yellow MISS
|
||||
for a dodge fx whose payload is `num: 'm'` (PRTS 作战机制 「伤害红字(暴击)」). Ordinary hits and heals draw no
|
||||
digits in that mode. The mark is computed in the sim; the random offset above the model is client-side. The
|
||||
simplified board uses the same mark with a fixed slot. The title footer links the project repository in a new tab.
|
||||
@@ -4,6 +4,19 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 5. Simulation (server/sim) — engine contract
|
||||
|
||||
联防 restoration (§30.13): the initial setup establishes buffs and devour attributes before restoring living
|
||||
operators' exact HP ratio and total SP; summons restore SP only. Opening Egir devour does not damage a carried
|
||||
survivor a second time. Forced-down carry stays out without spending an Egir revival; subsequent real knock-outs
|
||||
and redeploys are unchanged. No 1% / 1-HP floor is added to a positive carried ratio.
|
||||
|
||||
Expanded automatic skill areas (§30.11) include 司霆惊蛰 S2's current low-terrain flood when it strictly contains the
|
||||
initial area, 斩业星熊 S3's selectable air targets, and 予愿安洁莉娜 S3's initial 3-9 ∪ x-4.
|
||||
Permanent range extension, control/operation cooldown and targetability still apply; normal attacks are unchanged.
|
||||
|
||||
Prepared 外勤医疗 map characters (§30.22) keep an earlier aggro order than ordinary initial operators. Preparation
|
||||
can open their detail card and board-space range. Their `deploySeq` is still the later runtime spawn. The retained
|
||||
team grant (§30.14) is unchanged.
|
||||
|
||||
### 5.1 Battle API (implemented by the sim-core owner; consumed by match & content)
|
||||
|
||||
```js
|
||||
@@ -117,6 +130,12 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
|
||||
|
||||
### 5.5 Combat rules (engine)
|
||||
|
||||
- **Prepared 外勤医疗 slot**: 预备干员-医疗 is visible on the field during preparation. At `battleStart`, the elite count
|
||||
chooses either that medic or its Touch replacement for the same slot. [ASSUMED] This replacement does not move the
|
||||
slot behind the ordinary starting operators in effective deployment priority: both variants receive an earlier
|
||||
`aggroSeq`, while their actual `deploySeq` still records the later runtime creation and guards deployment-scoped
|
||||
effects. On a shared field, prepared slots follow player order [ASSUMED]. An ad-hoc map-character spawn during
|
||||
combat retains ordinary summon priority. See §30.22 and SIM.md §1.
|
||||
- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
|
||||
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free, and 乌尔比安 knocked out while his S3 has moved him always on his deployment tile when it is free (the owner's deviation of 2026-10-07, `Unit.downAtHome`, §25.17.3) — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
|
||||
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first — a 速射手's air priority (优先攻击空中单位) excepted, which comes before the enemy it blocks (the owner's decision of 2026-10-08, GitHub #220) — ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
|
||||
@@ -167,11 +186,40 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
|
||||
|
||||
---
|
||||
|
||||
Unreleased funnel drones deal additional arts damage alongside the ordinary attack (PRTS 分支特性信息 驭械术师, §30.24).
|
||||
The engine keeps a 100 % ATK body instance and a separate drone instance, each through mitigation and shields.
|
||||
`funnelMul` holds only the drone ramp; the attack's `dmgMul` (至简's talent) is shared once. Extra attached drones
|
||||
raise `hits`, not the body count; 至简's charged S2 sets `funnelBodyHits` to two. Both share one `attackId`, attack
|
||||
hook and SP / charge use; additions have `addition` and `funnel` tags and do not count as `isAttack`. [ASSUMED] Body
|
||||
instances precede additions at the existing projectile impact; consecutive hits keep one ramp per volley. A skill
|
||||
that only buffs or retargets the normal attack keeps the body; `isSkill` is not the switch. `funnelBodyHits === 0`
|
||||
is drone-only (荒芜拉普兰德 S2): the ramp stays inside those hits. 耶拉 S2 detaches `funnelMul` the way 洛洛 S2 does:
|
||||
one 100 % body inside her range, the unreleased addition off, and the released drones as separate skill hits that
|
||||
keep the lock until the enemy dies or the skill ends (§30.24). 澄闪's skills stay on their own `dealDamage` paths.
|
||||
|
||||
Funnel ramps return to the initial scale on a normal deployment, including automatic revival against the same living
|
||||
target (§30.25). The reset clears the current target, the scale and any per-target ramp map, and leaves other trait
|
||||
state alone. A free move (`moveRedeploy`, the same carry as a 秘术师's stored energy and 乌尔比安 S3) keeps the ramp.
|
||||
An in-flight shot recomputes the ramp when it lands, so a projectile launched before the reset can still step the new
|
||||
ramp.
|
||||
|
||||
洛洛 S2 releases its trait drone into an independent skill tick (§30.26). Acquisition uses the body's normal target
|
||||
selection; a living selectable lock stays valid outside the body's range and returns only when that enemy dies or the
|
||||
skill ends. The body retains its own ranged ordinary attacks. The released ramp inherits the trait state but is
|
||||
isolated from older projectiles, and while it is out the unreleased addition is off. Ending the skill returns that
|
||||
ramp to the trait and restores the normal multiplier and the overload cap. S1 and other operators' funnel attacks keep
|
||||
their existing paths. [ASSUMED] There is no flight delay; an untargetable lock is dropped and retargeted; body control
|
||||
does not pause the released drone.
|
||||
|
||||
---
|
||||
|
||||
## 7. Content modules (server/sim/content + match/effectsMeta handlers)
|
||||
|
||||
Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core).
|
||||
|
||||
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2).
|
||||
忍冬's 追凶 adds arts damage on the first qualifying instance too; the per-enemy window starts on that instance,
|
||||
and repeated hits do not extend it. The addition keeps its own tag and re-entrancy guard (§30.3).
|
||||
克莱门莎 S2 removes dead, hidden, self-bound / teleport-immune and unreachable passengers before checking new
|
||||
boarding in the same tick. Each released passenger returns its current weight to the cabin's remaining budget
|
||||
and frees its slot (§28.27); neither corpses nor failed carries reserve capacity.
|
||||
@@ -233,3 +281,14 @@ tile and attack-reach checks. Hand/temp rows, empty halves in single-player mode
|
||||
The general `onOwnBoard`/`onFieldBoard` contracts, other movement, summons and preparation placement remain unchanged.
|
||||
|
||||
香槟炸弹 also rejects ordinary allied auras and inspiration as a device, through its shared internal selection barrier (§29.11). This does not isolate other unhealable summons or alter its detonation.
|
||||
|
||||
§30.3 corrections: fortress targeting is blocked → ordinary range → own tile (selectable ground targets only).
|
||||
Automatic skill checks include that extra tile. Melee attacks are single-target unless the skill explicitly gives
|
||||
melee splash (Horn S2). Each shot retains its launch-time melee mode while flying. The bounty catapult's normal
|
||||
triple attack splashes at radius 1.5; 0.5 belongs to its 入戏 override. Clementia S3 accepts enemy erosion bursts only.
|
||||
Kjerag Ice and Tear Gas proc on actual carrier damage to enemies, including DoT/skill/splash; elemental gauge fill,
|
||||
HP loss, bare attack events and zero damage do not qualify. These changes do not alter other items' attack hooks.
|
||||
|
||||
Xi alone opts into hostile selection immunity during its ten-second post-rebirth invincibility (§30.8).
|
||||
The generic `invulnerable` flag remains damage protection only; other rebirth kits retain their existing choices.
|
||||
Presence-only and SP-only skill triggers are not suppressed merely because their enemies are unselectable.
|
||||
+18
-2
@@ -27,7 +27,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
|
||||
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
|
||||
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
|
||||
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
|
||||
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
R14 FINAL_ASSAULT (merged team LP; random pairs fixed for this match; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
real s after 120 real s, or 150 with an AI teammate at fight start (§29.1, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
|
||||
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
|
||||
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
|
||||
@@ -60,7 +60,7 @@ Summons (§20.1): an operator on the board sends its manually deployable summons
|
||||
|
||||
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
|
||||
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted, including the player's stocked 自选 of that tier — one shop level before the ordinary shop, §30.23; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
|
||||
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
|
||||
|
||||
@@ -106,6 +106,8 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
|
||||
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
|
||||
|
||||
Client sync preparation may await chess/backups data. A later edit (including restoring defaults) or `welcome` invalidates the earlier preparation: its payload, missing-data result or failure cannot send or change sync status. The current edit keeps the existing debounce, close flush, ordered requests and retry/lock handling (`public/js/ui/loadoutSync.js`).
|
||||
|
||||
**潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings.
|
||||
|
||||
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
||||
@@ -113,6 +115,10 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||
|
||||
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
||||
A payload that carries `ops`, including `ops: {}`, is refused before any write when chess.json or backups.json is missing (`loadoutOpsImportRefusal` in `screens/loadout.js`, the same check as a 自选编队 import). An older file with no `ops` still imports skills and modules once chess is ready and leaves the current 练度 in place.
|
||||
Export remains available for non-default `ops` alone, including operators outside the normal roster. The roster's
|
||||
adjusted count and reset scope stay limited to its own operators; the DIY export still carries picks only.
|
||||
|
||||
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
||||
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
|
||||
|
||||
@@ -123,6 +129,10 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
[ASSUMED] Bot purchasing applies one soft 18-point penalty to non-elite, non-DIY tier V/VI shared-pool cards whose
|
||||
active bonds overlap another living teammate's opening strategy. Human and AI teammates are treated equally; it
|
||||
neither reserves stock nor prevents a stronger purchase. Existing strategy presets and rehearsal counts are unchanged.
|
||||
A living teammate's selected strategy also soft-reserves its bonds for bot strategy selection and focus planning
|
||||
before any member is deployed (§30.28). The reservation ends when that teammate is eliminated. It does not hard-exclude
|
||||
a strategy, reserve stock, or stop an immediate merge. A reserved strategy keeps a quarter of its unreserved draw
|
||||
weight (`SOFT_BAND_RESERVE` = 0.25), so it can still be chosen. Mode-inactive bonds stay weight 0 (DESIGN §21.26).
|
||||
|
||||
[ASSUMED] AI reactions are cosmetic social choices, enabled by default and disabled with `SP_BOT_EMOTES=0`. Only
|
||||
mixed human/AI matches emit them, through the existing whitelist and cooldown. A human emote gets at most one eligible
|
||||
@@ -141,3 +151,9 @@ before mutation; data preparation failure leaves the old opening intact. This is
|
||||
mode rule. Unrelated result-screen changes from PR #408 are not included.
|
||||
|
||||
The shared DIY detail preview (stats, range, trait, talents, potential, cultivation and matching normal/elite skill descriptions) follows roster loadout resolution. Jessica shield placement is limited to her four orthogonal neighbors. The two Ptilopsis prep-gift copy exceptions are defined in §29.3 and META; no generic change to all copied conditions is implied.
|
||||
|
||||
Shared-stock corrections (§30.1–2): fixed `SERVER_GAIN_EQUIP` gifts still occupy ordinary/advanced 1/2 copies but
|
||||
may exceed capacity; `requirePool:false` bypasses equipment limits only for these fixed families. Already displayed
|
||||
operator shop and reward offers remain valid after exhaustion and record the deficit. Future rolls exclude empty
|
||||
stock; selling repays the deficit first. Item shop/reward claims and 整备 remain stock-limited. Rockrock and Flametail
|
||||
use the measured weighted subsets described in DATA and §30.2.
|
||||
+18
-2
@@ -40,6 +40,9 @@ S→C:
|
||||
interpolate linearly; nothing is inferred from the attack animation.
|
||||
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
|
||||
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
|
||||
- `m.layer {playerId, bondId, add}` — a positive prep layer gain, sent only to that player in ROUND_START / SP_DRAFT /
|
||||
PREP for audio feedback. No replay on reconnect/resync and no settlement backfill; battle audio uses `layer` tuples
|
||||
from the displayed field. Older clients can ignore this notification without changing gameplay.
|
||||
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)
|
||||
|
||||
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
|
||||
@@ -137,6 +140,19 @@ codes are UI metadata only, never authentication. No new network message or nick
|
||||
|
||||
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
|
||||
|
||||
Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in
|
||||
displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
|
||||
Preparation state carries read-only `prepMapChars` for 外勤医疗. The owner's entries can be clicked for a detail card
|
||||
and board-space range preview without adding a placement intent or a combat entity. Boss-mate and scouting metadata
|
||||
include them in displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
|
||||
disables picking and the server checks again before consumption.
|
||||
|
||||
`m.public.bossPairs: [[playerId, playerId?], …] | null` publishes the preparation pairing, first member left and
|
||||
second right. It is drawn once per match and retained for Hidden Core; intact pairs survive departures, singles
|
||||
pair up. Clients use it for preparation camera/placement/team frames; only older servers lacking the field use
|
||||
the legacy seat-order fallback. Viewing or reconnecting never draws a new pair.
|
||||
|
||||
[ASSUMED] Touch's confirmed initial team grant persists for this match after its holder is eliminated or leaves.
|
||||
The maintainer explicitly approved retaining this policy on 2026-10-12; its official-game lifetime remains unverified.
|
||||
The preparation medic and future battle's medic/Touch use the same entitlement; the receiving player must still be
|
||||
alive. A mere draft focus grants nothing; another match starts without it. Only `band_amedic` is retained in
|
||||
`contentInfo.matchBands`; `teammates` and other team strategies still filter eliminated players. The official text
|
||||
and initial/unlimited-round data do not explicitly settle the holder-elimination case (§30.14).
|
||||
@@ -274,10 +274,3 @@ enemy: picking emitted a unit click with no definition, instead of the tile clic
|
||||
engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and
|
||||
closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields
|
||||
does not carry old devices across. Gameplay and allied targeting rules are unchanged.
|
||||
|
||||
### 29.15 Ammunition readout
|
||||
|
||||
Active ammunition skills show remaining rounds and magazine capacity below the segmented bar (§10), including
|
||||
Laterano's extra rounds and refills. The renderer reads the existing exact snapshot counts rather than estimating
|
||||
them from SP. Missing ammunition snapshots, preparation and death hide the number. Dense magazines retain their
|
||||
numeric readout when the renderer omits cell separators. Combat rules and golden results are unchanged.
|
||||
@@ -0,0 +1,713 @@
|
||||
# DESIGN §30 — 0.2.5
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). This chapter records 0.2.5, released on 2026-10-12.
|
||||
|
||||
## 30. Community follow-up
|
||||
|
||||
### 30.1 Fixed gifts and already displayed operator offers
|
||||
|
||||
Reports #488/#491/#495, PR #496 by @2321Robin, and the owner's Muelsyse/Yela report: the shared equipment
|
||||
cap added in 0.2.4 incorrectly prevented fixed acquisition gifts. [路标档案馆:路标月报#2](https://www.bilibili.com/video/BV1eLXXBqEgF/)
|
||||
states both that equipment from any source occupies stock and that these operators' fixed gifts are unlimited.
|
||||
The owner confirmed on 2026-10-11: gifts still occupy stock, but exhaustion must never prevent the gift.
|
||||
|
||||
`GameData.isUnlimitedGiftItem` derives the families from `SERVER_GAIN_EQUIP`. The actual gift passes
|
||||
`requirePool:false` through `effectsMeta` to `acquireItem`; only those families bypass availability. Ordinary and
|
||||
advanced copies still count as 1/2. This covers Carnelian, Beeswax, Muelsyse, Kjera and Swire, and the same fixed
|
||||
family awarded by the Damazti strategy. It does not make every strategy's equipment unlimited. Shop and item
|
||||
reward purchases, random grants and 整备 upgrades keep their availability checks. When ownership exceeds the cap,
|
||||
removing one copy need not immediately reopen stock. The proposed PR's zero-occupancy model was not adopted.
|
||||
|
||||
#498: the same video's approximately 2:50–2:57 example describes nine owned 溯光星源 plus one
|
||||
already displayed card in each of four shops, allowing thirteen owned copies. Shop operator purchases therefore
|
||||
do not recheck availability; `acquireChess` records the full deficit and later rolls still exclude exhausted stock.
|
||||
The owner explicitly extended this behavior to already displayed **operator reward** offers; that extension is a
|
||||
maintainer decision, not footage directly establishing every reward type. Item purchases/rewards remain bounded.
|
||||
Funds, full-hand checks, per-offer sold flags, merge accounting and selling returns retain their ordinary rules.
|
||||
|
||||
Tests: `test/match/community-round30.test.js`, `shared-stock.test.js`, `garrisons_meta.test.js`.
|
||||
Normative rules: META shared inventory and helper contract; design/match economy.
|
||||
|
||||
### 30.2 Measured acquisition subsets
|
||||
|
||||
PR #497 by @2321Robin, same original research video's rule table: Rockrock's subset weights are ordinary hammer
|
||||
10; tremble/undying/haste/burn hammer 2 each; limited accelerator 1; shield drone 4; laser emitter 4. Flametail's
|
||||
subset is Wild Mane 10, Ashlock 10, Fartooth 3. These are weighted subsets, still constrained by shared stock.
|
||||
The generator owns these tables; `data/choices.json` is regenerated offline. Tests cover the table and exhausted
|
||||
eligibility rather than asserting that subset draws ignore ownership.
|
||||
|
||||
### 30.3 Damage and target-selection corrections
|
||||
|
||||
- Clementia S3's erosion-burst listener now requires an enemy target. The generic targetability helper does not
|
||||
check allegiance, so a friendly erosion burst previously scheduled a shot. Enemy bursts retain the same behavior;
|
||||
the mode's existing boss erosion immunity is unchanged.
|
||||
- Horn follows [PRTS 号角](https://prts.wiki/w/号角): blocked targets first, ordinary ranged targets next, then a
|
||||
selectable ground target on her own tile. Her skills' automatic target check includes that tile. Other adjacent
|
||||
blind tiles do not become valid targets. Ordinary/S1/S3 melee attacks are single-target; S2 explicitly retains
|
||||
melee splash. Each shot captures its melee mode, so switching targets cannot remove splash from a shell in flight.
|
||||
- The bounty [自制投石机](https://prts.wiki/w/自制投石机) has normal splash radius 1.5 and three hits. The old
|
||||
radius 0.5 came from its 入戏 override, not its ordinary attack. Tests cover adjacent/diagonal allies and an outside
|
||||
target. No claim is made here about unrelated attack cadence.
|
||||
- PR #502 by @Xinglan233: Vulpisfoglia's first qualifying damage instance marks the enemy and also deals 追凶's arts
|
||||
addition. Repeated damage does not extend the original window. Source: [PRTS 忍冬](https://prts.wiki/w/忍冬).
|
||||
- #489: [PRTS 盟约记录](https://prts.wiki/w/卫戍协议:盟约_下半/PRTS盟约记录) corrects both Kjerag Ice and Tear Gas
|
||||
from “攻击时” to “造成伤害时”. Only these handlers move to actual carrier damage, including DoT, skill and splash
|
||||
instances; bare attack events, elemental gauge fill, HP loss, zero damage and friendly targets do not trigger them.
|
||||
Existing probabilities/durations remain data-driven. Regression uses real Ascalon poison while she is disarmed.
|
||||
|
||||
Tests: `community-round30.test.js`, `item-damage-procs.test.js`, `vulpis-first-hit.test.js` under test/content,
|
||||
plus existing item/operator/fortress suites. Normative rules: SIM and design/engine.
|
||||
|
||||
### 30.4 Client information and input
|
||||
|
||||
- PR #503 by @Xinglan233: a loadout containing only operator potential/cultivation changes is exportable.
|
||||
- PR #505 by @Xinglan233: spectator `ownerOps` includes resolved DIY selection, so bond members have correct identity.
|
||||
- #479 / PR #504 by @robll-v1, owner approved: before settlement, the HP tower shows the current HP and a separate
|
||||
pending-loss marker; settlement updates the number. This supersedes the earlier live pre-deduction display choice.
|
||||
- #501 / PR #507 by @robll-v1, owner approved: a refresh request in flight disables another button/R request until
|
||||
it settles, including errors. This is a client interaction guard, not server-wide request idempotency.
|
||||
- #484 / PR #494 by @YuriRestia, owner approved: bond tooltips list this match's banned visible members. Main text
|
||||
and list share one viewport-clamped, scrollable container; narrow screens stack the sections. Dynamic member
|
||||
changes update an open tooltip even if the displayed count stays equal. Long-press lists persist for reading and
|
||||
close on outside tap or Escape; ordinary tooltips keep their short behavior.
|
||||
- PR #490 by @D-Roote: Korean UI corrections; the obsolete 3D failure key from the proposal is excluded in favor of
|
||||
the current recovery message. PR #499 by @YuriRestia: English Victoria text explicitly applies its bonus per item,
|
||||
through a narrow correction layer in the translation generator, retaining all placeholders.
|
||||
|
||||
Tests: refresh unit/E2E, loadout export E2E, match-info, watch-bonds, round30-tools E2E and i18n checks.
|
||||
Normative rules: PLAYING, I18N and design/client.
|
||||
|
||||
### 30.5 Developer simulation checker
|
||||
|
||||
PR #492 by @Cloudnyco, owner approved: `/dev/sim-check.html` replays generated BattleSpecs with the real browser
|
||||
loader against Node traces. The corpus is the golden battle families, excluding full matches. The CLI generates
|
||||
local ignored reference data and can run browser engines or compare an exported state block.
|
||||
|
||||
Review repairs: reject unknown/empty selections and unfinished runs; recover controls after start/step/result/dump
|
||||
errors; preserve negative zero and other special floats across JSON; verify shared content identity for specs,
|
||||
reference, runtime and data; reject stale inputs; escape reference text; allow long mobile result lists to scroll.
|
||||
Reports state the first **detected interval**, not an exact first divergent frame. State dumps can diagnose only
|
||||
their recorded fields; snapshot/deep-only differences may not expose a differing field. The bounded unit-state
|
||||
traversal and sampled snapshots do not prove equality of all memory, events or rendered frames. See SIM §11.
|
||||
|
||||
### 30.6 Rechecked reports without a gameplay change
|
||||
|
||||
Real browser loading verifies seven different Yan operators plus ordinary/elite Muelsyse carrying the converter
|
||||
and Yan item: count 9, tier 3 and two actual 炎佑. All 36 normal/advanced conversion-item pairings retain battle
|
||||
membership. The preparation/combat conversion feedback was not reproduced in these cases; this does not exclude
|
||||
an unreported configuration. Normal/hidden Helm missile tests confirm the existing shell and its 3×3 stun.
|
||||
|
||||
Lost Colossus and Zaaro exist in the source catalog, but the fixed bounty draw groups remain based on the earlier
|
||||
22-match evidence. The owner chose to retain those groups until direct grouping evidence is found; catalog
|
||||
presence alone is insufficient to assign a group or probability. No AI formation change from #452, WebRTC #487
|
||||
or built-in HTTPS #478 is included.
|
||||
|
||||
### 30.7 One visual clip per operator attack
|
||||
|
||||
#506: a single attack formerly kept an operator's animation looping for 1.4 attack intervals, allowing a second
|
||||
visual swing without another simulated attack. The owner approved playing one clip per real attack, retaining
|
||||
operator rhythm scaling, enemy speed rules, skill selection and damage timing. A queued wind-up can restart during
|
||||
the preceding attack's recovery; its actual attack consumes that preparation rather than replaying the clip.
|
||||
After recovery the actor returns to its base/skill-idle clip. With no look-ahead, operators still start at the strike
|
||||
frame: completing a late wind-up would delay the visual hit, so that separate policy is deliberately unchanged.
|
||||
Tests cover single/continuous fast attacks, real Provence metadata, skill idle and attack interpolation.
|
||||
|
||||
### 30.8 Xi invincibility and session recovery
|
||||
|
||||
#510: [PRTS “自在”](https://prts.wiki/w/“自在”) specifies ten seconds of invincibility after its five-second rebirth;
|
||||
the linked status also excludes hostile selection. Xi opts into `reborn.invincibleUntargetable` for that same
|
||||
window. The general `invulnerable` flag and other enemies' rebirth policies are unchanged. Attacks and
|
||||
target-requiring automatic skills wait; presence-only `SKILL_RANGE` and SP-only skills retain their existing
|
||||
trigger rules. In-flight damage and elemental fill still deal zero during invincibility. Tests cover all four
|
||||
trigger types, projectile arrival and resumed attacks after expiry.
|
||||
|
||||
PR #511 by @RainoutOvO: the connection recovery banner now sits above the AI-away overlay, so “continue here”
|
||||
remains clickable after another page replaces the session. It remains below global modals. Tests use actual
|
||||
pointer hit-testing and clicks, including a global confirmation dialog over the recovery button.
|
||||
|
||||
### 30.9 Golden changes
|
||||
|
||||
Before the follow-up changes below (§30.11 onward), all 287 scenarios were regenerated, with no additions or deletions;
|
||||
45 differed from 0.2.4. This table records that initial candidate. Follow-up changes are recorded separately below.
|
||||
Each cause below was checked by an isolated counterfactual rerun: reverting the named change or combination
|
||||
restores the old digest exactly. Shared RNG and later economy/target choices can amplify a local correction.
|
||||
The two co-op paths involving Rockrock can mask a damage-item effect when each is reverted alone; their combined
|
||||
rollback, not mere operator presence, establishes the attribution.
|
||||
|
||||
| Cause / combination | Changed scenarios |
|
||||
|---|---|
|
||||
| catapult normal splash radius | `roster-013`, `standin-06` |
|
||||
| catapult normal splash radius; Kjerag Ice / Tear Gas damage triggers and resulting RNG order | `roster-040` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order | `bond-maniShip-high`, `boss-boss_2-pair`, `boss-boss_3-pair`, `boss-boss_5-pair`, `coop2-ABYSS-10-boosted`, `coop2-NORMAL-70-diy`, `hidden-boss_8-pair`, `hidden-boss_9-solo`, `roster-005`, `roster-012`, `roster-019`, `roster-033`, `roster-047`, `solo-ABYSS-1`, `solo-FUNNY-1`, `solo-FUNNY-2`, `solo-HARD-1`, `solo-NORMAL-1`, `solo-NORMAL-2`, `standin-07` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Fortress own-tile targeting / melee splash | `coop2-HARD-4` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Fortress own-tile targeting / melee splash; Rockrock acquisition-subset weights; Vulpisfoglia first-hit addition | `coop3-ABYSS-5`, `coop4-ABYSS-7` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Rockrock acquisition-subset weights | `coop2-NORMAL-8-serverrun`, `coop4-FUNNY-6` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Rockrock acquisition-subset weights; Vulpisfoglia first-hit addition | `coop2-NORMAL-1-coverage-standins`, `coop2-NORMAL-3` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Vulpisfoglia first-hit addition | `coop2-NORMAL-5-coverage-diy`, `roster-026` |
|
||||
| Fortress own-tile targeting / melee splash (including Horn and Ashlock) | `boss-boss_5-solo`, `roster-004`, `roster-023`, `roster-032`, `roster-034`, `roster-043`, `solo-HARD-9-boosted` |
|
||||
| Vulpisfoglia first-hit addition | `bond-soloShip-low`, `bond-yanShip-low`, `roster-007`, `roster-009`, `roster-039` |
|
||||
| Xi post-rebirth hostile selection immunity | `roster-011` |
|
||||
|
||||
The fixed-gift, displayed-offer overdraw, Flametail-subset and Clementia changes do not move these corpus
|
||||
digests individually; their dedicated regressions cover the reported cases. UI, translation and developer-tool
|
||||
changes do not alter simulation rules. The 140 DIY-only corpus cases retain their previous digests.
|
||||
|
||||
### 30.10 Bond-layer audio
|
||||
|
||||
#482: the client only sounded an increase in active bond count, missing actual layer gains in prep and battle.
|
||||
The official audio table maps `ON_ACT1AUTOCHESS_ADD_BOND` to `act1autochess_b_ui_buffup`, with
|
||||
`maxSoundAllowed=1` and `popOldest=true`. The supplied [match recording](https://www.bilibili.com/video/BV1VxwNzZEqK/)
|
||||
also contains the cue around Matterhorn acquisition and battle layer gains. The maintainer approved that bank's
|
||||
single-channel replacement policy with no extra cooldown; the recording alone does not settle every timing edge.
|
||||
|
||||
Own live prep gains now send an ephemeral `m.layer`; the displayed battle's existing `layer` tuples use the same
|
||||
audio channel. One multi-layer gain is one cue, inactive bonds included; capped/zero gains are silent. New cues stop
|
||||
the previous source and invalidate its pending decode. Field entry, exit and mute cancel pending/playing cues.
|
||||
Snapshots, resync and settlement bookkeeping do not synthesize gains. Existing activation audio shares the channel.
|
||||
The change does not affect layer amounts, simulation events or RNG. Tests cover server unicast/phase/cap behavior,
|
||||
asynchronous replacement and cancellation, muted/missing audio and late field entry.
|
||||
|
||||
### 30.11 Automatic skill-area follow-up
|
||||
|
||||
Reports described Kroos the Keen Glint and Degenbrecher waiting for enemies to reach a smaller range. With full SP,
|
||||
normal/elite forms and all four facings, Kroos S1/S2 cast at the outer normal range and Degenbrecher S2/S3 cast in
|
||||
their own skill range outside normal attack range. The supplied still image does not establish SP, cooldown or
|
||||
the running client version, so those reports are not marked fixed. The documented operation cooldown and Kroos's
|
||||
normal-attack timing still apply.
|
||||
|
||||
The broader real-record audit covered 1,850 manual-skill configurations and 2,232 directional trigger cases.
|
||||
It found three independent omissions under the previously approved expanded auto-cast policy:
|
||||
|
||||
- 司霆惊蛰 S2 has a terrain-derived range with no static rangeId. Its extra trigger now uses the same low-tile
|
||||
flood and permanent range extension as the running skill, only when it strictly contains the initial range.
|
||||
High ground, field bounds, normal attacks and its SEARCH rule remain unchanged.
|
||||
- 斩业星熊 S3 can attack flyers while active, but its trigger inherited the inactive ground-only profile. Its
|
||||
actual expanded area now accepts selectable flyers, like the existing 陈 S3 exception.
|
||||
- 予愿安洁莉娜 S3's opening area is 3-9 plus the surrounding x-4. Its ACTIVE_RANGE trigger now includes that same
|
||||
union, without extending it to a future flight destination or changing its anchored post-move attack area.
|
||||
|
||||
These changes preserve SP, control and operation cooldown gates and do not admit stealth/sleep/untargetable enemies
|
||||
to an ACTIVE_RANGE condition. Tests cover all DIY forms, facings, ground/air targets, permanent extension and gates.
|
||||
|
||||
### 30.12 Random boss partners, retained for Hidden Core
|
||||
|
||||
The maintainer requested random pairing each match and explicitly chose to retain partners for the hidden boss.
|
||||
A separate seeded shuffle assigns pairs and left/right sides before Final Assault preparation. The same plan
|
||||
drives prep previews, legal deployment maps and the actual battle. Hidden Core retains it; a departure keeps intact
|
||||
pairs and pairs up the remaining single players. A new Match starts a fresh draw; a repeated seed remains reproducible.
|
||||
|
||||
`m.public.bossPairs` supplies the actual plan to the client camera, placement and green partner frames; seat-order
|
||||
inference survives only as compatibility with older servers missing this field. The audit checks coverage, saved
|
||||
pair stability and actual fields instead of recomputing seat pairs. Lowest-seat connected-human authority and
|
||||
other-pair watch restrictions are unchanged. Regression includes 2/3/4 players, departures, hidden continuation,
|
||||
mirror coordinates, reconnects and real-browser prep-to-battle views.
|
||||
|
||||
### 30.13 联防 HP/SP restoration and Egir opening devour
|
||||
|
||||
The report was reproduced through normal BattleResult → `uniteBattleOpts` → a new battle: an Egir survivor's
|
||||
carried HP was charged for opening devour again. Some survivors died and revived at full HP, consuming a revival
|
||||
slot and resetting SP; others lost HP twice. Two 1% clamps also raised legal low HP, and a 1-HP floor changed the
|
||||
ratio when the new maximum was smaller.
|
||||
|
||||
The maintainer approved reconstructing Egir marks/base-ATK/block attributes without repeating opening devour HP
|
||||
loss on a carried survivor. Deployment and battle-start effects establish the new stats, then the living operator's
|
||||
exact positive HP ratio and total SP are restored. Forced-down carry still contributes to the attribute chain and
|
||||
stays down without spending a revival slot. Summons carry SP only; previous active skills are not resumed. Normal,
|
||||
boss and fresh no-carry units keep their usual devour damage, and later knock-outs/redeploys work normally.
|
||||
|
||||
[PRTS 帮助](https://prts.wiki/w/卫戍协议/帮助) specifies restoring HP ratio and SP after deployment. The detailed
|
||||
suppression of repeated opening death/revive effects is the approved implementation of that contract, not a claim
|
||||
that the wiki documents each internal callback. Tests include real carry construction, prior full-health food that
|
||||
would otherwise die anew, remaining revival slots, values below 1%/1 HP, and ordinary/boss behavior.
|
||||
|
||||
### 30.14 Touch's team medic after its holder is eliminated
|
||||
|
||||
On 2026-10-12 the maintainer explicitly approved retaining the implementation, including both elimination and
|
||||
voluntary departure after a confirmed selection. This resolves the local policy choice, not the official-evidence gap.
|
||||
|
||||
The observed loss happens in newly constructed fields and the next preparation, not by removing an already spawned
|
||||
medic from a teammate's ongoing battle. Both prep and battle eligibility formerly searched only living holders.
|
||||
|
||||
[ASSUMED] A confirmed Touch selection grants the team medic for this match even after that player is eliminated
|
||||
or leaves. Official data marks the effect initial and unlimited in rounds and describes a medic on every player's
|
||||
field, but does not explicitly document the holder's elimination. This local candidate adopts the persistent grant
|
||||
under the maintainer's request to make evidence-based local choices pending review. A draft focus is insufficient;
|
||||
a confirmed choice followed by departure counts. New matches have no inherited grant. Recipients must be alive.
|
||||
|
||||
Only Touch is retained in `contentInfo.matchBands`; ordinary teammate contexts and other eliminated strategies
|
||||
stay excluded. Prep still displays the reserve medic, with the battle replacing it by Touch at the existing elite
|
||||
threshold. Tests cover LP elimination, departure, both medic variants, draft confirmation, fresh matches and the
|
||||
normal/unite/boss/hidden BattleSpec paths.
|
||||
|
||||
### 30.15 Other reports rechecked without changing their rules
|
||||
|
||||
- 联防 order: [PRTS 帮助](https://prts.wiki/w/卫戍协议/帮助) includes downed operators in deployed headcount. The
|
||||
implementation reads deployed count, not deployment capacity. Nine deployed with casualties may rank ahead of
|
||||
eight survivors; the active-bond and layer tie-breaks retain their existing documented uncertainty.
|
||||
- 乌尔比安 S3: [PRTS 乌尔比安](https://prts.wiki/w/乌尔比安) gives the next tile beyond an occupied anchor landing
|
||||
as a fallback before his own tile. The pictured report alone does not contradict this; his existing own-board,
|
||||
occupied/downed reservation and return rules remain unchanged.
|
||||
- 叙拉古: fixed-layer cases last exactly `32 + 0.4L` game seconds, which is half as many real seconds at 2×. The
|
||||
formula tooltip reads current layers while an already deployed buff retains its initial duration/ASPD; a new
|
||||
deployment reads the new layers. No direct source settled whether official in-battle gains should extend the old
|
||||
buff, so that separate uncertainty is retained; durations are not doubled globally.
|
||||
- 塑心: hidden-layer bonds can still receive layers under the documented PRTS rule (§25.21.6). The old exclusion
|
||||
was specific to 余's strategy (§27.21), not a global ban. Selecting the highest hidden layer count is retained.
|
||||
- Push/pull: 176 force/mass cases and real Degenbrecher boss cases did not reproduce a weight bypass. Mode leaders
|
||||
retain their existing displacement guard; an ordinary `rank=BOSS` enemy is not automatically the same as a
|
||||
protected mode leader. Weight, static-body and immunity checks remain. 余 S2 can teleport 铳 in one step under its
|
||||
separate teleport rules; this is a possible source of the reported movement, not an identification of the screenshot.
|
||||
Boss trigger-edge tests distinguish a large rendered model from its actual hit area; the still image alone does
|
||||
not establish a missed auto-cast.
|
||||
|
||||
### 30.16 Follow-up golden changes
|
||||
|
||||
All 287 scenarios were recomputed after §30.11–30.14. Only five match digests change from the preceding candidate:
|
||||
`coop2-NORMAL-3`, `coop4-FUNNY-6`, `coop2-ABYSS-10-boosted`, `coop2-NORMAL-70-diy`, and
|
||||
`coop2-NORMAL-5-coverage-diy`. In each, restoring only the old ordered boss pairing in an isolated process reproduces
|
||||
the preceding digest exactly; the changed partner/side also changes preparation choices and later combat totals.
|
||||
All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these
|
||||
additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this
|
||||
corpus; their dedicated regressions cover the reported and newly found cases.
|
||||
|
||||
### 30.17 Loadout sync preparation
|
||||
|
||||
PR #540 by @Xinglan233. Restoring defaults while a loadout sync was still waiting on backups.json could put the
|
||||
previous skill back on the server. `prepare()` assigned its request number only after the await, so the older
|
||||
payload was treated as the newer one. A stale missing-data result or a failed download could also replace a sync
|
||||
the reset had already finished.
|
||||
|
||||
`installPrefSync` now keeps a preparation revision. An edit of a watched field, including restoring defaults, and
|
||||
`welcome` increment it before the new flush is scheduled. A preparation that started at an older revision does not
|
||||
send and does not change sync status when it finishes, whether it built a payload, found the data missing, or
|
||||
failed. Debounce, the close flush, request ordering and the RATE / lock retry stay as they are. The same engine
|
||||
covers 干员持有 and 自选编队. Golden unchanged. Tests: `test/ui/loadout-sync-stale.test.js`.
|
||||
|
||||
### 30.18 Loadout import with ops while backups are missing
|
||||
|
||||
PR #538 by @Xinglan233. Importing a loadout file that includes `ops` checked only that chess.json was loaded.
|
||||
`cultivationCharIds` then dropped 自选 operators, which only backups.json names, and the import still saved. That
|
||||
cleared a 练度 the file was not meant to erase. `ops: {}` is the same replacement.
|
||||
|
||||
After a successful parse, `loadoutOpsImportRefusal` refuses the whole import with the 自选编队 data check
|
||||
(`importRefusal('diy')`) before any write. A file with no `ops` still imports skills and modules once chess.json is
|
||||
ready and leaves the current 练度 in place. Golden unchanged. Tests: `test/ui/loadout-ops-import.test.js` (node, no
|
||||
browser).
|
||||
|
||||
### 30.19 Player package scripts and doc links
|
||||
|
||||
GitHub #531 and #530. The player zip copied the repository `package.json`. `npm test` is `node --test`, and `test/`
|
||||
is not in the zip, so it reported 0 tests and exited 0. Nine other scripts name files or tools the zip does not
|
||||
contain (`golden`, `golden:update`, `build-data`, `check:imports`, `ci`, `package`, `package:lite`, `lint`,
|
||||
`typecheck`). README, CHANGELOG, NOTICE, THIRD-PARTY-NOTICES, PLAYING and DEPLOY also keep relative links to
|
||||
developer docs, pictures and `../../releases/latest`. Twenty-three of those, in README, PLAYING and DEPLOY, point
|
||||
at developer files.
|
||||
|
||||
The stage rewrites `package.json` only. A script stays when every `node <file>` it names is in the zip and it names
|
||||
at least one (`start`, `dev`, `setup`, `doctor`, `launch`, `postinstall`, `vendor`, `assets`). The others are
|
||||
removed, so `npm test` is a missing script and exits non-zero. The repository `package.json` is unchanged. The same
|
||||
stage rewrites those six Markdown files: a relative link whose target ships stays relative; a picture becomes
|
||||
`https://github.com/sganggs/Stronghold-Protocol/raw/v<tag>/<path>`; another file becomes `.../blob/v<tag>/<path>`;
|
||||
`../../releases/latest` becomes `.../releases/latest`. The tag is `v` plus the package version with a trailing
|
||||
`-dev` removed. [ASSUMED] an unreleased `0.2.5-dev` build points those links at `v0.2.5`, the tag that version will
|
||||
use. Source copies in the git checkout stay relative. Golden unchanged. Tests: `test/package-player.test.js`,
|
||||
`test/package.test.js`, `test/docs-paths.test.js`.
|
||||
|
||||
### 30.20 Official damage numbers and the title-page repository link
|
||||
|
||||
Issues #412 and #543. #412's font swap, element-colour pass and bold crit type are not part of this change.
|
||||
The new setting 「官方伤害数字」 (`officialDamageNumbers`) defaults off. While it is off, every rounded hit and
|
||||
heal still pops the existing typed number (merge, colours, 90-number ceiling, sparks and slashes unchanged).
|
||||
Turning 「显示伤害数字」 off still hides every digit, including official ones. The value is sanitised with the
|
||||
other settings (a missing or non-boolean saved value stays off) and copied into the live field view.
|
||||
|
||||
The official rule is PRTS [作战机制](https://prts.wiki/w/作战机制) 「伤害红字(暴击)」 (fetched 2026-10-12): show a
|
||||
red integer of the final damage when 最终伤害 ≥ 期望伤害 × 1.5, or when a skill forces red. Expected damage follows
|
||||
the normal formula except that DEF/RES penetration, post-mitigation bonuses (temporary attack-scale increases,
|
||||
damage-scale increases such as 脆弱, amount rewrites, barrier absorption), final-damage resets and special HP
|
||||
mechanisms are not counted. Invulnerable targets multiply expected damage by 0, and 0 ≥ 0 × 1.5 still shows red 0.
|
||||
A dodge of a hit that would have been red shows yellow MISS (`#ff9000`) instead. After 卫戍协议:盟约 the official
|
||||
client no longer caps red numbers at 10; this client does not restore that cap. The existing 90-number ceiling
|
||||
stays as a renderer limit [ASSUMED: 「无限制」 means the old cap of 10 is not brought back].
|
||||
|
||||
In this sim the comparison runs inside `dealDamage` and does not change HP, shields, hooks or RNG:
|
||||
|
||||
- Expected damage is mitigation of `dmg.amount` with penetration forced to 0, times `dmg.mul` only.
|
||||
- The compared total is the pre-shield amount after source and target multipliers. The integer on the existing
|
||||
`dmg` tuple — what both modes display — is what remains after shields and an `hpDamage` rewrite.
|
||||
- A qualifying tuple that already fires (`amount >= 0.5`, not silent) gains a fifth element `'r'`. Other tuples
|
||||
stay four elements. Element gauge fills are not marked. Heals are not marked, so official mode hides them.
|
||||
- A dodge that already fires sets `num: 'm'` on its fx payload only when the same test says the landed hit would
|
||||
be red. That test does not roll RNG again and does not refresh a dirty attacker's stats (a dirty `source.s`
|
||||
read rescales HP).
|
||||
|
||||
[ASSUMED] gaps, left unmarked rather than guessed:
|
||||
|
||||
- `dmg.amount` already includes the attack's stated ATK × 攻击倍率 (`atkScaleMul` is baked in by callers). It is
|
||||
not divided back out. `dmg.mul` stays on both sides of the comparison.
|
||||
- Cached-ATK expected damage (raw ATK, ignoring the attack scale and the target's DEF/RES), forced red, blue
|
||||
numbers and yellow BLOCK are not represented on DamageInfo. The three wiki examples are not hard-coded.
|
||||
- An invulnerable or sleeping hit, and a qualifying total below 0.5, still emit no `dmg` event, so official mode
|
||||
cannot show that red 0. Emitting one would increment `events.dmg` and could play an impact. The renderer can
|
||||
draw a red 0 when a tuple `['dmg', id, 0, type, 'r']` is supplied. Liftoff and a cancelled or hit-capped attack
|
||||
are not red 0.
|
||||
- PRTS does not say that ordinary hits are silent. That is the requested setting: official mode shows only the
|
||||
marked red integer and the yellow MISS. The sentence in the issue comment is not a PRTS sentence.
|
||||
- The random position is about ±0.35 tiles across and 0.15–0.55 tiles above the head. The simplified board keeps
|
||||
its fixed slot and only changes the colour / the MISS label.
|
||||
- Sparks and the melee slash stay in both modes.
|
||||
|
||||
#543: the title footer carries a text link labelled 「项目仓库」 to
|
||||
`https://github.com/sganggs/Stronghold-Protocol`, `target="_blank"` and `rel="noopener noreferrer"`. It does not
|
||||
replace 设置 → 问题反馈.
|
||||
|
||||
Tests: `test/sim/official-damage-numbers.test.js`, `test/ui/official-damage-numbers.test.js`,
|
||||
`test/render/official-numbers.test.js`. Golden digests do not move: no new event kind is emitted, and the extra
|
||||
tuple field is not part of the digest. Player notes: PLAYING; client notes: design/client.md.
|
||||
|
||||
### 30.21 Official battlefield scenes (#532)
|
||||
|
||||
Report: the web board draws the metal theme for every battlefield. The reporter asked for the sand theme.
|
||||
PR #533 by @andrew05060414.
|
||||
|
||||
Findings (2026-10-11; the official hot-update lists and asset indexes, read from the CDN, and the scene bundles of
|
||||
version `26-04-13-12-31-42_e9b560`):
|
||||
|
||||
- The official board is not assembled at run time. Every battlefield is a prebuilt scene bundle, shipped only while
|
||||
the season runs. DESIGN §15 said otherwise from an off-season list and is corrected there.
|
||||
- Which art a battlefield uses is the dependency list of its scene bundle: the sand theme for act1 m01–m07 and act2
|
||||
m02 / m04, `map_lakegarden` + `map_syracuse_metro` for act2 m01, `map_kxmr` + `map_lm_center` for act2 m03. The
|
||||
same in the 上半 version of 2025-11 and the 下半 versions of 2026-03 / 04. No battlefield uses the metal theme.
|
||||
- The sand theme bundle holds no mesh because the models are baked into each scene ("Combined Mesh (root: scene)").
|
||||
- A scene's three sub-graphics `HIDDEN@1…3` are the left normal field, the right normal field and the boss field.
|
||||
- `python3 tools/local-extract/fetch_scenes.py --list` against that Android hot-update list names 33 bundles and
|
||||
22,801,308 bytes. That is the compressed CDN download, not the extracted size in the full zip.
|
||||
|
||||
Change: `tools/local-extract/scenes.py` exports a battlefield's scene (meshes in board space, materials, key light,
|
||||
lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/assets/local/scene/<stageId>/`;
|
||||
`extract.py --only scene` runs it, reading the scene bundles from the client, `--scenes <dir>` or
|
||||
`.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official
|
||||
CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art
|
||||
manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the
|
||||
fallback and still draws the devices, gates, field edges and terrain overlays. Chunks of the parts on screen that
|
||||
share a material and a lightmap page are one mesh (`mergeSceneChunks`), so a battlefield such as act2 m02 (36 chunks,
|
||||
5 lightmap pages) stays inside the board's draw-call budget. A switched-off HIDDEN part is left out of that mesh.
|
||||
|
||||
Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip
|
||||
includes the extracted files. A lite package or a source install may download and extract them on that machine
|
||||
(`node tools/setup.mjs --scenes`; `--no-scenes` skips). Missing Python 3.8+ or UnityPy / lz4 / Pillow is a warning,
|
||||
and the metal board remains. The repository never stores the bundles or the extracted files (`.gitignore` already
|
||||
ignores `public/assets/`, `data/local-assets.json` and `.cache/`, and names `.cache/official-scenes/`). Packaging
|
||||
does not fail when the scenes are absent; the summary line `battlefield scenes` says whether this machine has them.
|
||||
`scenes.py` and `fetch_scenes.py` ship in a player zip only after they are committed, because the packager reads
|
||||
`git ls-files`.
|
||||
|
||||
No gameplay change: the scene is art under the same grid, camera and unit layer. Golden results are not moved by
|
||||
this change.
|
||||
|
||||
**[ASSUMED]** the light gains `SCENE_LIGHT` (`range` 8.0, `ambient` 0.2, `direct` 0.8, `indirect` 0.4) and the RGBM
|
||||
reading of the lightmap pages (Unity linear decode, range 5), both in DESIGN §15. Not done: the scene's normal /
|
||||
metallic / emission maps, map effects, sky box and post-processing; the tile graphics a level switches at run time
|
||||
(mire, tide) still use the remake's overlays. The extracted byte size inside a full zip was not measured here.
|
||||
|
||||
Tests: `test/render/board3d-scene.test.js` (the scene table against data/stages.json, board-space conversion, chunk
|
||||
layout shared by scenes.py and load.js, sub-graphic groups, RGBM decode, the loader with a fake store, BoardScene
|
||||
with a stub renderer: scene on / areas / fallback). `test/scene-assets.test.js` (setup's scene step, the full plan
|
||||
ships extracted scene files, the lite plan ships none, git ignores the bundles).
|
||||
|
||||
### 30.22 Prepared 外勤医疗 target order and preparation inspection
|
||||
|
||||
PR #523 by @YinqiMargatroid, continuing #451. PR #451 made 预备干员-医疗 visible in preparation and left its initial
|
||||
aggro order unchanged. The owner decided on 2026-10-12 to take both halves of this report as the author describes them.
|
||||
The retained team grant in §30.14 is unchanged: a confirmed Touch selection still supplies the medic after the holder
|
||||
is eliminated or leaves.
|
||||
|
||||
In the recording [卫戍协议](https://b23.tv/RQLI8WS), the right-side 联防 board at 19:13 has Touch and 哈洛德 inside the
|
||||
same flying 守墓石像's attack range when it locks its target, and the attack selects 哈洛德. The 17:01 preparation
|
||||
frame shows 哈洛德 with no equipment, and his position makes him the right player's first ordinary deployment under
|
||||
the current column scan. The 19:36 clip supplies another targeting comparison. [PRTS 卫戍协议/帮助](https://prts.wiki/w/卫戍协议/帮助)
|
||||
gives the column order and places operators before summons; [PRTS 作战机制](https://prts.wiki/w/作战机制) gives 索敌 as
|
||||
blocker, then special priority, then 仇恨, then earliest appearance. These observations support an effective priority
|
||||
earlier than ordinary operators for the prepared 外勤医疗 slot. They do not expose the official unit identity or the
|
||||
exact deployment counter.
|
||||
|
||||
The battle sim still creates the selected map-character variant at `battleStart`, after the board deployment.
|
||||
[ASSUMED] The prepared slot keeps an earlier effective targeting order whether it stays 预备干员-医疗 or is replaced
|
||||
by Touch. Only `aggroSeq` receives a negative pre-initial value (`i - chars.length` across every prepared character
|
||||
on the field); `deploySeq` remains the actual spawn's identity for deployment hooks and scheduled effects. `0` is not
|
||||
used, because `aggroCmp` treats a falsy `aggroSeq` as `deploySeq`. [ASSUMED] When several players have prepared slots,
|
||||
their relative order follows the existing player iteration, the same way other initial placements do. A map character
|
||||
spawned ad hoc after battle start, without `preBattleAggroSeq`, keeps ordinary summon order. Redeploy and device
|
||||
spawn still assign `aggroSeq` from the new `deploySeq`. The flying 守墓石像 tests check both variants at target-lock
|
||||
time, a right-side 联防 Touch ahead of the left medic and behind no ordinary operator, and the higher-taunt exception.
|
||||
|
||||
The further recording [准备阶段](https://b23.tv/4JXAp12) at 0:17 shows 预备干员-医疗 selected, with its detail card and
|
||||
attack range visible before combat. This shows an inspection affordance. It does not establish that the official game
|
||||
already simulates the medic as a combat entity during preparation. The client lets the existing read-only preparation
|
||||
view open its operator-style card and board-space range by tap, including on the mirrored boss half and in the flat
|
||||
fallback renderer. The original board-space record is kept for the card; only the rendered view is transformed. The
|
||||
preview stays immovable and is not sold or retreated. The battle creation point is unchanged. The same click path
|
||||
accepts another map-character variant if one is included in `prepMapChars`.
|
||||
|
||||
Normative lines: SIM.md §1 and §2 (`aggroSeq`, `spawnToken` `preBattleAggroSeq`), design/engine.md §5.5, design/client.md
|
||||
Interactions, design/network.md preparation state. Tests: `test/content/bands.test.js`, `test/content/tokens_devices.test.js`,
|
||||
`test/ui/inspect-range.test.js`, `test/ui/playtest6_summons.test.js`, `test/render/browser.test.js` (needs `RENDER_E2E=1`).
|
||||
|
||||
`npm run golden:update` recomputed all 287 scenarios against the preceding 0.2.5 candidate. No scenario was added or
|
||||
removed. Only `coop2-NORMAL-14-standins` moved. Its human seat holds `band_amedic`, so the team grant places the
|
||||
prepared map character on both fields; the earlier `aggroSeq` changes who those enemies strike, and the later bond
|
||||
and combat totals follow. The match stays a round-14 victory with team LP 31. The partner (`band_chiave`) first
|
||||
differs at round 11: activated layers 149 → 161, with 拉特兰 5 → 11 and 精准 24 → 30 in that settle row. Round 12
|
||||
continues the same layer gap (191 → 206). The human settle row first differs at round 13: kills 21 → 22, leaks
|
||||
36 → 35. Final human totals: kills 272 → 273, leaks 44 → 43, damage dealt 2,564,165 → 2,565,610, boss damage
|
||||
193,091 → 189,876. Partner boss damage 586,909 → 590,124, damage dealt 3,838,927 → 3,843,408, activated layers
|
||||
267 → 282; final bonds move 奥术 27 → 30, 拉特兰 17 → 23 and 精准 53 → 59. This scenario was not among the 45 that
|
||||
already differed from 0.2.4 (§30.9, §30.16), so the release-relative total is now 46. The other 286 digests match
|
||||
the preceding candidate.
|
||||
|
||||
### 30.23 Silenced 枯朽 death fog, and 自选 in the promotion reward
|
||||
|
||||
Two owner decisions of 2026-10-12. Neither is a new pool or a change to any other silence rule.
|
||||
|
||||
**#470.** `enemy_1267_nhpbr` (萨卡兹枯朽战士) and `enemy_1267_nhpbr_2` (萨卡兹枯朽战士组长) share one death ability,
|
||||
PollutedDie 【污染秽蚀】 (`kitPolluted`). A death while `flags.silence` is set creates no zone. The owner's official-play
|
||||
recall is that killing one which already shows the silence icon leaves no fog. That recall contradicts the PRTS /
|
||||
in-game text 「不可沉默」. Both handbook lines are `format: "NORMAL"` ("被击倒时释放污染秽蚀,使周围我方持续损失生命"),
|
||||
not `SILENCE`. 0.2.0 already withheld the fog (§25.18.2), from the general 死亡爆炸 line "如无特殊说明,此能力默认可沉默"
|
||||
and a client-template reading of `projectile_on_killed`. That section stays as the 0.2.0 reading. The reason recorded
|
||||
now is the owner's recall, against 「不可沉默」. The local gamedata cache does not contain the string 「不可沉默」; the
|
||||
contradiction is the issue title and the owner's statement.
|
||||
|
||||
The same ability is only those two keys. Checked and left unchanged: 萨卡兹枯朽战车 / 尖端 (`enemy_1272_nhtank`, `_2`)
|
||||
秽蚀轰击 is an attack skill with `_ignoreSilence` and `silenceImmune`; 枯朽之种 (`enemy_1269_nhfly`) self-destructs and
|
||||
is silence-immune; 枯朽萃聚使徒 (`enemy_1321_wdarft`) summons seeds and is silence-immune; 萨卡兹枯朽前锋 / 辟路前锋
|
||||
(`enemy_1422_lrsldr`, `_2`) deal arts while in a 源石污染区; 假想敌:蚀裂 (`enemy_9006_actoxi`) has a different death
|
||||
cloud that was already silenceable; 高能源石虫's 死亡爆炸 is a different ability. A silence that has expired does not
|
||||
stop the fog. A leak (`reason !== 'killed'`) still creates none. A killing blow that would apply silence only if the
|
||||
target is still alive is not this case: the report is the icon already showing.
|
||||
|
||||
**#515.** The promotion reward (three different operators, one tier above the 调度中心, at most 6, price 0) includes
|
||||
自选 operators of the newly reached tier, the way the ordinary shop includes them once the 调度中心 is at that tier.
|
||||
`pushRewardOffer` with no explicit `ids` now passes `diyStockEntries()` into `pool.roll`. The tier filter is unchanged,
|
||||
including the downward walk when a tier has no further distinct chess (playtest #6 item 19). Shop level 4 rolls reward
|
||||
tier 5, so a stocked tier-5 自选 piece can appear and a tier-6 piece cannot. Shop level 5 rolls reward tier 6, so a
|
||||
stocked tier-6 piece can appear; a tier-5 piece appears only if that roll walks down. Shop level 6 still can roll tier
|
||||
6. Shop level 3 rolls tier 4 and has no tier-4 自选 piece. The ordinary shop (`economy.js`, `diyRollEntries`) still
|
||||
waits for `shopLevel` 5 / 6. Effect offers that pass explicit `ids` do not roll. A 自选 piece is not guaranteed. A slot
|
||||
whose every bond is banned still has no stock. `left <= 0` stays out. Copy counts 8 / 5 stay [ASSUMED] and unchanged.
|
||||
|
||||
Seen: 卫戍协议/帮助 干员的获得与精锐化 describes a temporary refresh of three operators one tier above the 调度中心 and
|
||||
does not say that 自选 are excluded. The next section's note "仅在调度中心等级 ≥ 干员所在等阶时,该干员才有可能出现在栏位中"
|
||||
is the persistent shop: binding it to the refresh would leave a level-4 调度中心 with no tier-5 operator.
|
||||
`shopLevelDisplayDataDict` lists the tier-5 DIY slots at level 5 and the tier-6 slots at level 6; those rows are visible
|
||||
shop chess, and no second pool excludes them. No parsed page names 自选 inside the refresh. The inclusion is the
|
||||
owner's decision of 2026-10-12, not a new [ASSUMED]. 0.2.0 §25 had [ASSUMED] that the reward used the slot's shop
|
||||
level; that sentence stays there, and this section reverses it.
|
||||
|
||||
Normative lines: `server/match/player/acquire.js` `pushRewardOffer`, `server/match/player/diy.js`, META.md §3,
|
||||
DATA.md `rewardOffer` and §18, design/match.md economy, PLAYING.md 自选编队, SIM.md enemy damage zones,
|
||||
`server/sim/content/enemies/archetypes.js` `kitPolluted`. Tests: `test/match/diy-shop.test.js` (the reward tallies;
|
||||
the level-4 ordinary shop still rolls no tier-5 自选 piece), `test/content/enemies_bosses.test.js` (both keys, silenced
|
||||
and after the silence runs out).
|
||||
|
||||
`npm run golden:update` recomputed all 287 scenarios. No scenario was added or removed, and no digest moved.
|
||||
#470 does not change the sim: the fog was already withheld. #515 can change a promotion reward only when the seat
|
||||
has stocked 自选 of the reward tier and the 调度中心 is still below that slot's shop level. The two DIY matches start
|
||||
at shop level 5. `coop2-NORMAL-5-coverage-diy` rolls its only reward after the shop is already 6, where both iterators
|
||||
include the tier-6 stock, so the offer is the same. `coop2-NORMAL-70-diy` rolls four tier-6 rewards while the shop is
|
||||
still 5. On this seed the card the seat takes is the same operator as before. Rounds 8–10 differ only in an untaken
|
||||
card (round 8 swaps the other two; rounds 9 and 10 replace the third). The other level-5 offer is unchanged. Both
|
||||
level-6 offers match the previous roll, including the one that shows the tier-6 自选 piece, which the shop gate already
|
||||
allowed. The digest records
|
||||
shop draws, fielded pieces, board hashes and the rng state, not an untaken reward card, so the stored scenario does
|
||||
not move. The release-relative total against 0.2.4 stays 46.
|
||||
|
||||
### 30.24 Funnel normal attacks keep a body hit
|
||||
|
||||
PR #537 by @Xinglan233. [PRTS 分支特性信息 驭械术师](https://prts.wiki/w/分支特性信息/data) says a drone that has not
|
||||
been released by a skill deals additional damage on top of the operator's attack: 20 % ATK at first, +15 on the same
|
||||
target, cap 110 %. A skill-released drone deals skill damage.
|
||||
|
||||
The profession used to return that ramp as the whole normal-attack multiplier, so the body hit was missing.
|
||||
`resolveHit` now keeps a 100 % ATK body instance and separate drone instances. `funnelMul` holds only the drone ramp;
|
||||
an attack's `dmgMul` (至简's talent) is applied once to both. Extra attached drones raise `hits`, not the body count.
|
||||
至简's charged S2 sets `funnelBodyHits`, so two body hits and two additions share one ramp step. Both share one
|
||||
`attackId` and one attack hook; only the body is `isAttack`. Additions carry `addition` and `funnel` and are not also
|
||||
emitted as a normal attack.
|
||||
|
||||
[ASSUMED] Body instances precede additions at the existing projectile impact, and a multi-hit volley takes one ramp
|
||||
step. Ordinary attacks and any skill that only buffs or retargets the normal attack keep the body. `info.isSkill` is
|
||||
not the switch: every attack or targeting override sets it. `funnelBodyHits === 0` means the attack is drone-only
|
||||
(the ramp stays inside those hits and no body is added). 荒芜拉普兰德 S2 sets that. 至简 S2 sets `funnelBodyHits`
|
||||
to two, so two body hits and two additions still share one ramp step.
|
||||
|
||||
耶拉 S2, base and hidden, follows the 洛洛 S2 split. `skchr_kjera_2` (docs/research/03-operators.md) says 自身与浮游单元
|
||||
攻击, and the lock lasts until the enemy dies or the skill ends. While it runs, `funnelMul` is detached, so her own
|
||||
shot is one 100 % `isAttack` hit inside her range and the unreleased addition is off. `1 + attack@cnt` drones tick
|
||||
on their own (`kjera:drone`, arts, `isSkill`, not `isAttack`) and may stay outside her range. Cold is rolled once
|
||||
on the body and once on each drone hit. Ending the skill restores the multiplier.
|
||||
|
||||
[ASSUMED] Those released drones use the trait ramp (20 % → +15 % on the same target → the cap). Her blackboard has
|
||||
no drone scale; the branch page gives that ramp to drones that were not released. The base form continues its
|
||||
per-target ramp, and two drones on one enemy in the same tick take one step. That map is written back when the skill
|
||||
ends. The hidden form restarts each drone at the initial scale, and those scales are not written back onto the single
|
||||
trait ramp. There is no flight delay. A lock that can no longer be targeted is dropped and the drone retargets. Stun
|
||||
and disarm do not pause the released drones. An in-flight shot from before the skill can still step the live ramp;
|
||||
the skill keeps its own copy, and a shot that lands after the skill ends can step the restored ramp.
|
||||
|
||||
[ASSUMED] 荒芜拉普兰德 S1 says 释放浮游单元 and does not say she stops attacking. The toggle is read as a retarget of
|
||||
the unreleased attack: one body hit plus the two additions, at the raised ATK. A 洛洛-style release is not used.
|
||||
澄闪's skills and 荒芜拉普兰德 S3 stay on their own `dealDamage` paths. 澄闪 S3 and the skill-drone simulator on
|
||||
淬羽 are unchanged.
|
||||
|
||||
Kits that stored the ramp on `dmgMul` (至简, both 耶拉 forms, 澄闪 S2) now store it on `funnelMul`.
|
||||
|
||||
Tests: `test/sim/funnel-body.test.js`, and the funnel cases in `kits_t3`, `kits_t4`, `kits_t6`, `kits_alt_t6`,
|
||||
`kits_alt_t2` and `op_gdglow`. The golden moves from this correction, measured against the candidate after §30.29,
|
||||
are §30.30.
|
||||
|
||||
### 30.25 Redeploy resets the drone ramp
|
||||
|
||||
PR #536 by @Xinglan233. The same branch page says that changing target, or redeploying the operator, restores the
|
||||
drone damage to its initial value at once.
|
||||
|
||||
`installFunnel` resets `funnelTarget`, `funnelScale` and the per-target `funnelMap` (耶拉 / 至简) on a normal
|
||||
deployment, including automatic revival against the same living target. A free move (`moveRedeploy`, the same carry
|
||||
as a 秘术师's stored energy and 乌尔比安 S3) keeps the ramp. An in-flight drone still applies the ramp it computes on
|
||||
impact, so a shot launched before the reset can step the new ramp. That limit is documented and not changed.
|
||||
|
||||
Tests: `test/sim/funnel-redeploy.test.js`, `test/match/funnel-redeploy.test.js`.
|
||||
|
||||
### 30.26 洛洛 S2 keeps its lock outside her range
|
||||
|
||||
PR #535 by @Xinglan233. The official skill table `skchr_rockr_2`, as recorded in `docs/research/03-operators.md`,
|
||||
says the released drone stays locked until the enemy is killed or the skill ends, then returns. Leaving her attack
|
||||
range is not a return condition. The branch page says a skill-released drone deals skill damage, and that additional
|
||||
damage belongs to drones that were not released.
|
||||
|
||||
S2 now runs one independent skill tick (`rockr:drone`, one drone at both ranks). While the skill is up the trait
|
||||
multiplier is detached, so new body shots are ordinary attacks and the unreleased addition is off. The drone's hits
|
||||
are arts skill damage (`isSkill`, not `isAttack`). It inherits the trait ramp on its own copy, so an older projectile
|
||||
cannot change that ramp. A living selectable lock stays valid outside the body's range. Ending the skill writes the
|
||||
ramp back and restores the multiplier and the overload cap. S1 and other operators' funnel attacks keep their
|
||||
existing paths. 立于磐石 is unchanged.
|
||||
|
||||
[ASSUMED] There is no flight delay. A lock that becomes untargetable is dropped and the drone retargets. Body control
|
||||
(stun and the like) does not pause the released drone.
|
||||
|
||||
Tests: `test/content/rockrock-s2-lock.test.js`, and the 洛洛 case in `kits_t1t2`.
|
||||
|
||||
### 30.27 Hit tiles use half-even rounding
|
||||
|
||||
PR #520 by @Cloudnyco. PRTS 作战机制 (oldid 433935, 「碰撞体积与位置识别」) says the struck unit's coordinates are
|
||||
rounded half to even (四舍六入五成双) onto the tile they occupy. `Math.round` sends an exact .5 toward +∞, so an enemy
|
||||
held on an outer half-tile was indexed off the board and dropped from the hit index.
|
||||
|
||||
`roundHalfEven` is used for hit tiles only: `body.js` `posKey`, the point cases of `bodyOnTile` and `bodyTileReach`,
|
||||
the point-enemy index in `battle/queries.js`, and `targeting.js` `tileKeyOf`. Movement, knockback, `ai.js`, a unit's
|
||||
standing tile for range keys, and `onFrontLine` still use `Math.round`. #519's signed-zero case in `powi` was already
|
||||
on this candidate and is left as it is.
|
||||
|
||||
[ASSUMED] `FIELD_EDGE_INSET` = 1/1048576 pulls a clamped enemy just inside the outer half-tile, so half-even rounding
|
||||
keeps it on the edge tile. An inset of 1/1024 was rejected because it moved unbalance steps. The inset has no
|
||||
official source.
|
||||
|
||||
Tests: `test/sim/hit-tile-round.test.js`, `test/sim/detmath.test.js`, the edge landing in
|
||||
`feedback5-raid-own-board`, the 连星 probe in `op_wang`, and the 麦哲伦 probe in `feedback5-wv-summon-triggers`.
|
||||
|
||||
### 30.28 Bots soft-reserve a living teammate's opening bonds
|
||||
|
||||
PR #518 by @IceCodeNew, for issue #514. This is not an official rule. It extends the accepted #407 card penalty.
|
||||
|
||||
Opening-band choice and focus planning used to ignore a living teammate's selected strategy until someone from it was
|
||||
deployed, so a bot could take that faction's high cards. A living teammate's strategy now soft-reserves its bonds for
|
||||
both choices, before any member is deployed. The reservation ends when that teammate is eliminated (`alivePlayers`).
|
||||
It does not hard-exclude a strategy, reserve stock, or stop an immediate merge. A reserved band's draw weight is a
|
||||
quarter of its unreserved weight (`SOFT_BAND_RESERVE` = 0.25) and stays above zero, so the strategy can still be
|
||||
drawn. Focus planning applies the existing −6 teammate penalty to those bonds as well. Mode-inactive bonds stay
|
||||
weight 0 (DESIGN §21.26). A solo match has no teammate to reserve against. The shared-pool −18 card penalty is
|
||||
unchanged, and a stronger merge can still win it.
|
||||
|
||||
[ASSUMED] The quarter weight is a project choice. The PR text called the reservation soft; its diff hard-zeroed the
|
||||
weight. This candidate follows the text. The reservation is equal for human and AI teammates.
|
||||
|
||||
Tests: the two `#514` cases in `test/match/bot-smarter.test.js`. One proves the focus leaves the reserved bond while
|
||||
the teammate lives and returns after elimination. The other proves a reserved strategy is still drawn, and drawn more
|
||||
often once that teammate is eliminated. The existing card test still shows a merge outscoring the −18 penalty.
|
||||
|
||||
### 30.29 Golden changes after the funnel, hit-tile and bot-reservation ports
|
||||
|
||||
All 287 scenarios were recomputed once after §30.24–§30.28 and compared with this candidate before those ports
|
||||
(`HEAD` `7fd0478d`, `git show HEAD:test/golden/*.json`). No scenario was added or removed. 43 digests differ from
|
||||
that candidate. They are additional to §30.9 and §30.16.
|
||||
|
||||
Against released 0.2.4 the total is now 64. Of the previous 45, 21 are byte-for-byte the digests already on this
|
||||
candidate and are not repeated below: `roster-011`, `roster-012`, `roster-013`, `roster-032`, `roster-033`,
|
||||
`roster-034`, `roster-039`, `roster-040`, `roster-043`, `bond-maniShip-high`, `bond-yanShip-low`,
|
||||
`boss-boss_2-pair`, `boss-boss_5-solo`, `hidden-boss_8-pair`, `hidden-boss_9-solo`, `solo-NORMAL-2`,
|
||||
`coop2-NORMAL-3`, `solo-HARD-9-boosted`, `coop2-ABYSS-10-boosted`, `standin-06`, `standin-07`. The other 24 of
|
||||
those 45 moved again, and 19 scenarios that still matched 0.2.4 on this candidate now differ.
|
||||
|
||||
Opening strategy ids do not change in any of the 20 match digests. A solo match has no teammate, so §30.28 cannot
|
||||
move one. Co-op seeds keep the same `bandId` and the same bot-draw counts except `coop4-ABYSS-7`. The recorded
|
||||
match moves are battle totals from §30.24–§30.27, and the later gold and LP those totals change. No isolated
|
||||
counterfactual was re-run; the groups below follow who is actually on the board.
|
||||
|
||||
| Cause | Changed scenarios |
|
||||
|---|---|
|
||||
| Funnel body hit plus redeploy reset, and 洛洛 S2's independent lock (§30.24–§30.26). These boards include 洛洛 | `roster-004`, `roster-005`, `roster-023`, `roster-024`, `bond-victoriaShip-low`, `bond-victoriaShip-high` |
|
||||
| Funnel body hit plus redeploy reset (§30.24–§30.25). 至简 and/or 耶拉, not 洛洛 | `roster-007`, `roster-009`, `roster-026`, `roster-028`, `bond-deputShip-low`, `bond-emptyShip-low`, `bond-kjeragShip-high`, `bond-maniShip-low`, `bond-miraShip-low`, `bond-sargonShip-high`, `bond-soloShip-low`, `bond-visiShip-low` |
|
||||
| Same body hit and redeploy reset on 荒芜拉普兰德 | `roster-017`, `roster-019`, `roster-036`, `roster-046`, `roster-047`, `boss-boss_3-pair`, `boss-boss_5-pair`, `boss-boss_7-pair`, `hidden-boss_9-pair` |
|
||||
| 澄闪 DIY: the normal attack is a body hit plus one drone addition (§30.24) | `diy-098`, `diy-099` |
|
||||
| Hit-tile half-even rounding and the edge inset (§30.27). No funnel operator is deployed; the damage total stays 1388100 and the hook counts and the snapshot sample at index 27 move | `standin-04` |
|
||||
| Full matches. Opening bands are unchanged. Final lineups include a funnel operator, except `coop2-HARD-4` (both seats eliminated, lineup empty) and `coop3-ABYSS-5` (the surviving lineup has none), whose first divergence is round 7 and round 5; those two are the hit-tile change, or a funnel operator who is not left in the stored lineup | `solo-FUNNY-1`, `solo-FUNNY-2`, `solo-NORMAL-1`, `solo-HARD-1`, `solo-ABYSS-1`, `coop2-HARD-4`, `coop2-NORMAL-1-coverage-standins`, `coop2-NORMAL-5-coverage-diy`, `coop2-NORMAL-8-serverrun`, `coop2-NORMAL-70-diy`, `coop3-ABYSS-5`, `coop4-FUNNY-6`, `coop4-ABYSS-7` |
|
||||
|
||||
Result flips inside that match list: `solo-NORMAL-1` defeat → victory (team LP 0 → 2); `solo-FUNNY-1` team LP 15 → 23
|
||||
and `solo-FUNNY-2` team LP 8 → 23, both still victories, with every settled round row unchanged; `coop2-NORMAL-8-serverrun`
|
||||
team LP 26 → 27; `coop4-ABYSS-7` eliminated at round 12 → defeat at round 13, and its shop and bot draw counts change.
|
||||
The other matches keep the same end reason. `standin-04` is the only battle in this set with no funnel operator on
|
||||
the board.
|
||||
|
||||
### 30.30 Golden changes after the skill-only funnel body correction
|
||||
|
||||
All 287 scenarios were recomputed after the §30.24 correction (a skill that only buffs or retargets keeps the body;
|
||||
耶拉 S2 uses the 洛洛 split) and compared with this candidate after §30.29 (`HEAD` `e0f37718`,
|
||||
`git show HEAD:test/golden/*.json`). No scenario was added or removed. `fields`, `standins` and `diy` are unchanged
|
||||
(澄闪 stays on `dealDamage`). 19 digests differ. They are additional to §30.9, §30.16 and §30.29. Seventeen of them
|
||||
already moved in §30.29 and move again. `roster-038` and `coop2-NORMAL-14-standins` did not move in §30.29.
|
||||
|
||||
Opening strategy ids do not change in any of the 20 match digests. No isolated counterfactual was re-run. The roster
|
||||
and bond rows name the skill index the corpus assigns: 耶拉 index 1 is 心随意动, 荒芜拉普兰德 index 0 is 慵怠者悲鸣.
|
||||
至简 is on two of those roster boards at index 1; her S2 already kept two body hits, and her totals move because the
|
||||
fight around 耶拉 changed.
|
||||
|
||||
| Cause | Changed scenarios |
|
||||
|---|---|
|
||||
| 耶拉 S2, base form, skill index 1. `roster-007` also fields 至简 S2 | `roster-007`, `roster-009` |
|
||||
| 耶拉 S2, skill index 1. No 荒芜拉普兰德 on the board | `bond-emptyShip-low`, `bond-kjeragShip-high`, `bond-miraShip-low`, `bond-soloShip-low` |
|
||||
| 荒芜拉普兰德 S1, skill index 0. The toggle keeps one body hit and both unreleased additions | `roster-036`, `roster-038` |
|
||||
|
||||
Battle-total flips in that table: `roster-007` killed 132 → 137, leaks 41 → 37, coins 4 → 6, deaths 11 → 10, damage
|
||||
234357 → 243212 (耶拉's own damage 9204 → 17443; 至简 8936 → 9558). `roster-009` keeps the same kills and leaks;
|
||||
耶拉's damage 2 → 3 and 至简's kills 28 → 29, damage 7594 → 7168. `roster-036` killed 75 → 76, leaks 55 → 54,
|
||||
damage 1160465 → 1189341 (荒芜拉普兰德 60604 → 99946, kills 2 → 3). `roster-038` damage 3467641 → 3468742
|
||||
(荒芜拉普兰德 kills 4 → 14, damage 106163 → 187571). The four bonds keep the same kills and leaks. `bond-emptyShip-low`
|
||||
healing 381 → 362; 耶拉's damage 6585 → 11450 (kills 1 → 3), `bond-kjeragShip-high` 6927 → 16312,
|
||||
`bond-miraShip-low` 10165 → 18617 (kills 2 → 4), `bond-soloShip-low` 1963 → 2764.
|
||||
|
||||
Match end and the final lineup. A bot's chosen skill is not stored. Where the final lineup still contains 耶拉, her
|
||||
default skill is S2, which is the skill this correction changes.
|
||||
|
||||
| Final lineup | Changed scenarios and the recorded end |
|
||||
|---|---|
|
||||
| 耶拉, lineup unchanged | `solo-FUNNY-1` (`3_20_b`): victory, team LP 23 → 27; only round 8's bond layers move (steadShip 34 → 35). `solo-FUNNY-2` (`3_20_a`): victory, team LP 23 → 29; all eight stored round rows are identical, so the LP change is the boss round. `solo-NORMAL-1` (`3_20_b`): victory, team LP 2 → 11; first divergence round 10 (kjeragShip 13 → 14). `solo-HARD-1` (`3_20_a` and 至简 `3_13_a`): still a defeat, team LP 0, boss pool 215042 → 212054; first divergence round 8, the board hash. `coop2-NORMAL-8-serverrun` (ai_1 `3_20_a`): victory, team LP 27 → 37 (ai_0 LP 10 → 19, ai_1 LP 17 → 18); first divergence round 8, ai_1 killed 22 → 23, leaks 1 → 0. `coop2-NORMAL-5-coverage-diy` (ai_0 `3_20_a`): victory, team LP 33 → 34 (h_0 LP 27 → 28); first divergence round 13, ai_0's board hash, killed 26 → 21, leaks 17 → 22 |
|
||||
| 耶拉 was on the pre-change lineup; that seat is then eliminated and the stored lineup is empty | `solo-ABYSS-1`: defeat at round 13 → eliminated at round 12, team LP 0 → null. The pre-change lineup had 耶拉 `3_20_a` and 至简 `3_13_a`. First divergence round 8: LP 27 → 18, killed 19 → 10, leaks 0 → 9. `coop4-ABYSS-7`: still a defeat at round 13, team LP 0, boss pool 4821366 → 3417504. ai_1 is eliminated at round 13; the pre-change lineup had 至简 `3_13_a` and 耶拉 `3_20_a`. First divergence round 8: ai_1 LP 19 → 20, killed 2 → 3, leaks 7 → 6. ai_0's lineup also changes and has no funnel chess id before or after |
|
||||
| 耶拉 plus other funnel operators, lineup unchanged, same victory and the same team LP 130 | `coop4-FUNNY-6`: ai_0 耶拉 `3_20_a`, ai_1 荒芜拉普兰德 `6_18_a`, ai_3 洛洛 `2_10_a`. First divergence round 11, ai_0's kjeragShip layers 26 → 23 |
|
||||
| No 耶拉 on the final lineup. The diverging round's pieces and skill choice are not stored | `coop2-NORMAL-1-coverage-standins`: still a defeat, team LP 0, boss pool 25040 unchanged. ai_0's final lineup includes 荒芜拉普兰德 `6_18_a`. First divergence round 7, the other seat's miraShip layer 5 → 6. `coop2-NORMAL-14-standins`: victory, team LP 31 → 40 (ai_0 LP 13 → 22). Neither final lineup contains a funnel chess id. First divergence round 10 bond layers (raidShip 26 → 28, steadShip 16 → 18); that round's board hash is unchanged |
|
||||
|
||||
### 30.31 Ammunition readout
|
||||
|
||||
Active ammunition skills show remaining rounds and magazine capacity below the segmented bar (§10), including
|
||||
Laterano's extra rounds and refills. The renderer reads the existing exact snapshot counts rather than estimating
|
||||
them from SP. Missing ammunition snapshots, preparation and death hide the number. Dense magazines retain their
|
||||
numeric readout when the renderer omits cell separators. Combat rules and golden results are unchanged.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -508,6 +508,15 @@
|
||||
white-space: pre-line;
|
||||
}
|
||||
.tooltip.is-shown { opacity: 1; }
|
||||
.tooltip--aside { max-width: 3.2rem; white-space: normal; }
|
||||
.tooltip--compound { display: flex; gap: .16rem; max-width: min(8rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; pointer-events: auto; overscroll-behavior: contain; }
|
||||
.tooltip--compound .tooltip__main { flex: 1 1 2.2rem; min-width: 0; }
|
||||
.tooltip--compound .tooltip--aside { flex: 1 1 2.2rem; min-width: 0; border-left: 1px solid var(--line-2); padding-left: .12rem; }
|
||||
@media (max-width: 600px) {
|
||||
.tooltip--compound { flex-direction: column; }
|
||||
.tooltip--compound .tooltip__main, .tooltip--compound .tooltip--aside { flex: none; max-width: 100%; width: 100%; }
|
||||
.tooltip--compound .tooltip--aside { border-left: 0; border-top: 1px solid var(--line-2); padding: .1rem 0 0; }
|
||||
}
|
||||
.tooltip--top.is-shown { animation: tip-up var(--t-fast) var(--ease-out); }
|
||||
.tooltip--bottom.is-shown { animation: tip-down var(--t-fast) var(--ease-out); }
|
||||
@keyframes tip-up { from { transform: translateY(4px); } }
|
||||
|
||||
@@ -81,6 +81,15 @@
|
||||
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
|
||||
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
|
||||
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
|
||||
/* the box beside a bond's tooltip (matchInfo.js BondBannedAside): its banned members with avatar, name and bonds */
|
||||
.brief-bantip { display: flex; flex-direction: column; gap: .06rem; }
|
||||
.brief-bantip__h { font-size: calc(.12 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--red); }
|
||||
.brief-bantip__op { display: flex; align-items: center; gap: .08rem; }
|
||||
.brief-bantip__op .uthumb { flex: none; }
|
||||
.brief-bantip__txt { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
|
||||
.brief-bantip__txt b { font-size: calc(.14 * var(--t)); color: var(--text-hi); }
|
||||
.brief-bantip__txt > span { font-size: calc(.12 * var(--t)); color: var(--text-lo); }
|
||||
.brief-bantip__txt .is-this { color: var(--mint-400); }
|
||||
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
|
||||
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
|
||||
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
|
||||
|
||||
@@ -681,6 +681,8 @@
|
||||
.ff-float--arts { color: #d59cff; }
|
||||
.ff-float--true { color: #fff; }
|
||||
.ff-float--heal { color: #6dff9c; }
|
||||
.ff-float--crit { color: #ff2a2a; }
|
||||
.ff-float--miss { color: #ff9000; }
|
||||
@keyframes float-up { to { transform: translate(-50%, -.4rem); opacity: 0; } }
|
||||
.ff-badge { position: absolute; left: 50%; bottom: .08rem; transform: translateX(-50%); font-family: var(--font-display); font-size: .1rem; letter-spacing: .3em; color: var(--text-dim); opacity: .5; pointer-events: none; }
|
||||
.gm--combat .ff-badge { display: none; }
|
||||
|
||||
@@ -265,9 +265,14 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: .12rem .2rem;
|
||||
font-size: calc(.13 * var(--t));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.title-foot__meta { display: flex; align-items: flex-end; gap: .16rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.title-repo { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.title-repo:hover, .title-repo:focus-visible { color: var(--text-hi); border-bottom-color: currentColor; }
|
||||
/* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */
|
||||
.title-dev {
|
||||
padding: .03rem .1rem;
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
/* layers */
|
||||
--z-screen: 1;
|
||||
--z-banner: 40;
|
||||
--z-conn: 60;
|
||||
--z-conn: 75;
|
||||
--z-modal: 80;
|
||||
--z-toast: 90;
|
||||
--z-tooltip: 95;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Sim check · 卫戍协议:盟约</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<style>
|
||||
/* The check (simcheck/ui.js) in the game's tokens (css/theme.css): green-grey panels, mint, gold / red for the
|
||||
differences. A long scrolling page, fit for a phone. */
|
||||
html, body { margin: 0; height: auto; min-height: 100%; overflow-y: auto; background: var(--bg-0); }
|
||||
select { min-width: 0; max-width: 100%; }
|
||||
#app { --sc-bg: var(--bg-0); --sc-panel: var(--bg-1); --sc-fg: var(--text-hi); --sc-muted: var(--text-lo); --sc-line: var(--line);
|
||||
--sc-accent: var(--mint-500); --sc-ok: var(--mint-500); --sc-warn: var(--gold-2); --sc-bad: var(--red-premium);
|
||||
--sc-font: var(--font-cjk); --sc-mono: var(--font-mono); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"><p style="color:var(--text-lo);padding:16px">正在载入战斗模拟…</p></main>
|
||||
<script type="module" src="./sim-check.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
// The cross-engine check page (docs/SIM.md「Cross-engine check」): this browser runs the golden battle corpus with the
|
||||
// client's own sim loader (public/js/battle/runner.js loadBrowserSim) and compares every tick with the Node reference
|
||||
// that `node tools/sim-check.mjs` wrote to /dev/simcheck/data/ — any device on the LAN can open it from the server.
|
||||
import { mountCheck } from './simcheck/ui.js';
|
||||
import { strongholdAdapter, scenariosOf } from './simcheck/stronghold.js';
|
||||
import { VERSION, textFingerprint } from './simcheck/core.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
globalThis.__simcheck = { status: 'loading', results: [], summary: null, error: null };
|
||||
const get = async (url) => {
|
||||
const r = await fetch(url, { cache: 'no-cache' });
|
||||
if (!r.ok) throw Object.assign(new Error(`${r.status} ${url}`), { missing: r.status === 404 });
|
||||
return r.json();
|
||||
};
|
||||
|
||||
try {
|
||||
const [doc, reference, { loadBrowserSim }, { APP_VERSION }] = await Promise.all([
|
||||
get('/dev/simcheck/data/specs.json'), get('/dev/simcheck/data/reference.json'), import('/js/battle/runner.js'), import('/shared/constants.js'),
|
||||
]);
|
||||
if (doc.simcheck !== VERSION || reference.simcheck !== VERSION || !Array.isArray(doc.files) || !doc.files.length
|
||||
|| doc.identity !== reference.identity || doc.identity !== textFingerprint(JSON.stringify({ specs: doc.specs, files: doc.files }))) {
|
||||
throw new Error('参考与场景文件不匹配,请重新生成参考数据。');
|
||||
}
|
||||
const verifiedData = new Map();
|
||||
const validate = async () => {
|
||||
for (let i = 0; i < doc.files.length; i += 8) await Promise.all(doc.files.slice(i, i + 8).map(async ({ url, hash }) => {
|
||||
if (typeof url !== 'string' || !/^\/(?:sim\/|shared\/|data\/|data\.js$|js\/battle\/runner\.js$|dev\/simcheck\/)/.test(url) || url.includes('..')) throw new Error('invalid reference input path');
|
||||
const response = await fetch(url, { cache: 'no-cache' });
|
||||
if (!response.ok) throw new Error(`参考输入不可用:${url}`);
|
||||
const text = await response.text();
|
||||
if (textFingerprint(text) !== hash) throw new Error(`参考数据已过期:${url}。请重新生成参考并刷新页面。`);
|
||||
if (url.startsWith('/data/')) verifiedData.set(url, text);
|
||||
}));
|
||||
};
|
||||
await validate();
|
||||
const { spec, ds } = await loadBrowserSim({ fetchFn: url => {
|
||||
const text = verifiedData.get(url);
|
||||
if (text == null) throw new Error(`unverified data input: ${url}`);
|
||||
return Promise.resolve(new Response(text, { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
} });
|
||||
const scenarios = scenariosOf(doc);
|
||||
const FAMILIES = [['roster', '干员'], ['bonds', '盟约'], ['fields', '首领战场与联防'], ['standins', '补位'], ['diy', '自选']];
|
||||
if (reference.made?.version !== APP_VERSION) throw new Error('参考游戏版本已过期,请重新生成。');
|
||||
mountCheck(app, {
|
||||
project: 'Stronghold Protocol',
|
||||
title: '模拟一致性检查',
|
||||
subtitle: '逐帧比较已记录的单位状态与 Node 参考,并抽样比较快照和部分内部状态。输入文件按内容指纹核对;通过不代表全部画面、事件流或内部内存均已覆盖。状态下载仅定位状态向量字段。',
|
||||
validate,
|
||||
adapter: strongholdAdapter({ spec, ds, doc }),
|
||||
scenarios,
|
||||
subsets: [
|
||||
{ id: 'fast', label: '快速(黄金结果的快速子集)', pick: (sc) => sc.fast },
|
||||
{ id: 'all', label: '全部', pick: () => true },
|
||||
...FAMILIES.map(([id, label]) => ({ id, label, pick: (sc) => sc.family === id })),
|
||||
],
|
||||
reference,
|
||||
info: [['游戏版本', APP_VERSION]],
|
||||
});
|
||||
} catch (e) {
|
||||
globalThis.__simcheck = { status: 'error', results: [], summary: null, error: String(e.message || e) };
|
||||
app.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.style.cssText = 'color:var(--text-md);padding:16px;max-width:760px;margin:0 auto;font:14px/1.6 var(--font-cjk)';
|
||||
p.textContent = e.missing
|
||||
? '还没有参考数据:请在服务器的目录里运行 node tools/sim-check.mjs(生成 public/dev/simcheck/data/),然后刷新这个页面。'
|
||||
: `载入失败:${e && e.message || e}`;
|
||||
app.appendChild(p);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// simcheck — whether a deterministic simulation gives the same bits in every JavaScript engine: the server's Node and
|
||||
// the players' browsers (Chrome / Edge, Firefox, Safari, on desktops and phones). A run of one scenario is traced:
|
||||
// state the simulation's observable numbers after every tick (the adapter's state(): positions, HP, timers …),
|
||||
// hashed to their exact IEEE-754 bits; each block of `every` ticks keeps one hash of its ticks' hashes
|
||||
// view what a viewer is shown (the adapter's view(): a wire snapshot), hashed at the end of every block and at the
|
||||
// end of the run
|
||||
// deep the hidden state (the adapter's deep(): every value reachable from the units), hashed every `deepEvery`
|
||||
// ticks (a multiple of `every`; the walk is costly) and at the end of the run
|
||||
// result the outcome's digest (the adapter's result())
|
||||
// A run elsewhere is compared with a reference run (Node) block by block: the first block that differs, at any level,
|
||||
// says when the two parted; a dump of that block's state vectors from both sides then names the first field that
|
||||
// differs (diffDumps).
|
||||
//
|
||||
// Integer operations only (Math.imul, shifts) over the numbers' bits: the hashes are the same in every engine. NaN is
|
||||
// hashed as one value (engines may keep different NaN payloads).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both. No dependencies; Node and browsers.
|
||||
|
||||
const DV = new DataView(new ArrayBuffer(8));
|
||||
const rotl = (x, r) => (x << r) | (x >>> (32 - r));
|
||||
// murmur3's block mix and finaliser
|
||||
function mix(h, k) {
|
||||
k = Math.imul(k, 0xcc9e2d51); k = rotl(k, 15); k = Math.imul(k, 0x1b873593);
|
||||
h ^= k; h = rotl(h, 13);
|
||||
return (Math.imul(h, 5) + 0xe6546b64) | 0;
|
||||
}
|
||||
function fmix(h) {
|
||||
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
}
|
||||
// the kind of an object by its tag, not instanceof: a simulation may run in another realm (a Node VM context) than the
|
||||
// hashing code, with its own Map / Set constructors
|
||||
const tag = (v) => Object.prototype.toString.call(v);
|
||||
const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && tag(v) !== '[object DataView]');
|
||||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
/** A 32-bit hash fed with words, numbers (their bits), strings and plain values. */
|
||||
class Hash {
|
||||
constructor(seed = 0x9747b28c) { this.h = seed | 0; this.n = 0; }
|
||||
word(w) { this.h = mix(this.h, w | 0); this.n++; return this; }
|
||||
num(x) {
|
||||
if (x !== x) return this.word(0x7ff80000).word(0);
|
||||
DV.setFloat64(0, x, true);
|
||||
return this.word(DV.getUint32(0, true)).word(DV.getUint32(4, true));
|
||||
}
|
||||
str(s) { this.word(0x5354 ^ s.length); for (let i = 0; i < s.length; i++) this.word(s.charCodeAt(i)); return this; }
|
||||
// null / undefined / booleans / numbers / strings / arrays / plain objects (keys in their own order, which is the
|
||||
// order the same code created them in, in any engine)
|
||||
value(v, depth = 0) {
|
||||
if (v === null || v === undefined) return this.word(v === null ? 0x6e756c6c : 0x756e6466);
|
||||
switch (typeof v) {
|
||||
case 'number': return this.num(v);
|
||||
case 'string': return this.str(v);
|
||||
case 'boolean': return this.word(v ? 0x74727565 : 0x66616c73);
|
||||
case 'bigint': return this.str(v.toString());
|
||||
case 'object': break;
|
||||
default: return this.word(0x66756e63);
|
||||
}
|
||||
if (depth > 32) return this.word(0x64656570);
|
||||
if (isList(v)) { this.word(0x41525259 ^ v.length); for (const x of v) this.value(x, depth + 1); return this; }
|
||||
const keys = Object.keys(v);
|
||||
this.word(0x4f424a54 ^ keys.length);
|
||||
for (const k of keys) { this.str(k); this.value(v[k], depth + 1); }
|
||||
return this;
|
||||
}
|
||||
done() { return fmix(this.h ^ this.n); }
|
||||
}
|
||||
|
||||
const hex = (u) => (u >>> 0).toString(16).padStart(8, '0');
|
||||
/** The hash of a list of numbers (a state vector). */
|
||||
function hashNumbers(v) { const h = new Hash(); for (let i = 0; i < v.length; i++) h.num(v[i]); return h.done(); }
|
||||
/** The hash of any plain value. */
|
||||
function hashValue(v) { return new Hash().value(v).done(); }
|
||||
/** Portable content identity for local diagnostic inputs (not a security signature). */
|
||||
function textFingerprint(s) { return hex(new Hash().str(s).done()) + hex(new Hash(0x53a9d61f).str(s).done()); }
|
||||
function selectScenarios(scenarios, subsets, id, only = null) {
|
||||
const subset = subsets.find(s => s.id === id);
|
||||
if (!subset) throw new Error(`unknown subset: ${id}`);
|
||||
const names = only == null ? null : new Set(String(only).split(',').map(s => s.trim()).filter(Boolean));
|
||||
if (names) for (const name of names) if (!scenarios.some(s => s.id === name)) throw new Error(`unknown scenario: ${name}`);
|
||||
const list = scenarios.filter(s => subset.pick(s) && (!names || names.has(s.id)));
|
||||
if (!list.length) throw new Error('no scenarios selected');
|
||||
return { subset, list };
|
||||
}
|
||||
|
||||
/**
|
||||
* The hash of everything reachable from roots: own properties (non-enumerable too, data properties only), arrays,
|
||||
* typed arrays, Maps, Sets; another root met on the way counts as its index, an object met twice counts once, the keys
|
||||
* in `skip` are left out (references back to shared definitions, process-wide counters …).
|
||||
* @param {object[]} roots @param {{ skip?: Iterable<string>, maxDepth?: number }} [o]
|
||||
*/
|
||||
function deepHash(roots, { skip = [], maxDepth = 8 } = {}) {
|
||||
const idx = new Map(roots.map((r, i) => [r, i])), seen = new Set(), skipped = new Set(skip), h = new Hash();
|
||||
const walk = (v, depth) => {
|
||||
if (v === null || v === undefined) { h.word(7); return; }
|
||||
const t = typeof v;
|
||||
if (t === 'number') { h.num(v); return; }
|
||||
if (t === 'string') { h.str(v); return; }
|
||||
if (t === 'boolean') { h.word(v ? 3 : 5); return; }
|
||||
if (t !== 'object' || depth > maxDepth) return;
|
||||
if (depth > 0 && idx.has(v)) { h.word(0x55550000 + idx.get(v)); return; }
|
||||
if (seen.has(v)) { h.word(11); return; }
|
||||
seen.add(v);
|
||||
if (isList(v)) { h.word(v.length); for (const x of v) walk(x, depth + 1); return; }
|
||||
const kind = tag(v);
|
||||
if (kind === '[object Map]') { h.word(v.size); for (const [k, x] of v) { walk(k, depth + 1); walk(x, depth + 1); } return; }
|
||||
if (kind === '[object Set]') { h.word(v.size); for (const x of v) walk(x, depth + 1); return; }
|
||||
for (const k of Object.getOwnPropertyNames(v)) {
|
||||
if (skipped.has(k)) continue;
|
||||
const d = Object.getOwnPropertyDescriptor(v, k);
|
||||
if (!d || !('value' in d) || typeof d.value === 'function') continue;
|
||||
h.str(k); walk(d.value, depth + 1);
|
||||
}
|
||||
};
|
||||
for (const r of roots) walk(r, 0);
|
||||
return h.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of one scenario, traced as it goes; advance() runs it for a time budget (a page stays responsive), finish()
|
||||
* gives the trace. The adapter:
|
||||
* start(scenario) → run; step(run) → true when a tick was simulated, false when the run is over;
|
||||
* state(run) → number[] (after every tick); labels?(run) → string[] (the names of state's entries, for dumps);
|
||||
* view?(run) → plain value; deep?(run) → uint32; result(run) → string; maxTicks? (a runaway guard)
|
||||
* dump: [first, last] ticks whose state vectors (and labels) are kept.
|
||||
*/
|
||||
function tracer(adapter, scenario, { every = 30, deepEvery = every * 5, dump = null } = {}) {
|
||||
const max = adapter.maxTicks || 1e6;
|
||||
if (!Number.isInteger(every) || every < 1 || !Number.isInteger(deepEvery) || deepEvery < every || deepEvery % every) throw new Error('invalid sample interval');
|
||||
if (dump && (!Array.isArray(dump) || dump.length !== 2 || !dump.every(Number.isInteger) || dump[0] < 1 || dump[1] < dump[0] || dump[1] > max)) throw new Error('invalid dump window');
|
||||
const run = adapter.start(scenario);
|
||||
let t = 0, block = new Hash(), over = false;
|
||||
const blocks = [], views = [], deeps = [], dumps = {};
|
||||
const sample = (end) => {
|
||||
if (adapter.view) views.push(hashValue(adapter.view(run)));
|
||||
if (adapter.deep && (end || t % deepEvery === 0)) deeps.push(adapter.deep(run) >>> 0);
|
||||
};
|
||||
return {
|
||||
get ticks() { return t; },
|
||||
get done() { return over; },
|
||||
advance(budgetMs = Infinity) {
|
||||
const until = budgetMs === Infinity ? Infinity : now() + budgetMs;
|
||||
while (!over) {
|
||||
if (!adapter.step(run)) {
|
||||
over = true;
|
||||
if (t === 0) sample(true);
|
||||
if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0);
|
||||
break;
|
||||
}
|
||||
if (t >= max) throw new Error(`scenario ${scenario.id}: maxTicks ${max} reached before completion`);
|
||||
t++;
|
||||
const s = adapter.state(run);
|
||||
block.word(hashNumbers(s));
|
||||
if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, encodeNumber), labels: adapter.labels ? adapter.labels(run) : null };
|
||||
if (t % every === 0) { blocks.push(block.done()); block = new Hash(); sample(false); }
|
||||
if ((t & 31) === 0 && now() > until) break;
|
||||
}
|
||||
return over;
|
||||
},
|
||||
finish() {
|
||||
while (!over) this.advance();
|
||||
return { ticks: t, blocks: blocks.map(hex).join(''), views: views.map(hex).join(''), deeps: deeps.map(hex).join(''), result: String(adapter.result(run)), ...(dump ? { dumps } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
/** A whole run's trace (see tracer). */
|
||||
function trace(adapter, scenario, opts) { return tracer(adapter, scenario, opts).finish(); }
|
||||
|
||||
const firstDiff = (a = '', b = '') => {
|
||||
const n = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < n; i += 8) if (a.slice(i, i + 8) !== b.slice(i, i + 8)) return i / 8;
|
||||
return -1;
|
||||
};
|
||||
/**
|
||||
* A trace against its reference. ok, or: `at` — where they first parted (level 'state' / 'view' / 'deep', the block,
|
||||
* its ticks [from, to]); `result` — the outcomes differ; `ticks` — the runs' lengths differ. The level whose difference
|
||||
* shows first is the one reported — by the tick it is seen at (a block's or a sample's last tick; a deep sample covers
|
||||
* deepEvery ticks), the finer level on a tie; a run whose state never differs but whose view does still counts.
|
||||
*/
|
||||
function compare(ref, got, every = 30, deepEvery = every * 5) {
|
||||
if (!ref) return { ok: false, missing: true };
|
||||
let at = null;
|
||||
for (const level of ['state', 'view', 'deep']) {
|
||||
const key = level === 'state' ? 'blocks' : level === 'view' ? 'views' : 'deeps', span = level === 'deep' ? deepEvery : every;
|
||||
const i = firstDiff(ref[key], got[key]);
|
||||
if (i < 0) continue;
|
||||
const to = Math.min((i + 1) * span, Math.max(ref.ticks, got.ticks));
|
||||
if (!at || to < at.to) at = { level, block: i, from: i * span + 1, to };
|
||||
}
|
||||
const result = ref.result !== got.result, ticks = ref.ticks !== got.ticks;
|
||||
return { ok: !at && !result && !ticks, at, result, ticks: ticks ? [ref.ticks, got.ticks] : null };
|
||||
}
|
||||
/** How bad a comparison is, for sorting and summaries: 3 the outcome differs, 2 what viewers see, 1 only inside. */
|
||||
function severity(c) {
|
||||
if (c.ok) return 0;
|
||||
if (c.missing || c.result || c.ticks) return 3;
|
||||
return c.at && c.at.level !== 'deep' ? 2 : 1;
|
||||
}
|
||||
|
||||
const SPECIAL_NUMBERS = new Map([['NaN', NaN], ['Infinity', Infinity], ['-Infinity', -Infinity], ['-0', -0]]);
|
||||
const encodeNumber = (x) => Number.isNaN(x) ? 'NaN' : x === Infinity ? 'Infinity' : x === -Infinity ? '-Infinity' : Object.is(x, -0) ? '-0' : x;
|
||||
const bitsOf = (value) => {
|
||||
const x = typeof value === 'string' && SPECIAL_NUMBERS.has(value) ? SPECIAL_NUMBERS.get(value) : value;
|
||||
if (typeof x !== 'number') throw new Error('invalid numeric state entry');
|
||||
if (Number.isNaN(x)) return 'NaN';
|
||||
DV.setFloat64(0, x, true);
|
||||
return hex(DV.getUint32(4, true)) + hex(DV.getUint32(0, true));
|
||||
};
|
||||
/**
|
||||
* Two dumps of the same ticks (tracer `dump`) compared field by field: the differences in tick order, each with the
|
||||
* tick, the entry's index and label, both values and their bits. Stops after `limit`.
|
||||
*/
|
||||
function diffDumps(a, b, limit = 20) {
|
||||
const out = [];
|
||||
const ticks = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].map(Number).sort((x, y) => x - y);
|
||||
for (const t of ticks) {
|
||||
const x = a[t], y = b[t];
|
||||
if (!x || !y) { out.push({ tick: t, index: -1, label: x ? 'missing on the other side' : 'missing in the reference' }); if (out.length >= limit) break; continue; }
|
||||
const n = Math.max(x.state.length, y.state.length);
|
||||
for (let i = 0; i < n && out.length < limit; i++) {
|
||||
const p = x.state[i], q = y.state[i];
|
||||
if (Object.is(p, q) || (p !== undefined && q !== undefined && bitsOf(p) === bitsOf(q))) continue;
|
||||
out.push({ tick: t, index: i, label: (x.labels && x.labels[i]) || (y.labels && y.labels[i]) || `#${i}`, ref: p, got: q, refBits: p === undefined ? '' : bitsOf(p), gotBits: q === undefined ? '' : bitsOf(q) });
|
||||
}
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const VERSION = 2;
|
||||
export { VERSION, Hash, hex, hashNumbers, hashValue, textFingerprint, selectScenarios, deepHash, tracer, trace, compare, severity, diffDumps };
|
||||
@@ -0,0 +1,104 @@
|
||||
// simcheck's Node side: the reference (every scenario traced in Node, core.js), headless runs of the check page in
|
||||
// several engines, and the field-by-field comparison of a dump a browser downloaded with Node's run of the same ticks.
|
||||
// Engines: Playwright's chromium / firefox / webkit when the `playwright` package is installed (its browsers fetched
|
||||
// with `npx playwright install firefox webkit`), else a local Chrome through puppeteer-core ($CHROME_PATH or the usual
|
||||
// install paths).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { tracer, diffDumps, VERSION } from './core.js';
|
||||
|
||||
/**
|
||||
* Trace every scenario in Node: the reference a page compares with.
|
||||
* @param {object} adapter @param {{ id: string }[]} scenarios
|
||||
* @param {{ every?: number, project: string, made?: object, onProgress?: (i: number, n: number, id: string) => void }} o
|
||||
*/
|
||||
export function buildReference(adapter, scenarios, { every = 30, deepEvery = every * 5, project, made = {}, onProgress } = {}) {
|
||||
const out = {};
|
||||
scenarios.forEach((sc, i) => {
|
||||
if (onProgress) onProgress(i, scenarios.length, sc.id);
|
||||
const { ticks, blocks, views, deeps, result } = tracer(adapter, sc, { every, deepEvery }).finish();
|
||||
out[sc.id] = { ticks, blocks, views, deeps, result };
|
||||
});
|
||||
return { simcheck: VERSION, project, every, deepEvery, made: { engine: `Node ${process.version}`, date: new Date().toISOString().slice(0, 10), ...made }, scenarios: out };
|
||||
}
|
||||
|
||||
const CHROMES = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
||||
async function tryImport(name) { try { return await import(name); } catch { return null; } }
|
||||
|
||||
/**
|
||||
* Open the check page (url, with ?auto=1 and the subset) in each engine and wait for its results.
|
||||
* @param {string} url @param {string[]} engines 'chromium' | 'firefox' | 'webkit' | 'chrome' (a local Chrome)
|
||||
* @returns {Promise<Record<string, { version?: string, status: string, summary?: object, results?: object[], report?: string, error?: string }>>}
|
||||
*/
|
||||
export async function runEngines(url, engines, { timeoutMs = 30 * 60000, log = () => {} } = {}) {
|
||||
const pw = await tryImport('playwright'), pp = pw ? null : await tryImport('puppeteer-core');
|
||||
const out = {};
|
||||
for (const name of engines) {
|
||||
let browser = null;
|
||||
try {
|
||||
if (pw && pw[name === 'chrome' ? 'chromium' : name]) {
|
||||
const type = pw[name === 'chrome' ? 'chromium' : name], exe = CHROMES.find((p) => p && existsSync(p));
|
||||
// chromium / chrome: Playwright's own build when it is installed, else the local Chrome
|
||||
try { browser = await type.launch(name === 'chrome' && exe ? { executablePath: exe } : {}); } catch (e) {
|
||||
if (type !== pw.chromium || !exe) throw e;
|
||||
browser = await type.launch({ executablePath: exe });
|
||||
}
|
||||
} else if (pp && (name === 'chrome' || name === 'chromium')) {
|
||||
const exe = CHROMES.find((p) => p && existsSync(p));
|
||||
if (!exe) throw new Error('no Chrome found: set CHROME_PATH');
|
||||
browser = await (pp.default || pp).launch({ executablePath: exe, headless: true });
|
||||
} else throw new Error(pw ? `playwright has no ${name}` : `${name} needs the playwright package (npm i -D playwright && npx playwright install ${name})`);
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e.message || e)));
|
||||
await page.goto(url, { timeout: 120000 });
|
||||
const t0 = Date.now();
|
||||
let last = -1;
|
||||
for (;;) {
|
||||
const st = await page.evaluate(() => { const s = globalThis.__simcheck; return s ? { status: s.status, n: s.results.length } : null; });
|
||||
if (st && ['done', 'stopped', 'error'].includes(st.status)) break;
|
||||
if (errors.length) throw new Error(errors.join('; '));
|
||||
if (st && st.n !== last && st.n % 25 === 0) { log(` ${name}: ${st.n}`); last = st.n; }
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out');
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const res = await page.evaluate(() => { const s = globalThis.__simcheck; return { status: s.status, expected: s.expected, completed: s.completed, error: s.error, summary: s.summary, results: s.results, report: s.report }; });
|
||||
out[name] = { version: browser.version ? await browser.version() : '', ...res, errors };
|
||||
} catch (e) {
|
||||
out[name] = { status: 'error', error: String(e && e.message || e) };
|
||||
} finally { if (browser) await browser.close().catch(() => {}); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dump a browser downloaded (the page's 下载这一段的状态) against Node's run of the same ticks: the differing entries,
|
||||
* first ones first, as printable lines.
|
||||
* @param {string} file @param {object} adapter @param {{ id: string }[]} scenarios
|
||||
*/
|
||||
export function diffDumpFile(file, adapter, scenarios, { every = 30, deepEvery = every * 5, limit = 20, identity = null } = {}) {
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (d.simcheck !== VERSION || !d.dumps || typeof d.dumps !== 'object' || !Array.isArray(d.window)) throw new Error('invalid dump format');
|
||||
if (identity && d.identity !== identity) throw new Error('dump and reference inputs differ');
|
||||
const sc = scenarios.find((s) => s.id === d.id);
|
||||
if (!sc) throw new Error(`no scenario ${d.id} here`);
|
||||
const ref = tracer(adapter, sc, { every, deepEvery, dump: d.window }).finish();
|
||||
const diffs = diffDumps(ref.dumps, d.dumps, limit);
|
||||
const lines = [`${d.id}, ticks ${d.window[0]}–${d.window[1]}: Node ${process.version} vs ${d.engine}`];
|
||||
if (!diffs.length) lines.push(' the same in every entry of these ticks (the difference is in what the state vector does not hold: view / deep)');
|
||||
for (const x of diffs) lines.push(x.index < 0 ? ` tick ${x.tick}: ${x.label}` : ` tick ${x.tick} ${x.label}: ${x.ref} (${x.refBits}) vs ${x.got} (${x.gotBits})`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** One line per engine for a console. */
|
||||
export function formatEngines(res) {
|
||||
return Object.entries(res).map(([name, r]) => {
|
||||
if (r.status === 'error') return `${name}: error — ${r.error}`;
|
||||
const s = r.summary || {};
|
||||
const bad = (r.results || []).filter((x) => !x.c.ok).map((x) => x.id);
|
||||
return `${name} ${r.version || ''}: ${s.ok}/${s.n} the same${bad.length ? ` — different: ${bad.slice(0, 12).join(', ')}${bad.length > 12 ? ' …' : ''}` : ''}${r.errors && r.errors.length ? ` (page errors: ${r.errors.length})` : ''}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Stronghold Protocol's battles for simcheck (core.js): the golden battle corpus — tools/golden.mjs's families roster,
|
||||
// bonds, fields, standins and diy (its matches are not single battles) — as BattleSpecs (written by
|
||||
// tools/sim-check.mjs), each built with /sim/spec.js createBattleFromSpec as the browser runner does
|
||||
// (public/js/battle/runner.js) and stepped tick by tick; a field battle stops at its golden cap like the corpus.
|
||||
// state after every tick: the battle time, the RNG state and per unit FIELDS (exact bits)
|
||||
// view b.snapshot(): what viewers are sent
|
||||
// deep every value reachable from the units, the definitions and the process-wide sequence numbers left out
|
||||
// result resultDigest(b.result()).hash: what the server's SP_VERIFY compares
|
||||
// The page (sim-check.js) and the tool (tools/sim-check.mjs) both use it, with the sim and its data loaded their way.
|
||||
import { deepHash } from './core.js';
|
||||
|
||||
/** The data files the sim reads: public/js/battle/runner.js SIM_DATA_FILES (test/sim/simcheck.test.js keeps them equal). */
|
||||
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects', 'backups']);
|
||||
/** Per unit in the state vector. */
|
||||
export const FIELDS = Object.freeze(['alive', 'removed', 'deployed', 'x', 'y', 'hp', 'atkCd', 'sp', 'burn', 'neural', 'necrosis', 'apoptosis', 'erosion',
|
||||
'dmg', 'taken', 'heal', 'blocking']);
|
||||
// left out of the deep state: references back to definitions and the battle, and buffs.js's process-wide `seq` (it
|
||||
// depends on what ran before in the same page or process)
|
||||
const DEEP_SKIP = Object.freeze(['def', 'profile', 'kit', 'battle', 'b', 'seq']);
|
||||
const QUIET = Object.freeze({ error() {}, warn() {}, info() {}, debug() {} });
|
||||
|
||||
/** The scenario list of a specs document: id, golden family, in the fast subset. */
|
||||
export const scenariosOf = (doc) => doc.specs.map((s) => ({ id: s.id, family: s.fam, fast: s.fast }));
|
||||
|
||||
/**
|
||||
* @param {{ spec: { createBattleFromSpec: Function, resultDigest: Function }, ds: object, doc: { specs: object[] }, logger?: object }} o
|
||||
* spec: the /sim/spec.js module; ds: a simdata DataSource over SIM_DATA_FILES; doc: the specs document
|
||||
*/
|
||||
export function strongholdAdapter({ spec, ds, doc, logger = QUIET }) {
|
||||
const byId = new Map(doc.specs.map((s) => [s.id, s]));
|
||||
return {
|
||||
maxTicks: 200000,
|
||||
start(sc) {
|
||||
const s = byId.get(sc.id);
|
||||
return { b: spec.createBattleFromSpec(s.spec, ds, { logger }), cap: s.cap ?? null };
|
||||
},
|
||||
step(r) {
|
||||
const b = r.b;
|
||||
if (b.finished) return false;
|
||||
if (r.cap != null && b.time >= r.cap - 1e-9) { b.forceEnd('forced'); return false; }
|
||||
b.step();
|
||||
return true;
|
||||
},
|
||||
state(r) {
|
||||
const b = r.b, v = [b.time, b.rng.state() >>> 0];
|
||||
for (const u of b.units) {
|
||||
const e = u.elem || {}, s = u.stats || {};
|
||||
v.push(u.alive ? 1 : 0, u.removed ? 1 : 0, u.deployed ? 1 : 0, u.x, u.y, u.hp, u.atkCd, u.skill && u.skill.sp != null ? u.skill.sp : -1,
|
||||
e.burn ?? 0, e.neural ?? 0, e.necrosis ?? 0, e.apoptosis ?? 0, e.erosion ?? 0, s.dmg ?? 0, s.taken ?? 0, s.heal ?? 0, (u.blocking || []).length);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
labels(r) {
|
||||
const out = ['time', 'rng'];
|
||||
for (const u of r.b.units) { const name = `${u.side === 'enemy' ? 'enemy' : 'ally'} ${u.defId}#${u.id}`; for (const f of FIELDS) out.push(`${name}.${f}`); }
|
||||
return out;
|
||||
},
|
||||
view(r) { return r.b.snapshot(); },
|
||||
deep(r) { return deepHash(r.b.units, { skip: DEEP_SKIP }); },
|
||||
result(r) { return spec.resultDigest(r.b.result()).hash; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// simcheck's page: runs a corpus of scenarios in this browser, compares every run with the reference made in Node
|
||||
// (core.js compare) and says which ones parted, when, and how badly — the outcome, what viewers see, or only the
|
||||
// hidden state. For a run that parted, the state vectors of the block where it parted can be downloaded and compared
|
||||
// field by field with Node's (the project's tool, `--diff`). The summary can be copied as a report.
|
||||
// Plain DOM, no framework; colours from CSS variables (--sc-*), so a page can wear its project's theme. Headless
|
||||
// drivers read window.__simcheck ({ status, results, summary, report }) and may pass ?subset=<id>&auto=1 (&only=a,b).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both.
|
||||
import { tracer, compare, severity, selectScenarios, VERSION } from './core.js';
|
||||
|
||||
const TEXT = {
|
||||
run: '开始检查', stop: '停止', subset: '范围', scenarios: (n) => `${n} 场`,
|
||||
idle: '准备好了。检查只在本机进行,不会上传任何数据。',
|
||||
running: (i, n, id) => `正在检查 ${i} / ${n}:${id}`, stopped: '已停止', done: (n, t) => `完成:${n} 场,用时 ${t}`,
|
||||
ok: '一致', bad: '不一致', result: '结果不同', view: '画面不同', state: '状态不同', deep: '仅内部状态不同', ticks: '长度不同', missing: '参考里没有',
|
||||
where: (c, hz) => `首次检测到差异的区间:${(c.at.from / hz).toFixed(1)}–${(c.at.to / hz).toFixed(1)} 秒(第 ${c.at.from}–${c.at.to} 帧),${({ state: '状态', view: '快照', deep: '内部状态' })[c.at.level]}不同`,
|
||||
outcome: '结果不同', length: (a, b) => `长度 ${a} → ${b} 帧`,
|
||||
dump: '下载这一段的状态', dumping: '正在记录…',
|
||||
copy: '复制报告', copied: '已复制报告', copyFail: '无法写入剪贴板,请手动复制下面的内容',
|
||||
allOk: '全部选中场景的已记录状态、抽样快照与结果一致。',
|
||||
engine: '这个浏览器', reference: '参考', none: '没有不一致的场次。', error: '运行出错',
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
.sc { --_bg: var(--sc-bg, #101413); --_panel: var(--sc-panel, #171d1b); --_fg: var(--sc-fg, #e6ece9); --_muted: var(--sc-muted, #93a19b);
|
||||
--_line: var(--sc-line, #2a3532); --_accent: var(--sc-accent, #4ed8af); --_ok: var(--sc-ok, #4ed8af); --_warn: var(--sc-warn, #f2c14e);
|
||||
--_bad: var(--sc-bad, #ff6b5e); --_font: var(--sc-font, system-ui, sans-serif); --_mono: var(--sc-mono, ui-monospace, monospace);
|
||||
box-sizing: border-box; max-width: 760px; margin: 0 auto; padding: 16px; color: var(--_fg); font: 14px/1.55 var(--_font); }
|
||||
.sc *, .sc *::before, .sc *::after { box-sizing: inherit; }
|
||||
.sc h1 { margin: 0 0 4px; font-size: 18px; }
|
||||
.sc-sub { margin: 0 0 12px; color: var(--_muted); }
|
||||
.sc-info { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 0 0 14px; padding: 10px 12px; background: var(--_panel);
|
||||
border: 1px solid var(--_line); font-size: 13px; }
|
||||
.sc-info dt { color: var(--_muted); } .sc-info dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.sc-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; }
|
||||
.sc button, .sc select { font: inherit; color: var(--_fg); background: var(--_panel); border: 1px solid var(--_line); min-height: 40px; padding: 6px 12px; cursor: pointer; }
|
||||
.sc button.sc-primary { border-color: var(--_accent); color: var(--_accent); }
|
||||
.sc button:disabled { opacity: .5; cursor: default; }
|
||||
.sc-bar { height: 6px; background: var(--_panel); border: 1px solid var(--_line); margin: 0 0 6px; }
|
||||
.sc-bar i { display: block; height: 100%; width: 0; background: var(--_accent); transition: width .2s; }
|
||||
.sc-status { margin: 0 0 12px; color: var(--_muted); min-height: 1.55em; overflow-wrap: anywhere; }
|
||||
.sc-sum { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; }
|
||||
.sc-sum b { font: 600 18px/1 var(--_mono); margin-left: 4px; }
|
||||
.sc-sum .is-ok b { color: var(--_ok); } .sc-sum .is-warn b { color: var(--_warn); } .sc-sum .is-bad b { color: var(--_bad); }
|
||||
.sc-list { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 6px; }
|
||||
.sc-list li { padding: 8px 10px; background: var(--_panel); border-left: 3px solid var(--_warn); overflow-wrap: anywhere; }
|
||||
.sc-list li.is-bad { border-left-color: var(--_bad); }
|
||||
.sc-list code { font: 13px var(--_mono); }
|
||||
.sc-list button { min-height: 32px; padding: 3px 10px; margin-top: 6px; font-size: 13px; }
|
||||
.sc-note { color: var(--_muted); font-size: 13px; }
|
||||
.sc-allok { color: var(--_ok); }
|
||||
.sc textarea { width: 100%; min-height: 160px; margin-top: 8px; background: var(--_panel); color: var(--_fg); border: 1px solid var(--_line);
|
||||
font: 12px/1.5 var(--_mono); padding: 8px; }
|
||||
`;
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const clock = (ms) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
||||
// a task boundary that background tabs do not throttle like timers
|
||||
const yieldTask = () => new Promise((r) => { const ch = new MessageChannel(); ch.port1.onmessage = () => r(); ch.port2.postMessage(0); });
|
||||
|
||||
/** This browser, briefly: the engine family and version. */
|
||||
function engineName() {
|
||||
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
|
||||
const m = (re) => (ua.match(re) || [])[1];
|
||||
if (m(/Edg\/(\d+)/)) return `Edge ${m(/Edg\/(\d+)/)} (Blink)`;
|
||||
if (m(/Firefox\/(\d+)/)) return `Firefox ${m(/Firefox\/(\d+)/)} (Gecko)`;
|
||||
if (/AppleWebKit/.test(ua) && !/Chrome|Chromium|CriOS/.test(ua)) return `Safari ${m(/Version\/([\d.]+)/) || ''} (WebKit)`.replace(' ', ' ');
|
||||
if (m(/CriOS\/(\d+)/)) return `Chrome iOS ${m(/CriOS\/(\d+)/)} (WebKit)`;
|
||||
if (m(/Chrom(?:e|ium)\/(\d+)/)) return `Chrome ${m(/Chrom(?:e|ium)\/(\d+)/)} (Blink)`;
|
||||
return ua || 'unknown';
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try { if (navigator.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { /* the textarea */ }
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:16px';
|
||||
document.body.appendChild(ta);
|
||||
try { ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); return document.activeElement === ta && !!document.execCommand('copy'); } catch { return false; } finally { ta.remove(); }
|
||||
}
|
||||
function download(name, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the check into root.
|
||||
* @param {HTMLElement} root
|
||||
* @param {{ project: string, title: string, subtitle?: string, adapter: object, scenarios: { id: string, family?: string }[],
|
||||
* subsets: { id: string, label: string, pick: (sc: object) => boolean }[], reference: { every: number, made?: object,
|
||||
* scenarios: Record<string, object> }, hz?: number, info?: [string, string][], text?: object }} o
|
||||
*/
|
||||
function mountCheck(root, o) {
|
||||
const T = { ...TEXT, ...(o.text || {}) }, every = o.reference.every || 30, deepEvery = o.reference.deepEvery || every * 5, hz = o.hz || 30;
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (!document.getElementById('sc-style')) { const st = document.createElement('style'); st.id = 'sc-style'; st.textContent = CSS; document.head.appendChild(st); }
|
||||
const engine = engineName(), made = o.reference.made || {};
|
||||
const info = [[T.engine, engine], [T.reference, [made.engine, made.commit, made.date].filter(Boolean).join(' · ') || '—'], ...(o.info || [])];
|
||||
root.classList.add('sc');
|
||||
root.innerHTML = `<h1>${esc(o.title)}</h1>${o.subtitle ? `<p class="sc-sub">${esc(o.subtitle)}</p>` : ''}
|
||||
<dl class="sc-info">${info.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
|
||||
<div class="sc-controls"><label>${esc(T.subset)} <select class="sc-subset">${o.subsets.map((s) =>
|
||||
`<option value="${esc(s.id)}">${esc(s.label)} · ${esc(T.scenarios(o.scenarios.filter(s.pick).length))}</option>`).join('')}</select></label>
|
||||
<button class="sc-primary sc-run">${esc(T.run)}</button><button class="sc-stop" hidden>${esc(T.stop)}</button></div>
|
||||
<div class="sc-bar"><i></i></div><p class="sc-status" role="status">${esc(T.idle)}</p>
|
||||
<div class="sc-sum"></div><ol class="sc-list"></ol>
|
||||
<div class="sc-report" hidden><button class="sc-copy">${esc(T.copy)}</button> <span class="sc-note"></span><textarea readonly hidden></textarea></div>`;
|
||||
const $ = (s) => root.querySelector(s);
|
||||
const sel = $('.sc-subset'), runBtn = $('.sc-run'), stopBtn = $('.sc-stop'), bar = $('.sc-bar i'), status = $('.sc-status');
|
||||
if (q.get('subset') && o.subsets.some((s) => s.id === q.get('subset'))) sel.value = q.get('subset');
|
||||
sel.onchange = () => q.set('subset', sel.value);
|
||||
const state = { status: 'idle', results: [], summary: null, report: '', expected: 0, completed: 0 };
|
||||
globalThis.__simcheck = state;
|
||||
let stop = false;
|
||||
|
||||
const summarize = (list) => {
|
||||
const s = { n: list.length, ok: 0, result: 0, view: 0, state: 0, deep: 0 };
|
||||
for (const r of list) {
|
||||
if (r.c.ok) { s.ok++; continue; }
|
||||
if (r.c.missing || r.c.result || r.c.ticks) s.result++;
|
||||
else s[r.c.at.level]++;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const describe = (r) => {
|
||||
const c = r.c, parts = [];
|
||||
if (c.missing) return T.missing;
|
||||
if (c.error) return `${T.error}:${c.error}`;
|
||||
if (c.at) parts.push(T.where(c, hz));
|
||||
if (c.result) parts.push(T.outcome);
|
||||
if (c.ticks) parts.push(T.length(c.ticks[0], c.ticks[1]));
|
||||
return parts.join(';');
|
||||
};
|
||||
const render = (list, total, elapsed, current) => {
|
||||
const s = summarize(list);
|
||||
bar.style.width = `${total ? (100 * list.length) / total : 0}%`;
|
||||
$('.sc-sum').innerHTML = [[T.ok, s.ok, 'is-ok'], [T.result, s.result, 'is-bad'], [T.view, s.view, 'is-warn'], [T.state, s.state, 'is-warn'], [T.deep, s.deep, 'is-warn']]
|
||||
.map(([k, v, cls]) => `<span class="${v && cls !== 'is-ok' ? cls : cls === 'is-ok' ? cls : ''}">${esc(k)}<b>${v}</b></span>`).join('');
|
||||
const bad = list.filter((r) => !r.c.ok).sort((a, b) => severity(b.c) - severity(a.c));
|
||||
$('.sc-list').innerHTML = bad.map((r, i) => `<li class="${severity(r.c) === 3 ? 'is-bad' : ''}"><code>${esc(r.id)}</code>${r.family ? ` <span class="sc-note">${esc(r.family)}</span>` : ''}<br>${esc(describe(r))}
|
||||
${r.c.at ? `<br><button data-i="${i}">${esc(T.dump)}</button>` : ''}</li>`).join('');
|
||||
$('.sc-list').querySelectorAll('button[data-i]').forEach((b) => { b.onclick = () => dumpOne(bad[Number(b.dataset.i)], b); });
|
||||
if (current) status.textContent = T.running(list.length + 1, total, current);
|
||||
state.summary = s;
|
||||
return s;
|
||||
};
|
||||
const reportText = (list, subset, elapsed) => {
|
||||
const s = summarize(list), lines = [
|
||||
`simcheck ${VERSION} · ${o.project}`, `${T.engine}:${engine}`, `UA:${(navigator && navigator.userAgent) || ''}`,
|
||||
`${T.reference}:${info[1][1]}`, `${T.subset}:${subset.label} · ${T.scenarios(list.length)} · ${clock(elapsed)}`,
|
||||
`${T.ok} ${s.ok} · ${T.result} ${s.result} · ${T.view} ${s.view} · ${T.state} ${s.state} · ${T.deep} ${s.deep}`];
|
||||
for (const r of list.filter((x) => !x.c.ok).sort((a, b) => severity(b.c) - severity(a.c))) lines.push(`- ${r.id}:${describe(r)}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
const dumpOne = async (r, btn) => {
|
||||
const old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = T.dumping;
|
||||
try {
|
||||
await o.validate?.();
|
||||
await yieldTask();
|
||||
const tr = tracer(o.adapter, o.scenarios.find((x) => x.id === r.id), { every, deepEvery, dump: [r.c.at.from, r.c.at.to] });
|
||||
while (!tr.advance(25)) await yieldTask();
|
||||
const got = tr.finish();
|
||||
download(`simcheck-${String(o.project).replace(/[^\w\u4e00-\u9fff]+/g, '-').toLowerCase()}-${r.id}-${r.c.at.from}.json`, JSON.stringify({ simcheck: VERSION, project: o.project, identity: o.reference.identity, id: r.id, window: [r.c.at.from, r.c.at.to],
|
||||
engine, ua: navigator.userAgent, dumps: got.dumps }));
|
||||
} catch (e) { status.textContent = `${T.error}:${String(e.message || e)}`; }
|
||||
finally { btn.disabled = false; btn.textContent = old; }
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
stop = false; runBtn.disabled = true; sel.disabled = true; stopBtn.hidden = false; $('.sc-report').hidden = true;
|
||||
state.status = 'running'; state.results = [];
|
||||
state.expected = 0; state.completed = 0; state.error = null;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await o.validate?.();
|
||||
if (q.has('subset') && !o.subsets.some(s => s.id === q.get('subset'))) throw new Error(`unknown subset: ${q.get('subset')}`);
|
||||
const { subset, list } = selectScenarios(o.scenarios, o.subsets, sel.value, q.get('only'));
|
||||
state.expected = list.length;
|
||||
for (const sc of list) {
|
||||
if (stop) break;
|
||||
render(state.results, list.length, performance.now() - t0, sc.id);
|
||||
await yieldTask();
|
||||
const t1 = performance.now(), tr = tracer(o.adapter, sc, { every, deepEvery });
|
||||
while (!tr.advance(25)) { await yieldTask(); if (stop) break; }
|
||||
if (stop) break;
|
||||
const got = tr.finish();
|
||||
state.results.push({ id: sc.id, family: sc.family, c: compare(o.reference.scenarios[sc.id], got, every, deepEvery), ms: performance.now() - t1 });
|
||||
state.completed = state.results.length;
|
||||
}
|
||||
const elapsed = performance.now() - t0, s = render(state.results, list.length, elapsed, null);
|
||||
const complete = s.n > 0 && state.completed === state.expected;
|
||||
status.innerHTML = stop ? esc(T.stopped) : complete && s.ok === s.n ? `<span class="sc-allok">${esc(T.allOk)}</span> ${esc(T.done(s.n, clock(elapsed)))}` : esc(T.done(s.n, clock(elapsed)));
|
||||
if (complete && !state.results.some(r => !r.c.ok)) $('.sc-list').innerHTML = `<li class="sc-note">${esc(T.none)}</li>`;
|
||||
state.report = reportText(state.results, subset, elapsed);
|
||||
state.status = stop ? 'stopped' : 'done';
|
||||
} catch (e) {
|
||||
state.status = 'error'; state.error = String(e.message || e); state.report = `${T.error}:${state.error}`;
|
||||
status.textContent = state.report;
|
||||
} finally { $('.sc-report').hidden = false; runBtn.disabled = false; sel.disabled = false; stopBtn.hidden = true; }
|
||||
};
|
||||
runBtn.onclick = run;
|
||||
stopBtn.onclick = () => { stop = true; };
|
||||
$('.sc-copy').onclick = async () => {
|
||||
const ok = await copyText(state.report), ta = $('.sc-report textarea');
|
||||
$('.sc-report .sc-note').textContent = ok ? T.copied : T.copyFail;
|
||||
ta.hidden = ok; if (!ok) { ta.value = state.report; ta.focus(); ta.select(); }
|
||||
};
|
||||
if (q.get('auto') === '1') run();
|
||||
return state;
|
||||
}
|
||||
|
||||
export { mountCheck, engineName };
|
||||
@@ -141,6 +141,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "Callsign (max {NAME_MAX_LEN} chars)",
|
||||
"开始": "Start",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "Unofficial fan remake · Game assets © Hypergryph / Yostar",
|
||||
"项目仓库": "Repository",
|
||||
"高": "High",
|
||||
"中": "Medium",
|
||||
"低": "Low",
|
||||
@@ -155,6 +156,8 @@
|
||||
"音效": "Sound Effects",
|
||||
"静音": "Mute",
|
||||
"显示伤害数字": "Damage Numbers",
|
||||
"官方伤害数字": "Official Damage Numbers",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "Show only red numbers that meet the official crit rule. While off, every hit still shows a number.",
|
||||
"画面质量": "Graphics Quality",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction",
|
||||
"快捷键": "Hotkeys",
|
||||
@@ -707,6 +710,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
||||
"解冻": "Unfreeze",
|
||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||
"正在刷新…": "Refreshing…",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"与你在同一战场": "On your field",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "{NAME_MAX_LEN}文字以内で入力",
|
||||
"开始": "開始",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非公式ファンリメイク · ゲーム素材の権利は Hypergryph / Yostar に帰属します",
|
||||
"项目仓库": "リポジトリ",
|
||||
"高": "高",
|
||||
"中": "中",
|
||||
"低": "低",
|
||||
@@ -161,6 +162,8 @@
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
"官方伤害数字": "公式ダメージ数値",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "公式のクリティカル条件を満たす赤い数値だけを表示します。オフのときは、これまで通り命中ごとに表示します。",
|
||||
"画面质量": "画質",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "タッチ操作:ユニットをタップで選択(撤退 / 売却)· ユニットやカードを長押しで詳細表示 · ドラッグで配置後、スワイプで向きを選択",
|
||||
"快捷键": "ショートカットキー",
|
||||
@@ -708,6 +711,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
||||
"解冻": "解除",
|
||||
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
||||
"正在刷新…": "更新中…",
|
||||
"免费 ×{free}": "無料 ×{free}",
|
||||
"查看 {name} 的战场": "{name}の戦場を見る",
|
||||
"与你在同一战场": "あなたと同じ戦場",
|
||||
|
||||
+55
-51
@@ -108,7 +108,7 @@
|
||||
"由创建者选择": "방장이 선택",
|
||||
"由创建者设置": "방장이 설정",
|
||||
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 특수 선택 순서에서 AI 팀원보다 먼저 선택합니다.",
|
||||
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"离开同盟": "동맹 나가기",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 다른 참가자에게 넘어가거나 동맹이 해산됩니다. 나가시겠습니까?",
|
||||
@@ -143,6 +143,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "코드네임 입력 (최대 {NAME_MAX_LEN}자)",
|
||||
"开始": "시작",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "비공식 팬 리메이크 · 게임 리소스 © Hypergryph / Yostar",
|
||||
"项目仓库": "저장소",
|
||||
"高": "높음",
|
||||
"中": "보통",
|
||||
"低": "낮음",
|
||||
@@ -157,6 +158,8 @@
|
||||
"音效": "효과음",
|
||||
"静音": "음소거",
|
||||
"显示伤害数字": "대미지 수치 표시",
|
||||
"官方伤害数字": "공식 대미지 수치",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "공식 치명타 조건을 만족하는 빨간 숫자만 표시합니다. 끄면 지금까지처럼 모든 타격에 숫자가 표시됩니다.",
|
||||
"画面质量": "그래픽 품질",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "터치 조작: 유닛을 탭하여 선택(퇴각 / 판매) · 유닛이나 카드를 길게 눌러 상세 정보 확인 · 드래그로 배치한 뒤 스와이프로 방향 선택",
|
||||
"快捷键": "단축키",
|
||||
@@ -241,7 +244,6 @@
|
||||
"继续作战": "전투 재개",
|
||||
"正在载入模拟数据…": "시뮬레이션 데이터를 불러오는 중…",
|
||||
"正在进入模拟…": "시뮬레이션에 진입하는 중…",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
|
||||
"已配发的装备无法销毁": "장착한 장비는 파기할 수 없습니다.",
|
||||
"销毁道具": "아이템 파기",
|
||||
"道具": "아이템",
|
||||
@@ -387,9 +389,9 @@
|
||||
"倒计时结束后仍未选定将自动分配": "제한 시간이 끝날 때까지 선택하지 않으면 자동으로 배정됩니다.",
|
||||
"教鞭选择": "교편 선택",
|
||||
"教鞭 · 战术特训": "교편 · 전술 특훈",
|
||||
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
|
||||
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
|
||||
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
|
||||
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요.",
|
||||
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요.",
|
||||
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다.",
|
||||
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
|
||||
"选择一项(无时间限制)": "하나를 선택하세요(시간 제한 없음).",
|
||||
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
|
||||
@@ -688,7 +690,7 @@
|
||||
" · 未装备模组": " · 모듈 미장착",
|
||||
"已购买": "구매 완료",
|
||||
"已招募": "모집 완료",
|
||||
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
|
||||
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다.",
|
||||
"再次点击确认升级({price} 资金)": "한 번 더 눌러 승급 확인 (자금 {price})",
|
||||
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
|
||||
"已满级": "최대 레벨",
|
||||
@@ -704,6 +706,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
||||
"解冻": "동결 해제",
|
||||
"刷新商店 · {key}": "상점 갱신 · {key}",
|
||||
"正在刷新…": "갱신 중…",
|
||||
"免费 ×{free}": "무료 ×{free}",
|
||||
"查看 {name} 的战场": "{name}의 전장 보기",
|
||||
"与你在同一战场": "같은 전장",
|
||||
@@ -816,14 +819,14 @@
|
||||
"已恢复默认快捷键": "기본 단축키를 복원했습니다.",
|
||||
"快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 키보드가 필요합니다. 터치 기기에서도 키보드를 연결하면 사용할 수 있습니다.",
|
||||
"查看 / 更改快捷键": "단축키 보기 / 변경",
|
||||
"问题反馈": "문제 신고",
|
||||
"问题反馈": "문제 제보",
|
||||
"复制诊断信息": "진단 정보 복사",
|
||||
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.",
|
||||
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류 발생 당시의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 치환됩니다.",
|
||||
"已记录的错误": "기록된 오류",
|
||||
"附上本场战斗": "이 전투 첨부",
|
||||
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.",
|
||||
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요",
|
||||
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요",
|
||||
"附上本场战斗": "이번 전투 첨부",
|
||||
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 해당 전투를 재현할 수 있습니다.",
|
||||
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요.",
|
||||
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 복사할 수 없습니다. 아래 내용을 직접 복사해 주세요.",
|
||||
"诊断信息": "진단 정보",
|
||||
"观战中无法进行该操作": "관전 중에는 할 수 없습니다.",
|
||||
"标准": "표준",
|
||||
@@ -832,8 +835,8 @@
|
||||
"终极": "초월",
|
||||
"rounds::回合": "라운드",
|
||||
"{who}:获得{name}": "{who}: {name} 획득",
|
||||
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다",
|
||||
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다",
|
||||
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다.",
|
||||
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다.",
|
||||
"获得{name}": "{name} 획득",
|
||||
"联防阶段:{names} 迎战突破防线的敌人": "연합 방어 단계: {names} 님이 방어선을 돌파한 적을 맞아 싸웁니다.",
|
||||
"地形变化:{names}无法停留在原位置,已撤回整备区": "지형 변화: {names} 오퍼레이터가 원래 위치에 머무를 수 없어 정비 구역으로 돌아갔습니다.",
|
||||
@@ -857,7 +860,7 @@
|
||||
"(锁定)": "(고정)",
|
||||
"(精锐时生效)": "(정예화 시 적용)",
|
||||
"更换": "변경",
|
||||
"清空": "비우기",
|
||||
"清空": "초기화",
|
||||
"已在{tier}阶自选中": "이미 {tier}티어 선발에 포함됨",
|
||||
"锁定": "고정",
|
||||
"6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ 프리셋 오퍼레이터와 보유한 6★ 오퍼레이터, 그리고 4★ 프리셋 오퍼레이터(뱅가드, 스페셜리스트 제외)",
|
||||
@@ -1103,59 +1106,59 @@
|
||||
"总局数": "총 판수",
|
||||
"胜场": "승리",
|
||||
"胜率": "승률",
|
||||
"到达隐秘核心": "숨겨진 핵심 도달",
|
||||
"通关隐秘核心": "숨겨진 핵심 클리어",
|
||||
"最远回合": "최고 라운드",
|
||||
"到达隐秘核心": "히든 코어 개방",
|
||||
"通关隐秘核心": "히든 코어 격파",
|
||||
"最远回合": "최고 도달 라운드",
|
||||
"累计时长": "누적 플레이 시간",
|
||||
"{n} 分": "{n}분",
|
||||
"难度": "난이도",
|
||||
"局数": "판수",
|
||||
"隐秘核心通关": "숨겨진 핵심 클리어",
|
||||
"隐秘核心通关": "히든 코어 격파",
|
||||
"使用局数": "사용 판수",
|
||||
"通过局数": "클리어 판수",
|
||||
"通过率": "클리어율",
|
||||
"称号": "평가",
|
||||
"战斗累计": "전투 누적",
|
||||
"称号": "칭호",
|
||||
"战斗累计": "전투 통계",
|
||||
"时间": "시간",
|
||||
"模式": "모드",
|
||||
"结果": "결과",
|
||||
"点击查看该局结算": "이 판의 정산 보기",
|
||||
"点击查看该局结算": "해당 판의 정산 보기",
|
||||
"单人": "독립",
|
||||
"同盟": "동맹",
|
||||
"胜": "승",
|
||||
"负": "패",
|
||||
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
|
||||
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
|
||||
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다.",
|
||||
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}.",
|
||||
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
|
||||
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
|
||||
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
|
||||
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
|
||||
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
|
||||
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복).",
|
||||
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다.",
|
||||
"已清空本机统计数据": "해당 기기의 통계 데이터를 삭제했습니다.",
|
||||
"本机数据 · 仅记录本浏览器中你参与的对局": "해당 기기의 데이터 · 브라우저에서 참여한 판만 기록됩니다.",
|
||||
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
|
||||
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
|
||||
"未知": "알 수 없음",
|
||||
"对局记录": "판 기록",
|
||||
"对局记录": "전적",
|
||||
"{n} 小时": "{n}시간",
|
||||
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.",
|
||||
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다",
|
||||
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다",
|
||||
"中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다",
|
||||
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다",
|
||||
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 1라운드 이상 클리어한 판. 그 외의 기록은 '전적'에만 남습니다.",
|
||||
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 있습니다.",
|
||||
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다.",
|
||||
"中途退出的对局没有结算可看": "중도 포기한 판은 정산을 볼 수 없습니다.",
|
||||
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다.",
|
||||
"中途退出": "중도 퇴장",
|
||||
"不计入统计": "집계 제외",
|
||||
"显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)",
|
||||
"点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.",
|
||||
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.",
|
||||
"返回统计": "통계로 돌아가기",
|
||||
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다",
|
||||
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다",
|
||||
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다.",
|
||||
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다.",
|
||||
"({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)",
|
||||
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요",
|
||||
"清空统计数据": "통계 데이터 비우기",
|
||||
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요.",
|
||||
"清空统计数据": "통계 데이터 초기화",
|
||||
"将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.",
|
||||
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)",
|
||||
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다.)",
|
||||
"从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)",
|
||||
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
|
||||
"清空本机的统计数据": "이 기기의 통계 데이터 삭제",
|
||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
|
||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
|
||||
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다.",
|
||||
@@ -1174,13 +1177,13 @@
|
||||
"练度": "육성도",
|
||||
"潜能与练度": "잠재와 육성도",
|
||||
"满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60",
|
||||
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다",
|
||||
"原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다",
|
||||
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다",
|
||||
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다",
|
||||
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다.",
|
||||
"原型干员没有潜能与练度": "프리셋 오퍼레이터는 잠재와 육성도가 없습니다.",
|
||||
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다.",
|
||||
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다.",
|
||||
"含潜能与练度;": "잠재와 육성도 포함. ",
|
||||
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?",
|
||||
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다",
|
||||
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다.",
|
||||
"精英1": "정예 1",
|
||||
"精英2": "정예 2",
|
||||
"精英2 Lv.60": "정예 2 Lv.60",
|
||||
@@ -1195,7 +1198,7 @@
|
||||
"跟随全局": "전체 설정 따르기",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "이 브라우저에만 저장됩니다. 일본어 음성이 없으면 중국어로 재생합니다.",
|
||||
"添加到桌面": "홈 화면에 추가",
|
||||
"恢复本机对局": "이 기기의 게임 복귀",
|
||||
"恢复本机对局": "게임 복귀",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "다른 창에서 진행 중이거나 이 브라우저에서 안전하게 복귀할 수 없습니다. 원래 창을 닫고 다시 시도하세요.",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "닫힌 창의 게임을 선택하세요. 서버 재시작 또는 게임 만료 후에는 복귀할 수 없습니다.",
|
||||
"{name} · 同盟 {code}": "{name} · 동맹 {code}",
|
||||
@@ -1210,9 +1213,10 @@
|
||||
"同意重刷": "다시 추첨에 동의",
|
||||
"拒绝重刷": "다시 추첨 거부",
|
||||
"取消投票": "투표 취소",
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다",
|
||||
"库存不足": "재고 부족"
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다.",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다.",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
|
||||
"库存不足": "재고 부족",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)."
|
||||
}
|
||||
@@ -145,6 +145,7 @@
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "輸入你的代號(最多 {NAME_MAX_LEN} 字)",
|
||||
"开始": "開始",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非官方同人復刻 · 遊戲素材版權歸 上海鷹角網絡 / Yostar 所有",
|
||||
"项目仓库": "專案儲存庫",
|
||||
"高": "高",
|
||||
"中": "中",
|
||||
"低": "低",
|
||||
@@ -159,6 +160,8 @@
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
"官方伤害数字": "官方傷害數字",
|
||||
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "只顯示達到官方暴擊條件的紅色數字。關閉時仍顯示每一次命中。",
|
||||
"画面质量": "畫面品質",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "觸控操作:點擊單位選中(撤退 / 出售)· 長按單位或卡牌查看詳情 · 拖動部署後滑動選擇朝向",
|
||||
"快捷键": "快捷鍵",
|
||||
@@ -706,6 +709,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
||||
"解冻": "解凍",
|
||||
"刷新商店 · {key}": "更新商店 · {key}",
|
||||
"正在刷新…": "正在更新…",
|
||||
"免费 ×{free}": "免費 ×{free}",
|
||||
"查看 {name} 的战场": "查看 {name} 的戰場",
|
||||
"与你在同一战场": "與你在同一戰場",
|
||||
|
||||
+70
-3
@@ -525,6 +525,9 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.bondCueNode = null;
|
||||
this.bondCueToken = 0;
|
||||
this.bondCueScope = null;
|
||||
this.voiceOverrides = {};
|
||||
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
|
||||
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
|
||||
@@ -666,6 +669,7 @@ export class AudioManager {
|
||||
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
|
||||
};
|
||||
this._applyVolumes();
|
||||
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,6 +838,58 @@ export class AudioManager {
|
||||
|
||||
// ---- SFX ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True while a cue of `scope` started less than `ms` ago. One preparation update can both add layers (m.layer → layerGain)
|
||||
* and raise the active-bond count (screens/game.js): the second trigger of that same update keeps the playing cue
|
||||
* instead of cutting it off and starting it again. A later gain still replaces the cue at once (no cooldown).
|
||||
*/
|
||||
bondCueRecent(scope, ms = 400) {
|
||||
if (this.bondCueScope !== scope || typeof performance === 'undefined') return false;
|
||||
return performance.now() - (this.bondCueAt || 0) < ms;
|
||||
}
|
||||
|
||||
/** The official ADD_BOND bank has maxSoundAllowed=1 / popOldest=true. No extra cooldown. */
|
||||
stopBondCue(scope = null) {
|
||||
if (scope && this.bondCueScope !== scope) return;
|
||||
this.bondCueToken++;
|
||||
this.bondCueScope = null;
|
||||
const cur = this.bondCueNode;
|
||||
this.bondCueNode = null;
|
||||
if (!cur) return;
|
||||
try { cur.src.stop(); } catch { /* already ended */ }
|
||||
cur.end();
|
||||
}
|
||||
|
||||
_playBondCue(url, volume, scope = 'prep') {
|
||||
this.stopBondCue();
|
||||
const token = this.bondCueToken, ctx = this.ctx;
|
||||
if (!ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
this.bondCueScope = scope;
|
||||
this.bondCueAt = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
this._buffer(url).then((buf) => {
|
||||
if (!buf || token !== this.bondCueToken || ctx !== this.ctx || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
let src, gain;
|
||||
const end = () => {
|
||||
try { src?.disconnect(); gain?.disconnect(); } catch { /* ignore */ }
|
||||
if (this.bondCueNode?.src === src) this.bondCueNode = null;
|
||||
};
|
||||
try {
|
||||
src = ctx.createBufferSource(); gain = ctx.createGain();
|
||||
src.buffer = buf;
|
||||
gain.gain.value = Math.max(0, Math.min(1.5, volume));
|
||||
src.connect(gain); gain.connect(this.sfxGain);
|
||||
src.onended = end;
|
||||
this.bondCueNode = { src, gain, end };
|
||||
src.start();
|
||||
} catch { end(); }
|
||||
}, () => {});
|
||||
}
|
||||
|
||||
/** One cue per real gain, including several layers at once or an inactive bond. */
|
||||
layerGain(bondId, add, scope = 'prep') {
|
||||
if (typeof bondId === 'string' && bondId && Number.isFinite(add) && add > 0) this.sfx('bondUp', { volume: 0.7, scope });
|
||||
}
|
||||
|
||||
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
|
||||
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
@@ -862,12 +918,15 @@ export class AudioManager {
|
||||
/**
|
||||
* UI sound by name (sfx.ui keys). Unknown names are ignored.
|
||||
* @param {string} name
|
||||
* @param {{ volume?: number }} [o]
|
||||
* @param {{ volume?: number, scope?: string }} [o]
|
||||
*/
|
||||
sfx(name, o = {}) {
|
||||
try {
|
||||
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
|
||||
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
|
||||
if (typeof url === 'string') {
|
||||
if (name === 'bondUp') this._playBondCue(url, o.volume ?? 0.9, o.scope);
|
||||
else this._play(url, { volume: o.volume ?? 0.9 });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1013,6 +1072,7 @@ export class AudioManager {
|
||||
|
||||
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
|
||||
setFieldUnits(units) {
|
||||
this.stopBondCue();
|
||||
this.units.clear();
|
||||
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
|
||||
// one that happens to share its id
|
||||
@@ -1056,7 +1116,9 @@ export class AudioManager {
|
||||
if (!Array.isArray(e)) continue;
|
||||
const kind = e[0];
|
||||
if (kind === 'spawn') { this._track(e[1]); continue; }
|
||||
if (kind === 'atk') {
|
||||
if (kind === 'layer') {
|
||||
this.layerGain(e[2], e[3], 'battle');
|
||||
} else if (kind === 'atk') {
|
||||
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
|
||||
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
|
||||
const src = this.units.get(e[1]);
|
||||
@@ -1176,6 +1238,11 @@ export function installAudio(deps) {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
const pub = s.match?.public ?? null;
|
||||
if (deps.selectRoute(s) !== 'game') audio.stopBondCue();
|
||||
else if (![PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub?.phase)) {
|
||||
// A new prep gain can precede its m.public phase update. Cancel only the previous battle's cue.
|
||||
audio.stopBondCue('battle');
|
||||
}
|
||||
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
|
||||
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
|
||||
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.
|
||||
|
||||
@@ -61,9 +61,10 @@
|
||||
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
|
||||
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
|
||||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor?, diy? }] that player's
|
||||
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId,
|
||||
// a 自选 operator carries its pick like UnitInfo) | []
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
|
||||
@@ -883,7 +884,8 @@ export function createBattleRunner(deps) {
|
||||
.map((u) => {
|
||||
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
|
||||
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
|
||||
return si ? { ...o, standInFor: si } : o;
|
||||
const diy = u.def?.diyFor ? u.def.loadout?.diy : null;
|
||||
return { ...o, ...(si ? { standInFor: si } : {}), ...(diy ? { diy: { ...diy } } : {}) };
|
||||
});
|
||||
},
|
||||
/** Re-show the current battle (the game screen remounted). */
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ export function buildDiagnostics(input) {
|
||||
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
|
||||
}
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
|
||||
+5
-1
@@ -44,7 +44,7 @@ import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
import { GameScreen } from './screens/game.js';
|
||||
import { installAudio } from './audio.js';
|
||||
import { audio, installAudio } from './audio.js';
|
||||
import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
@@ -229,6 +229,10 @@ function wireNet() {
|
||||
});
|
||||
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
||||
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
||||
net.on('m.layer', (msg) => {
|
||||
const s = store.get();
|
||||
if (selectRoute(s) === 'game' && msg.playerId === s.me?.playerId) audio.layerGain(msg.bondId, msg.add);
|
||||
});
|
||||
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
|
||||
net.on('m.result', (msg) => {
|
||||
const res = payload(msg);
|
||||
|
||||
+45
-16
@@ -53,7 +53,7 @@
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
// chosen / the move is in flight (null releases; setPrep then settles it)
|
||||
// view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
|
||||
// view.setSettings({ damageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
|
||||
// view.setSettings({ damageNumbers, officialDamageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
|
||||
// view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf)
|
||||
//
|
||||
// Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen
|
||||
@@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
@@ -149,7 +149,7 @@ export { releaseGl } from './app/host.js';
|
||||
/**
|
||||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
@@ -162,7 +162,7 @@ export async function createFieldView(host, options = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const assets = resolveAssets(opts.assets);
|
||||
const data = makeData(opts.data);
|
||||
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
|
||||
const settings = { damageNumbers: true, officialDamageNumbers: false, quality: 'high', ...(opts.settings || {}) };
|
||||
// the 3D board (three.js + the official art) loads in parallel with everything else
|
||||
const boardPref = boardPreference(opts.board);
|
||||
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
|
||||
@@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let pendingView = null; // tile band/focus to apply when the camera transition ends
|
||||
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
|
||||
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
|
||||
let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable
|
||||
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
|
||||
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
|
||||
let promoBase = [];
|
||||
@@ -361,6 +362,14 @@ export async function createFieldView(host, options = {}) {
|
||||
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
|
||||
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
|
||||
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
|
||||
/** The stage's official scene, once loaded (the procedural board shows meanwhile and when there is none). */
|
||||
function useOfficialScene(st) {
|
||||
const id = st && typeof st.id === 'string' ? st.id : '';
|
||||
if (!id || !board3d) return;
|
||||
loadScenePack(assets, id).then((scene) => {
|
||||
if (scene && !destroyed && board3d && stageRec && stageRec.id === id) board3d.setScenePack(id, scene);
|
||||
}).catch(() => {});
|
||||
}
|
||||
function enable3d(THREE, pack) {
|
||||
if (destroyed || board3d || !THREE || !pack) return false;
|
||||
recover.THREE = THREE; recover.pack = pack;
|
||||
@@ -380,7 +389,7 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setExternal(true);
|
||||
backdrop.visible = false;
|
||||
b.setArea(boardArea(viewKind(camKind, camOpts)));
|
||||
if (stageRec) b.setStage(stageRec);
|
||||
if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); }
|
||||
b.setFocus(camRect());
|
||||
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
tiles.project(cam, true);
|
||||
@@ -630,7 +639,7 @@ export async function createFieldView(host, options = {}) {
|
||||
stageRec = st;
|
||||
tiles.setStage(st);
|
||||
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); }
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); useOfficialScene(st); }
|
||||
tiles.project(cam, true);
|
||||
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
|
||||
// tile heights: lay it out again on this stage
|
||||
@@ -804,16 +813,17 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
// The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece.
|
||||
// Keep its original board-space record for the detail card/range; only the rendered view is transformed to the
|
||||
// displayed boss half. The partner's units already use field coordinates and are not own prep map characters.
|
||||
prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.uid) || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
return { unit: u, display: { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 } };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -821,8 +831,8 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
|
||||
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
|
||||
*/
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
@@ -1016,6 +1026,17 @@ export async function createFieldView(host, options = {}) {
|
||||
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
|
||||
}
|
||||
|
||||
/** The owner's read-only preparation map character under a canvas point (never a drag target). */
|
||||
function mapCharAt(x, y) {
|
||||
if (mode !== 'prep') return null;
|
||||
const units = [];
|
||||
for (const e of prepMapChars) {
|
||||
const u = pickUnitOf(views.get(`m:${e.unit.uid}`));
|
||||
if (u) { u.unit = e.unit; units.push(u); }
|
||||
}
|
||||
return pickOnTile(units, groundTile(x, y))?.unit || null;
|
||||
}
|
||||
|
||||
const drag = createDragController({
|
||||
hitPiece: (x, y) => pieceAt(x, y),
|
||||
pickTile: (x, y) => pickBoardTile(x, y),
|
||||
@@ -1218,6 +1239,12 @@ export async function createFieldView(host, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||||
const mapChar = mapCharAt(ev.x, ev.y);
|
||||
if (mapChar) {
|
||||
emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true,
|
||||
button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY });
|
||||
return;
|
||||
}
|
||||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||
if (penViews.size && mode === 'prep') {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
@@ -1299,6 +1326,7 @@ export async function createFieldView(host, options = {}) {
|
||||
clearViews();
|
||||
if (prepPieces.length) promoBase = prepPieces;
|
||||
prepPieces = [];
|
||||
prepMapChars = [];
|
||||
infos.clear();
|
||||
gone.clear();
|
||||
interp.reset();
|
||||
@@ -1443,7 +1471,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const m = meleePending.get(v.id);
|
||||
let src = null;
|
||||
if (m && now - m.t < 0.35) { src = m.src; meleePending.delete(v.id); fx._slashAt = src.id; }
|
||||
fx.damage(v, amt, e[3], src);
|
||||
fx.damage(v, amt, e[3], src, e[4]);
|
||||
break;
|
||||
}
|
||||
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
|
||||
@@ -1880,6 +1908,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
const q = settings.quality;
|
||||
if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers;
|
||||
if (typeof s.officialDamageNumbers === 'boolean') settings.officialDamageNumbers = s.officialDamageNumbers;
|
||||
if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality;
|
||||
if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); }
|
||||
},
|
||||
@@ -1937,7 +1966,7 @@ export async function createFieldView(host, options = {}) {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
promotions,
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
};
|
||||
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
|
||||
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
|
||||
// tiles, uv, materials: { theme?, fx? } }
|
||||
// const scene = await loadScenePack(assets, stageId) // the stage's official scene (tools/local-extract/scenes.py),
|
||||
// → { key, stage, doc, chunks: [{ material, lightmap, group, bounds, position, uv, uv1, normal, color, index }],
|
||||
// groups: { name: [x0, y0, x1, y1] }, images: { tex: { name: img }, lm: [img|null] } } null when not extracted
|
||||
//
|
||||
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
|
||||
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
|
||||
@@ -158,5 +161,170 @@ export function loadBoardPack(assets) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Manifest group of a stage's official scene, and of the textures the scenes share. */
|
||||
export const sceneGroup = (stageId) => `scene/${stageId}`;
|
||||
export const SCENE_TEX_GROUP = 'scene/tex';
|
||||
export const SCENE_VERSION = 1;
|
||||
/** Bytes per vertex of a scene chunk: position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4. */
|
||||
export const SCENE_VERTEX_BYTES = 36;
|
||||
|
||||
/**
|
||||
* Typed-array views of the chunks of a scene.bin (scenes.py pack_chunk); null when a record does not fit the buffer.
|
||||
* @param {object} doc scene.json
|
||||
* @param {ArrayBuffer} buf scene.bin
|
||||
*/
|
||||
export function parseSceneChunks(doc, buf) {
|
||||
if (!isObj(doc) || !Array.isArray(doc.chunks) || !(buf instanceof ArrayBuffer)) return null;
|
||||
const out = [];
|
||||
for (const c of doc.chunks) {
|
||||
const n = c?.verts | 0, m = (c?.tris | 0) * 3, o = c?.offset | 0;
|
||||
const end = o + n * SCENE_VERTEX_BYTES + m * (c.index32 ? 4 : 2);
|
||||
if (!(n > 0) || !(m > 0) || o < 0 || o % 4 || end > buf.byteLength || typeof c.material !== 'string') return null;
|
||||
let p = o;
|
||||
const position = new Float32Array(buf, p, n * 3); p += n * 12;
|
||||
const uv = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const uv1 = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const normal = new Int8Array(buf, p, n * 4); p += n * 4;
|
||||
const color = new Uint8Array(buf, p, n * 4); p += n * 4;
|
||||
const index = c.index32 ? new Uint32Array(buf, p, m) : new Uint16Array(buf, p, m);
|
||||
out.push({
|
||||
material: c.material, lightmap: Number.isInteger(c.lightmap) ? c.lightmap : -1, group: typeof c.group === 'string' ? c.group : '',
|
||||
bounds: Array.isArray(c.bounds) ? c.bounds : null, position, uv, uv1, normal, color, index,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh bucket of a scene chunk. One material and one lightmap page are one draw. Water ignores the page: that shader
|
||||
* has no lightmap (`scene.js` `sceneWaterMaterial`).
|
||||
* @param {{ material?: string, lightmap?: number }} chunk
|
||||
* @param {{ kind?: string }|null} info scene.json materials[name]
|
||||
*/
|
||||
export function sceneMaterialKey(chunk, info) {
|
||||
return `${chunk?.material}|${info?.kind === 'water' ? '' : chunk?.lightmap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scene chunk from several that share a material bucket (`sceneMaterialKey`). Vertex attributes stay in the
|
||||
* scene.bin layout (position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4) and indices are rewritten
|
||||
* with the vertex offset; past 65535 vertices the index is 32-bit (WebGL2). A single chunk is returned as-is, so the
|
||||
* pack's views of scene.bin stay shared. The caller passes chunks of one bucket that are on screen together: a
|
||||
* file-level merge would bake a switched-off HIDDEN part into the base mesh.
|
||||
* @param {object[]|null} chunks
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function mergeSceneChunks(chunks) {
|
||||
if (!Array.isArray(chunks) || chunks.length === 0) return null;
|
||||
if (chunks.length === 1) return chunks[0];
|
||||
const first = chunks[0];
|
||||
let nv = 0, ni = 0, index32 = false;
|
||||
for (const c of chunks) {
|
||||
const n = c?.position?.length / 3;
|
||||
if (!Number.isInteger(n) || n <= 0) throw new Error(`scene chunk ${c?.material}: ${c?.position?.length ?? 0} position components`);
|
||||
if (c.material !== first.material) throw new Error(`scene merge mixes ${first.material} and ${c.material}`);
|
||||
if (c.uv?.length !== n * 2 || c.uv1?.length !== n * 2 || c.normal?.length !== n * 4 || c.color?.length !== n * 4) {
|
||||
throw new Error(`scene chunk ${c.material}: attributes do not match ${n} vertices`);
|
||||
}
|
||||
if (!c.index || c.index.length === 0) throw new Error(`scene chunk ${c.material}: no indices`);
|
||||
nv += n;
|
||||
ni += c.index.length;
|
||||
if (c.index instanceof Uint32Array) index32 = true;
|
||||
}
|
||||
if (nv > 65535) index32 = true;
|
||||
const position = new Float32Array(nv * 3);
|
||||
const uv = new Float32Array(nv * 2);
|
||||
const uv1 = new Float32Array(nv * 2);
|
||||
const normal = new Int8Array(nv * 4);
|
||||
const color = new Uint8Array(nv * 4);
|
||||
const index = index32 ? new Uint32Array(ni) : new Uint16Array(ni);
|
||||
let vo = 0, io = 0, bounds = null;
|
||||
for (const c of chunks) {
|
||||
const n = c.position.length / 3;
|
||||
position.set(c.position, vo * 3);
|
||||
uv.set(c.uv, vo * 2);
|
||||
uv1.set(c.uv1, vo * 2);
|
||||
normal.set(c.normal, vo * 4);
|
||||
color.set(c.color, vo * 4);
|
||||
for (let i = 0; i < c.index.length; i++) {
|
||||
const v = c.index[i];
|
||||
if (v < 0 || v >= n) throw new Error(`scene chunk ${c.material}: index ${v} out of ${n}`);
|
||||
index[io + i] = v + vo;
|
||||
}
|
||||
vo += n;
|
||||
io += c.index.length;
|
||||
if (Array.isArray(c.bounds) && c.bounds.length >= 6) {
|
||||
const b = c.bounds;
|
||||
bounds = bounds
|
||||
? [Math.min(bounds[0], b[0]), Math.min(bounds[1], b[1]), Math.min(bounds[2], b[2]), Math.max(bounds[3], b[3]), Math.max(bounds[4], b[4]), Math.max(bounds[5], b[5])]
|
||||
: b.slice();
|
||||
}
|
||||
}
|
||||
return { material: first.material, lightmap: first.lightmap, group: '', bounds, position, uv, uv1, normal, color, index };
|
||||
}
|
||||
|
||||
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
|
||||
export function sceneGroupRects(chunks) {
|
||||
const out = {};
|
||||
for (const c of chunks || []) {
|
||||
if (!c.group || !c.bounds) continue;
|
||||
const b = c.bounds, r = out[c.group];
|
||||
out[c.group] = r ? [Math.min(r[0], b[0]), Math.min(r[1], b[1]), Math.max(r[2], b[3]), Math.max(r[3], b[4])] : [b[0], b[1], b[3], b[4]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchBuffer(url) {
|
||||
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.arrayBuffer() : null; } catch { return null; }
|
||||
}
|
||||
|
||||
const sceneCache = new Map();
|
||||
|
||||
/** Does the local-art manifest list the official scene of `stageId`? (manifest only) */
|
||||
export async function sceneListed(assets, stageId) {
|
||||
if (!stageId || !assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
|
||||
try { await assets.local(); } catch { return false; }
|
||||
return !!assets.localUrl(sceneGroup(stageId), 'scene') && !!assets.localUrl(sceneGroup(stageId), 'bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a stage's official scene (scene.json + scene.bin + lightmap pages + albedo textures). A missing texture or
|
||||
* lightmap page only drops that map; a missing or malformed scene gives null (→ the procedural board).
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export function loadScenePack(assets, stageId) {
|
||||
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
|
||||
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
|
||||
const pending = (async () => {
|
||||
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
|
||||
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
|
||||
const group = sceneGroup(stageId);
|
||||
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
|
||||
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
|
||||
const chunks = parseSceneChunks(doc, buf);
|
||||
if (!chunks || !chunks.length) return null;
|
||||
const img = async (u) => {
|
||||
if (!u) return null;
|
||||
const i = await assets.image(u, { retry: true }).catch(() => null);
|
||||
return i && (i.width || i.naturalWidth) > 0 ? i : null;
|
||||
};
|
||||
const mats = isObj(doc.materials) ? doc.materials : {};
|
||||
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
|
||||
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
|
||||
const [texList, lm] = await Promise.all([
|
||||
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
|
||||
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
|
||||
]);
|
||||
const tex = {};
|
||||
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
|
||||
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
|
||||
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
|
||||
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
|
||||
return pack;
|
||||
});
|
||||
sceneCache.set(stageId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
|
||||
@@ -1,13 +1,14 @@
|
||||
// render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected
|
||||
// (the renderer loads /vendor/three.module.js; Node tests pass the npm package).
|
||||
//
|
||||
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB,
|
||||
// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
|
||||
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 / DXT5nm →
|
||||
// RGB, tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
|
||||
// emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints
|
||||
// MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559
|
||||
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
|
||||
// MT_wind_device → unlit TX_wind_device (the blower mesh)
|
||||
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
|
||||
// the materials of an official battlefield scene (load.js loadScenePack) → sceneMaterial / sceneWaterMaterial
|
||||
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
|
||||
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
|
||||
// geometry when the camera moves.
|
||||
@@ -343,6 +344,132 @@ export function smogMaterial(THREE, tex, focus) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning of the official scene materials (sceneMaterial). The official shader (Torappu/Scene/StandardRealtimeShadow) is
|
||||
* not reproduced: the albedo is lit by the baked indirect light of the scene's lightmap plus the scene's key light
|
||||
* masked by its baked shadow, in linear light (Unity's Shadowmask lighting mode). `range` — the linear light a lightmap
|
||||
* texel of 1.0 stands for when scene.json has no lightRange; [ASSUMED] `ambient` — the light of a mesh without a
|
||||
* lightmap page; `direct` / `indirect` — gains of the two terms (1 = the scene's own values).
|
||||
*/
|
||||
export const SCENE_LIGHT = Object.freeze({ range: 8.0, ambient: 0.2, direct: 0.8, indirect: 0.4 });
|
||||
|
||||
const colorOf = (info, key, fallback = [1, 1, 1, 1]) => {
|
||||
const c = info && info.colors && Array.isArray(info.colors[key]) ? info.colors[key] : fallback;
|
||||
return [0, 1, 2, 3].map((i) => (Number.isFinite(c[i]) ? c[i] : fallback[i]));
|
||||
};
|
||||
const floatOf = (info, key, fallback) => (info && info.floats && Number.isFinite(info.floats[key]) ? info.floats[key] : fallback);
|
||||
const hasKeyword = (info, kw) => !!(info && Array.isArray(info.keywords) && info.keywords.includes(kw));
|
||||
|
||||
/** Blend class of a scene material record (scene.json materials[name]): 'opaque' | 'cutout' | 'blend'. */
|
||||
export function sceneBlend(info) {
|
||||
if (hasKeyword(info, '_ALPHABLEND_ON') || hasKeyword(info, '_ALPHAPREMULTIPLY_ON')) return 'blend';
|
||||
return hasKeyword(info, '_ALPHATEST_ON') ? 'cutout' : 'opaque';
|
||||
}
|
||||
|
||||
/**
|
||||
* A lit material of an official scene: albedo (`map`, × _Color) × (the lightmap's baked light + key light · N·L ·
|
||||
* baked shadow (lightmap A, by _ShadowStrength)), alpha test / blend by the material's keywords, the focus falloff of
|
||||
* the other board materials. `map` is an sRGB texture; `lightMap` holds (light / lightRange)^(1/2.2) in RGB (no colour
|
||||
* space). Geometry attributes: position, normal, uv, aLm (lightmap uv), aColor.
|
||||
* @param {{ map?: any, lightMap?: any, info?: object, lightRange?: number, light?: { dir: number[], color: number[], intensity: number } }} o
|
||||
*/
|
||||
export function sceneMaterial(THREE, o, focus) {
|
||||
const info = o.info || null;
|
||||
const blend = sceneBlend(info);
|
||||
const L = o.light || { dir: [0.36, 0.45, 0.82], color: [1, 1, 1], intensity: 1 };
|
||||
const k = (Number.isFinite(L.intensity) ? L.intensity : 1) * SCENE_LIGHT.direct;
|
||||
const lin = (v) => Math.pow(Math.max(0, v), 2.2); // the scene's colours are authored in gamma space
|
||||
const col = colorOf(info, '_Color');
|
||||
const uniforms = {
|
||||
uMap: { value: o.map || null }, uLm: { value: o.lightMap || null },
|
||||
uColor: { value: new THREE.Vector4(lin(col[0]), lin(col[1]), lin(col[2]), col[3]) },
|
||||
uCutoff: { value: blend === 'cutout' ? floatOf(info, '_Cutoff', 0.5) : 0 },
|
||||
uLmScale: { value: o.lightMap ? (o.lightRange > 0 ? o.lightRange : SCENE_LIGHT.range) * SCENE_LIGHT.indirect : 0 },
|
||||
uAmbient: { value: o.lightMap ? 0 : SCENE_LIGHT.ambient },
|
||||
uShadow: { value: floatOf(info, '_ShadowStrength', 1) },
|
||||
uLightDir: { value: new THREE.Vector3(...L.dir).normalize() },
|
||||
uLightColor: { value: new THREE.Vector3(lin(L.color[0]) * k, lin(L.color[1]) * k, lin(L.color[2]) * k) },
|
||||
...focus,
|
||||
};
|
||||
const m = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: { ...(o.map ? { SCENE_MAP: 1 } : {}), ...(o.lightMap ? { SCENE_LM: 1 } : {}) },
|
||||
vertexShader: [
|
||||
'attribute vec2 aLm;',
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vUv = uv; vLm = aLm;',
|
||||
' vN = normalize(mat3(modelMatrix) * normal);',
|
||||
' vec4 w = modelMatrix * vec4(position, 1.0);',
|
||||
' vWorld = w.xyz;',
|
||||
' gl_Position = projectionMatrix * viewMatrix * w;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
fragmentShader: [
|
||||
'uniform sampler2D uMap; uniform sampler2D uLm; uniform vec4 uColor; uniform float uCutoff;',
|
||||
'uniform float uLmScale; uniform float uAmbient; uniform float uShadow; uniform vec3 uLightDir; uniform vec3 uLightColor;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vec4 a = uColor;',
|
||||
' #ifdef SCENE_MAP',
|
||||
' a *= texture2D(uMap, vUv);',
|
||||
' #endif',
|
||||
' if (a.a < uCutoff) discard;',
|
||||
' vec3 light = vec3(uAmbient); float shade = 1.0;',
|
||||
' #ifdef SCENE_LM',
|
||||
' vec4 lm = texture2D(uLm, vLm);',
|
||||
' light += pow(lm.rgb, vec3(2.2)) * uLmScale; shade = mix(1.0, lm.a, uShadow);',
|
||||
' #endif',
|
||||
' vec3 n = normalize(gl_FrontFacing ? vN : -vN);',
|
||||
' light += uLightColor * max(dot(n, uLightDir), 0.0) * shade;',
|
||||
' gl_FragColor = vec4(a.rgb * light * focusMask(vWorld.xy), a.a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: blend === 'blend', depthWrite: blend !== 'blend',
|
||||
});
|
||||
m.userData.blend = blend;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
|
||||
export function sceneWaterMaterial(THREE, tex, focus, info) {
|
||||
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
|
||||
const uniforms = {
|
||||
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
|
||||
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
|
||||
};
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' float diff = 0.5, spec = 0.0, ca = 0.0;',
|
||||
' #ifdef WATER_MAPS',
|
||||
' vec2 p = vWorld.xy * 0.45;',
|
||||
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
|
||||
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
|
||||
' diff = clamp(dot(n, L), 0.0, 1.0);',
|
||||
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
|
||||
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
|
||||
' #endif',
|
||||
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
|
||||
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
|
||||
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
|
||||
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//
|
||||
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
|
||||
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
|
||||
// board.setScenePack(id, pack) the official scene of stage `id` (load.js loadScenePack): drawn instead of the
|
||||
// procedural tiles and background while that stage is set (null → procedural)
|
||||
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
|
||||
// board.setFocus(rect) the lit field (others fade, animated)
|
||||
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
|
||||
@@ -17,15 +19,18 @@
|
||||
// static crates (merged, same material),
|
||||
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
|
||||
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
|
||||
// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap
|
||||
// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay.
|
||||
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
|
||||
|
||||
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
|
||||
import { surfaceUV } from './atlas.js';
|
||||
import {
|
||||
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
|
||||
} from './materials.js';
|
||||
import { syncThreeCamera } from '../projection.js';
|
||||
import { mergeSceneChunks, sceneMaterialKey } from './load.js';
|
||||
|
||||
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
|
||||
export const LIGHTING = Object.freeze({
|
||||
@@ -53,6 +58,17 @@ export function gatePulse(t, phase = 0) {
|
||||
return 0.134 + (0.229 - 0.134) * k;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a named part of an official scene (its ground rect [x0, y0, x1, y1]) shown with the built `areas`? The official
|
||||
* scene keeps each field as a sub-graphic it switches on (HIDDEN@n: the left / right normal field, the boss field);
|
||||
* a part is shown when its centre lies in one of the areas.
|
||||
*/
|
||||
export function sceneGroupVisible(rect, areas) {
|
||||
if (!Array.isArray(rect) || rect.length < 4) return true;
|
||||
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
|
||||
return (areas || []).some((a) => cx >= a.c0 - 0.5 && cx <= a.c1 + 0.5 && cy >= a.r0 - 0.5 && cy <= a.r1 + 0.5);
|
||||
}
|
||||
|
||||
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
|
||||
|
||||
const mergeInto = (list) => {
|
||||
@@ -194,6 +210,10 @@ export class BoardScene {
|
||||
this.scene.add(this.root, this.dynamic);
|
||||
this.stageKey = null;
|
||||
this.stage = null;
|
||||
this.scenePacks = new Map(); // stage id → official scene pack (setScenePack)
|
||||
this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds)
|
||||
this.official = null;
|
||||
this.sceneWater = [];
|
||||
this.area = AREAS.normal;
|
||||
this.areaKey = areaKey(AREAS.normal);
|
||||
this.battleRect = null;
|
||||
@@ -267,7 +287,8 @@ export class BoardScene {
|
||||
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
|
||||
setStage(stage) {
|
||||
if (this.destroyed) return;
|
||||
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
const official = stage ? this.scenePacks.get(stage.id) || null : null;
|
||||
const key = stage ? `${stage.id || ''}|${official ? official.key : ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
if (key === this.stageKey) return;
|
||||
this.stageKey = key;
|
||||
this.stage = stage || null;
|
||||
@@ -276,19 +297,107 @@ export class BoardScene {
|
||||
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
|
||||
this.board = board;
|
||||
const M = this.meshes = {};
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
this.official = official && this._buildScene(official) ? official : null;
|
||||
if (!this.official) {
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
}
|
||||
this._buildDevices(board);
|
||||
this._buildGates(board);
|
||||
this._buildEdges(board);
|
||||
this._buildTerrain(board);
|
||||
this._buildBackground(board);
|
||||
if (!this.official) this._buildBackground(board);
|
||||
this._fitShadow(board);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */
|
||||
setScenePack(id, pack) {
|
||||
if (this.destroyed || typeof id !== 'string' || !id) return false;
|
||||
const prev = this.scenePacks.get(id) || null;
|
||||
if (prev === (pack || null)) return false;
|
||||
if (prev) this._disposeScene(prev);
|
||||
if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id);
|
||||
if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */
|
||||
_sceneResources(pack) {
|
||||
let res = this.sceneRes.get(pack.key);
|
||||
if (res) return res;
|
||||
const T = this.THREE;
|
||||
const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1);
|
||||
const tex = {}, lm = [];
|
||||
for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true });
|
||||
for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null);
|
||||
res = { tex, lm, mats: new Map() };
|
||||
this.sceneRes.set(pack.key, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
_disposeScene(pack) {
|
||||
const res = this.sceneRes.get(pack.key);
|
||||
if (!res) return;
|
||||
for (const m of res.mats.values()) m.dispose();
|
||||
for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.();
|
||||
this.sceneRes.delete(pack.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The official scene's meshes: the base plus the fields of the built areas, one mesh per material and lightmap
|
||||
* page (`mergeSceneChunks`). A switched-off HIDDEN part is left out before the merge.
|
||||
*/
|
||||
_buildScene(pack) {
|
||||
const T = this.THREE;
|
||||
const res = this._sceneResources(pack);
|
||||
const infos = pack.doc?.materials || {};
|
||||
this.sceneWater = [];
|
||||
const buckets = new Map();
|
||||
for (const c of pack.chunks || []) {
|
||||
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
|
||||
const info = infos[c.material] || null;
|
||||
const mk = sceneMaterialKey(c, info);
|
||||
let bucket = buckets.get(mk);
|
||||
if (!bucket) { bucket = { info, chunks: [] }; buckets.set(mk, bucket); }
|
||||
bucket.chunks.push(c);
|
||||
}
|
||||
let n = 0;
|
||||
for (const [mk, bucket] of buckets) {
|
||||
const c = mergeSceneChunks(bucket.chunks);
|
||||
const info = bucket.info;
|
||||
const water = info?.kind === 'water';
|
||||
let mat = res.mats.get(mk);
|
||||
if (!mat) {
|
||||
mat = water
|
||||
? sceneWaterMaterial(T, this.tex, this.focus, info)
|
||||
: sceneMaterial(T, {
|
||||
map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info,
|
||||
light: pack.doc?.light || null, lightRange: pack.doc?.lightRange,
|
||||
}, this.focus);
|
||||
res.mats.set(mk, mat);
|
||||
}
|
||||
if (water) this.sceneWater.push(mat);
|
||||
const g = new T.BufferGeometry();
|
||||
g.setAttribute('position', new T.BufferAttribute(c.position, 3));
|
||||
g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true));
|
||||
g.setAttribute('uv', new T.BufferAttribute(c.uv, 2));
|
||||
g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2));
|
||||
g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true));
|
||||
g.setIndex(new T.BufferAttribute(c.index, 1));
|
||||
g.computeBoundingSphere();
|
||||
const mesh = new T.Mesh(g, mat);
|
||||
mesh.castShadow = false; mesh.receiveShadow = false;
|
||||
mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4;
|
||||
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
||||
this.root.add(mesh);
|
||||
this.meshes[`scene${n++}`] = mesh;
|
||||
}
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
_buildDevices(board) {
|
||||
const crates = [], blowers = [];
|
||||
this.staticCrates = [];
|
||||
@@ -550,6 +659,7 @@ export class BoardScene {
|
||||
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
|
||||
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
|
||||
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
|
||||
if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.frames++;
|
||||
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
|
||||
@@ -566,6 +676,8 @@ export class BoardScene {
|
||||
this.destroyed = true;
|
||||
for (const d of [...this.devices]) d.destroy();
|
||||
this._clear();
|
||||
for (const p of [...this.scenePacks.values()]) this._disposeScene(p);
|
||||
this.scenePacks.clear();
|
||||
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
|
||||
for (const m of Object.values(this.mat)) m?.dispose?.();
|
||||
for (const t of Object.values(this.tex)) t?.dispose?.();
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ensureDamageFonts() {
|
||||
fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700',
|
||||
fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7,
|
||||
dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2,
|
||||
}, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
|
||||
}, { chars: st.font === 'sp-dmg-miss' ? [['0', '9'], '+-×!', 'MISS'] : [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
|
||||
} catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); }
|
||||
}
|
||||
fontsReady = true;
|
||||
|
||||
@@ -38,8 +38,13 @@ const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths
|
||||
/** Screen scale of a number's text (24 px font) at `s` px per tile. */
|
||||
const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1);
|
||||
|
||||
/** Characters of a damage number as drawn (heals get a '+'). */
|
||||
const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0);
|
||||
/** Characters of a damage number as drawn (heals get a '+'; a yellow MISS is four letters). */
|
||||
const numChars = (v, style) => (style === 'miss' ? 4 : String(Math.round(v)).length + (style === 'heal' ? 1 : 0));
|
||||
|
||||
// [ASSUMED] PRTS says a red number sits at a random spot in a range above the model and gives no pixels.
|
||||
const OFFICIAL_JITTER_X = 0.7; // tiles, full width (±0.35)
|
||||
const OFFICIAL_LIFT_MIN = 0.15; // tiles above the head
|
||||
const OFFICIAL_LIFT_SPAN = 0.4;
|
||||
|
||||
/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */
|
||||
const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']);
|
||||
@@ -48,7 +53,7 @@ export class FxNumbers {
|
||||
// ---- hits / numbers -----------------------------------------------------------------------------------------
|
||||
|
||||
/** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */
|
||||
damage(view, amount, type, srcView) {
|
||||
damage(view, amount, type, srcView, mark) {
|
||||
if (!view) return;
|
||||
const style = dmgStyleKey(type);
|
||||
const p = this._chest(view);
|
||||
@@ -66,7 +71,13 @@ export class FxNumbers {
|
||||
}
|
||||
}
|
||||
view.onHit?.();
|
||||
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big);
|
||||
if (this.ctx.settings?.damageNumbers === false) return;
|
||||
// Official mode (设置「官方伤害数字」): only a sim-marked red integer. Sparks and the slash stay.
|
||||
if (this.ctx.settings?.officialDamageNumbers) {
|
||||
if (mark === 'r') this.officialNumber(view, amount, 'crit');
|
||||
return;
|
||||
}
|
||||
this.number(view, amount, style, big);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +107,42 @@ export class FxNumbers {
|
||||
tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08,
|
||||
});
|
||||
}
|
||||
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false);
|
||||
if (this.ctx.settings?.damageNumbers !== false && !this.ctx.settings?.officialDamageNumbers) this.number(view, amount, 'heal', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* One official popup: a red integer (including 0) or the yellow MISS. It does not join a running total.
|
||||
* Position is client-side only — the sim stays deterministic.
|
||||
*/
|
||||
officialNumber(view, amount, style) {
|
||||
if (!view) return;
|
||||
const miss = style === 'miss';
|
||||
const n = miss ? 0 : Math.round(amount);
|
||||
if (!miss && (!Number.isFinite(n) || n < 0)) return;
|
||||
const now = this.time;
|
||||
const cam = this.ctx.cam();
|
||||
const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8;
|
||||
const a = cam.project(view.x, view.y, base, this._p);
|
||||
const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100;
|
||||
const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y;
|
||||
const tile = pxPerZ > 1e-3 ? pxPerZ : s;
|
||||
const cx = ax + (Math.random() - 0.5) * OFFICIAL_JITTER_X * tile;
|
||||
const cy = ay - (OFFICIAL_LIFT_MIN + Math.random() * OFFICIAL_LIFT_SPAN) * tile;
|
||||
const sc = numScale(s, false, style);
|
||||
const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX;
|
||||
const h = 24 * NUM_LINE_EM * sc * NUM_POP;
|
||||
const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS;
|
||||
while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift());
|
||||
const t = this._takeNum(style);
|
||||
t.text.visible = true;
|
||||
t.text.alpha = 1;
|
||||
t.text.text = miss ? 'MISS' : String(n);
|
||||
Object.assign(t, {
|
||||
unit: view, style, value: n, life: 0, end: NUM_LIFE, born: now, lastHit: now, pop: 1, big: false, fading: false,
|
||||
official: true, ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base,
|
||||
risePx: NUM_RISE * tile * 0.5, _x: cx, _y: cy, _w: w, _h: h,
|
||||
});
|
||||
this.nums.push(t);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +175,7 @@ export class FxNumbers {
|
||||
// 1. merge into this unit's running total of the same style
|
||||
let mine = 0, youngest = null, oldest = null;
|
||||
for (const t of this.nums) {
|
||||
if (t.unit !== view || t.fading) continue;
|
||||
if (t.unit !== view || t.fading || t.official) continue;
|
||||
mine++;
|
||||
if (!oldest || t.born < oldest.born) oldest = t;
|
||||
if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t;
|
||||
|
||||
@@ -258,6 +258,10 @@ export class FxSim {
|
||||
case 'dodge': {
|
||||
this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 });
|
||||
this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 });
|
||||
// num:'m' — the sim already decided this dodge would have been a red number (PRTS yellow MISS, #ff9000).
|
||||
if (ex.num === 'm' && at.v && this.ctx.settings?.damageNumbers !== false && this.ctx.settings?.officialDamageNumbers) {
|
||||
this.officialNumber(at.v, 0, 'miss');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'counter': {
|
||||
|
||||
@@ -159,7 +159,7 @@ export class FxSystem {
|
||||
}
|
||||
|
||||
const FX_PARTS = [FxParticles, FxProjectiles, FxBeams, FxLocks, FxNumbers, FxRings, FxArrivals, FxSim, FxZones];
|
||||
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
|
||||
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","officialNumber","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
|
||||
|
||||
for (const key of FX_METHOD_ORDER) {
|
||||
let found = null;
|
||||
|
||||
+20
-23
@@ -6,8 +6,8 @@
|
||||
//
|
||||
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
|
||||
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
|
||||
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): consumes its wind-up or starts at
|
||||
// OnAttack, once; operator timeScale = loopDuration / interval;
|
||||
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
|
||||
// clip plays once at its own speed, then base; `clipPerAttack` (enemies,
|
||||
// GitHub #58): every attack plays the clip once — at its own speed, faster
|
||||
@@ -33,8 +33,7 @@
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
|
||||
// redraws the model, only advances `clock`)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
|
||||
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
|
||||
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
|
||||
// attacks while it runs.
|
||||
|
||||
@@ -158,10 +157,10 @@ export class SpineActor {
|
||||
/**
|
||||
* Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when
|
||||
* the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm.
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): retain rhythm scaling, but still play each attack once.
|
||||
*/
|
||||
this.clipPerAttack = false;
|
||||
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
|
||||
this.wound = false; // wound up for the coming attack (windUp → attack)
|
||||
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
|
||||
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
|
||||
this._play(this._idleName(), true);
|
||||
@@ -366,9 +365,8 @@ export class SpineActor {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
const single = once || this.clipPerAttack;
|
||||
// in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false;
|
||||
// One queued attack owns one wind-up. A later attack may begin while the previous recovery is still playing.
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (once || this.wound)) return false;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
|
||||
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
@@ -378,8 +376,8 @@ export class SpineActor {
|
||||
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
|
||||
if (!once) this.interval = iv;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
|
||||
this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.attackUntil = this.clock + lead + Math.max(0, loopDur - hit) / plan.ts;
|
||||
this._play(clip.loop, false, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.windTs = plan.ts;
|
||||
this.windUntil = this.clock + lead;
|
||||
this.wound = true;
|
||||
@@ -397,14 +395,13 @@ export class SpineActor {
|
||||
if (!clip) return;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
const per = this.clipPerAttack && !once;
|
||||
const single = once || per;
|
||||
// clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip
|
||||
const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4);
|
||||
const hit = this._hitTime(clip.loop, loopDur);
|
||||
// (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one)
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound);
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && this.wound;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
|
||||
this.attackUntil = this.clock + Math.max(0, loopDur - hit) / ts;
|
||||
this.windUntil = null;
|
||||
this.wound = false;
|
||||
if (!wasAttacking) {
|
||||
@@ -414,19 +411,19 @@ export class SpineActor {
|
||||
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
|
||||
const whole = per && this.interval >= loopDur;
|
||||
if (whole) this.attackUntil = this.clock + loopDur / ts;
|
||||
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else if (single) {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) e.timeScale = ts;
|
||||
this._play(clip.loop, false, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) {
|
||||
e.timeScale = ts;
|
||||
// re-phase gently so the strike frame lines up with this attack
|
||||
const t = e.trackTime % loopDur;
|
||||
let d = hit - t;
|
||||
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
|
||||
e.trackTime += d * 0.8;
|
||||
// Preserve operator hit-phase correction when a coarse/batched update crossed windUntil.
|
||||
// This adjusts the prepared single clip; it does not start another visual swing.
|
||||
if (!once && !per) {
|
||||
const t = e.trackTime % loopDur;
|
||||
let d = hit - t;
|
||||
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
|
||||
e.trackTime += d * 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export const DMG_STYLE = Object.freeze({
|
||||
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
|
||||
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
|
||||
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
|
||||
// Official red integer and yellow MISS (PRTS 作战机制 「伤害红字(暴击)」, MISS #ff9000). Default numbers never use these.
|
||||
crit: { font: 'sp-dmg-crit', fill: ['#ffd0d0', '#ff2a2a'], stroke: '#4a0000' },
|
||||
miss: { font: 'sp-dmg-miss', fill: ['#ffe0a8', '#ff9000'], stroke: '#4a2400' },
|
||||
});
|
||||
|
||||
/** Map b.ev dmg types to a style key. */
|
||||
|
||||
@@ -228,6 +228,38 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
|
||||
* between `pulse` and 1 at `pulseHz` (real time).
|
||||
*/
|
||||
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
|
||||
|
||||
/** The share of a model's height a bar is placed above (the head is not the very top of the silhouette). */
|
||||
export const HEAD_TOP_K = 0.92;
|
||||
/** Enemies whose model is taller than this many tiles keep their bar at the cap — a bar must stay on screen. */
|
||||
export const HEAD_TILES_CAP = 2.2;
|
||||
export const HEAD_TILES_CAP_BOSS = 3.2;
|
||||
|
||||
/**
|
||||
* Tiles above the feet where a unit's HP bar / name tag sits.
|
||||
*
|
||||
* Operators and tokens are uniform chibis (UNIT.headroom). Enemies vary: with a known model they use **how far the model
|
||||
* reaches above its own origin** (spine.js `top` = `bounds.y + bounds.height`, not the box's raw height — a body whose
|
||||
* origin sits mid-model, 逐腐兽, would otherwise push its bar ~a tile too high), scaled by the official model factor and
|
||||
* the vertical-stretch quirk; without bounds they fall back to the chibi headroom × that factor.
|
||||
* @param {{ isEnemy:boolean, isBoss:boolean, spineShown:boolean, bounds?:{y?:number,height?:number}|null, modelK:number, modelKY:number }} o
|
||||
* @returns {number} tiles
|
||||
*/
|
||||
export function headTilesForUnit(o) {
|
||||
const k = (Number.isFinite(o.modelK) ? o.modelK : 1) * (Number.isFinite(o.modelKY) ? o.modelKY : 1);
|
||||
if (!o.isEnemy) return UNIT.headroom;
|
||||
const cap = o.isBoss ? HEAD_TILES_CAP_BOSS : HEAD_TILES_CAP;
|
||||
const b = o.spineShown ? o.bounds : null;
|
||||
if (b && Number.isFinite(b.height) && b.height > 20) {
|
||||
// how far the box reaches *above the origin*: 猎狗pro −3.7, 大鲍勃 −2.6 (feet-anchored, ≈ height), 逐腐兽 −404.7 of
|
||||
// 722.6 and 锏 −798 of 1183 (origin mid-body). Using the raw height there put the bar about a tile too high.
|
||||
const top = Number.isFinite(b.y) ? b.y + b.height : b.height;
|
||||
return clamp(Math.min(Math.max(top, 20), 900) * UNIT.modelScale * k * HEAD_TOP_K, 0.55, cap);
|
||||
}
|
||||
if (o.isBoss) return HEAD_TILES_CAP;
|
||||
if (o.spineShown) return clamp(UNIT.headroom * k, 0.55, HEAD_TILES_CAP);
|
||||
return UNIT.headroom;
|
||||
}
|
||||
/**
|
||||
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
|
||||
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
|
||||
@@ -1298,13 +1330,14 @@ export class UnitView {
|
||||
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
|
||||
}
|
||||
|
||||
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
|
||||
// headroom × their official model factor)
|
||||
let headTiles = UNIT.headroom;
|
||||
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * this.modelKY * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
|
||||
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
|
||||
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK * this.modelKY, 0.55, 2.2);
|
||||
this._headTiles = headTiles;
|
||||
// keep the original short-circuit: many views never build an actor (prep chips, diamonds, tests), so the model
|
||||
// only counts when a spine is actually shown
|
||||
const actor = spineShown ? this.actor : null;
|
||||
const headTiles = this._headTiles = headTilesForUnit({
|
||||
isEnemy: this.isEnemy, isBoss: this.isBoss, spineShown,
|
||||
bounds: actor && actor.entry ? actor.entry.bounds : null,
|
||||
modelK: this.modelK, modelKY: this.modelKY,
|
||||
});
|
||||
this.screen.top = by - headTiles * s;
|
||||
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { actions, onRefreshBusy } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
@@ -183,6 +183,10 @@ function MatchScreen() {
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
|
||||
// single-flights the intent; the R hotkey is dropped by the same guard)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false);
|
||||
useEffect(() => onRefreshBusy(setRefreshBusy), []);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -428,6 +432,7 @@ function MatchScreen() {
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
if (viewModeRef.current !== 'prep') {
|
||||
audio.stopBondCue('battle');
|
||||
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
|
||||
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
|
||||
staleFieldRef.current = field;
|
||||
@@ -759,7 +764,8 @@ function MatchScreen() {
|
||||
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
|
||||
const prevActive = useRef(activeBonds);
|
||||
useEffect(() => {
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP) audio.sfx('bondUp', { volume: 0.7 });
|
||||
// a layer gain of the same update already started the cue (audio.bondCueRecent): keep it, don't restart it
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP && !audio.bondCueRecent('prep')) audio.sfx('bondUp', { volume: 0.7, scope: 'prep' });
|
||||
prevActive.current = activeBonds;
|
||||
}, [activeBonds]);
|
||||
|
||||
@@ -998,7 +1004,7 @@ function MatchScreen() {
|
||||
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
|
||||
const penKey = previewEnemyKey(e);
|
||||
if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; }
|
||||
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; }
|
||||
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); return; }
|
||||
if (!Number.isInteger(e.uid)) return;
|
||||
setDetail({ kind: 'piece', uid: e.uid });
|
||||
// a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on
|
||||
@@ -1398,7 +1404,7 @@ function MatchScreen() {
|
||||
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
|
||||
})();
|
||||
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
|
||||
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
|
||||
// an own board piece or read-only prepared map character in prep; in battle or on a scouted board an ally
|
||||
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
|
||||
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
|
||||
// draw their own
|
||||
@@ -1468,7 +1474,7 @@ function MatchScreen() {
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
|
||||
@@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field,
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e || e.area !== 'board') return null;
|
||||
row = e.row; col = e.col; dir = normDir(e.piece.dir);
|
||||
} else if (target?.kind === 'unit' && !showPrep) {
|
||||
} else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) {
|
||||
const unit = target.unit;
|
||||
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
||||
if (unit.area != null && unit.area !== 'board') return null;
|
||||
// A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from
|
||||
// m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R.
|
||||
if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) return null;
|
||||
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
|
||||
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(snapshot ? snapshot[1] : unit.x);
|
||||
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||
if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x);
|
||||
dir = (!showPrep && normDir(live?.dir, null)) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||
} else return null;
|
||||
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
||||
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
||||
|
||||
@@ -451,6 +451,20 @@ export function importRefusal(kind, ready, status = data.status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A loadout payload that carries `ops` (including empty `ops`) replaces 潜能 / 练度. 自选 operators are in that list
|
||||
* and only backups.json names them, so the refusal is the 自选编队 one: chess.json and backups.json both loaded.
|
||||
* Null when the payload has no `ops` (an older file leaves the current 练度 alone) or that data is ready (GitHub #538).
|
||||
* @param {Record<string, any> | null | undefined} ops
|
||||
* @param {boolean} ready
|
||||
* @param {(name: string) => string} [status]
|
||||
* @returns {{ text: string, tone: 'warn'|'error' } | null}
|
||||
*/
|
||||
export function loadoutOpsImportRefusal(ops, ready, status = data.status) {
|
||||
if (ops == null) return null;
|
||||
return importRefusal('diy', ready, status);
|
||||
}
|
||||
|
||||
/** In place of a tab whose game data did not load (missingGameData): which files, and what to try (GitHub #173). */
|
||||
export function DataMissing({ files }) {
|
||||
const urls = files.map((name) => `/data/${DATA_FILES[name] || `${name}.json`}`);
|
||||
@@ -586,6 +600,10 @@ function LoadoutScreen({ st }) {
|
||||
}
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
|
||||
// ops replaces 潜能 / 练度, including 自选 operators that only backups.json names. Missing data used to drop
|
||||
// them and still save, clearing a 练度 the file did not mean to erase (GitHub #538).
|
||||
const opsRefused = loadoutOpsImportRefusal(res.ops, ready);
|
||||
if (opsRefused) { toast(opsRefused.text, opsRefused.tone); return; }
|
||||
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
|
||||
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
|
||||
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
|
||||
@@ -675,7 +693,7 @@ function LoadoutScreen({ st }) {
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${t(syncText)}</span>` : null}
|
||||
<span class="lo-count">${t('已调整')} <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged && !Object.keys(st.ops || {}).length} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')}<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')}<//>
|
||||
</div>`}
|
||||
|
||||
@@ -291,8 +291,11 @@ export function TitleScreen() {
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
<span class="title-foot__meta">
|
||||
<a class="title-repo" href="https://github.com/sganggs/Stronghold-Protocol" target="_blank" rel="noopener noreferrer">${t('项目仓库')}</a>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
</span>
|
||||
</footer>
|
||||
</div>`;
|
||||
}
|
||||
+35
-12
@@ -534,24 +534,27 @@ export function DialogHost() {
|
||||
|
||||
// ---- Tooltip -----------------------------------------------------------------------------------
|
||||
|
||||
let tipState = null; // { id, content, rect, placement }
|
||||
let tipState = null; // { id, content, aside, rect, placement }
|
||||
let tipSeq = 0;
|
||||
const tipListeners = new Set();
|
||||
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
|
||||
|
||||
/**
|
||||
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
|
||||
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
* `aside`: a second section in the same clamped, scrollable box; stacked on narrow screens.
|
||||
* @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
*/
|
||||
const TOUCH_TIP_MS = 450;
|
||||
|
||||
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
const ref = useRef(null);
|
||||
const idRef = useRef(0);
|
||||
const timer = useRef(null);
|
||||
const textRef = useRef(text);
|
||||
const asideRef = useRef(aside);
|
||||
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
|
||||
textRef.current = text;
|
||||
asideRef.current = aside;
|
||||
if (!idRef.current) idRef.current = ++tipSeq;
|
||||
const show = () => {
|
||||
clearTimeout(timer.current);
|
||||
@@ -560,7 +563,8 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
const el = ref.current;
|
||||
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
|
||||
if (!el || !el.isConnected || content == null || content === '') return;
|
||||
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
|
||||
setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement,
|
||||
hold: () => clearTimeout(timer.current), hide });
|
||||
}, delay);
|
||||
};
|
||||
const hide = () => {
|
||||
@@ -570,7 +574,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
|
||||
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
|
||||
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
|
||||
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
|
||||
// (≥ 450 ms) does. Ordinary tips expire after release; compound lists stay open for scrolling until dismissed.
|
||||
const touchAt = useRef(0);
|
||||
const longTimer = useRef(null);
|
||||
const onPointerDown = (e) => {
|
||||
@@ -586,18 +590,24 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
if (!e || e.pointerType === 'mouse') return;
|
||||
touchAt.current = Date.now();
|
||||
clearTimeout(longTimer.current);
|
||||
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
if (tipState?.id === idRef.current && !asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
};
|
||||
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
|
||||
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
|
||||
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
|
||||
const onLeave = () => {
|
||||
pressed.current = false;
|
||||
if (Date.now() - touchAt.current > 900) {
|
||||
if (asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 250); }
|
||||
else hide();
|
||||
}
|
||||
};
|
||||
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
|
||||
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
|
||||
useEffect(() => {
|
||||
if (tipState?.id !== idRef.current) return;
|
||||
if (text == null || text === '') setTip(null);
|
||||
else setTip({ ...tipState, content: text });
|
||||
}, [text]);
|
||||
else setTip({ ...tipState, content: text, aside: asideRef.current });
|
||||
}, [text, aside]);
|
||||
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
|
||||
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
|
||||
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
|
||||
@@ -612,7 +622,12 @@ export function TooltipLayer() {
|
||||
const [pos, setPos] = useState(null);
|
||||
useEffect(() => {
|
||||
tipListeners.add(force);
|
||||
return () => tipListeners.delete(force);
|
||||
const outside = (e) => { if (tipState?.aside && !e.target?.closest?.('.tooltip')) tipState.hide?.(); };
|
||||
const escape = (e) => { if (e.key === 'Escape') tipState?.hide?.(); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
document.addEventListener('keydown', escape);
|
||||
window.addEventListener('resize', force);
|
||||
return () => { tipListeners.delete(force); document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); window.removeEventListener('resize', force); };
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = boxRef.current;
|
||||
@@ -628,13 +643,21 @@ export function TooltipLayer() {
|
||||
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
|
||||
let left = r.left + r.width / 2 - b.width / 2;
|
||||
left = Math.max(m, Math.min(vw - b.width - m, left));
|
||||
top = Math.max(m, Math.min(vh - b.height - m, top));
|
||||
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
|
||||
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
|
||||
});
|
||||
if (!tipState) return null;
|
||||
const ready = pos && pos.id === tipState.id;
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
|
||||
const hasAside = tipState.aside != null && tipState.aside !== '';
|
||||
const off = 'top:-9999px;left:-9999px';
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', hasAside && 'tooltip--compound', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
tabIndex=${hasAside ? 0 : undefined} onMouseEnter=${() => tipState?.hold?.()} onMouseLeave=${() => tipState?.hide?.()}
|
||||
onFocusIn=${() => tipState?.hold?.()} onPointerDown=${() => tipState?.hold?.()}
|
||||
onFocusOut=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) tipState?.hide?.(); }}
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>
|
||||
${hasAside ? html`<div class="tooltip__main">${tipState.content}</div><div class="tooltip--aside">${tipState.aside}</div>` : tipState.content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,30 @@ export function fallbackTileClass(stage, row, col) {
|
||||
return 'floor';
|
||||
}
|
||||
|
||||
/**
|
||||
* The simplified board's floating label for one event, or null when that event draws no digits.
|
||||
* Official mode keeps only a marked red integer (including 0) and a yellow MISS. The slot stays above the unit;
|
||||
* the random offset of the Pixi numbers is not repeated here [ASSUMED].
|
||||
* @param {any[]} e
|
||||
* @param {{ damageNumbers?: boolean, officialDamageNumbers?: boolean }} settings
|
||||
* @returns {{ id: any, text: string, type: string }|null}
|
||||
*/
|
||||
export function fallbackFloat(e, settings) {
|
||||
if (!settings?.damageNumbers || !Array.isArray(e)) return null;
|
||||
if (settings.officialDamageNumbers === true) {
|
||||
if (e[0] === 'fx' && e[1] === 'dodge' && e[4] && e[4].num === 'm') return { id: e[4].id, text: 'MISS', type: 'miss' };
|
||||
if (e[0] !== 'dmg' || e[4] !== 'r') return null;
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
return { id: e[1], text: String(amount), type: 'crit' };
|
||||
}
|
||||
if (e[0] !== 'dmg' && e[0] !== 'heal') return null;
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (amount <= 0) return null;
|
||||
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
|
||||
return { id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the DOM fallback view.
|
||||
* @param {HTMLElement} host
|
||||
@@ -78,7 +102,7 @@ export function createFallbackView(host, opts = {}) {
|
||||
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L', deployField: 'normal',
|
||||
mode: 'prep', priv: null, editable: false, canPlace: null,
|
||||
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
|
||||
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
|
||||
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, officialDamageNumbers: false, ...(opts.settings || {}) },
|
||||
size: { w: 0, h: 0 }, destroyed: false, pen: [],
|
||||
};
|
||||
|
||||
@@ -337,7 +361,9 @@ export function createFallbackView(host, opts = {}) {
|
||||
const pos = tilePos(L, u.y, u.x);
|
||||
const src = tokenAvatarUrl(m(), u.defId);
|
||||
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}
|
||||
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
title=${u.name || u.defId}>
|
||||
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
|
||||
</div>`);
|
||||
@@ -456,11 +482,9 @@ export function createFallbackView(host, opts = {}) {
|
||||
if (e[0] === 'spawn' && e[1] && e[1].id != null) {
|
||||
st.units.set(e[1].id, e[1]);
|
||||
if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]);
|
||||
} else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) {
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (amount <= 0) continue;
|
||||
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
|
||||
st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now });
|
||||
} else if (st.floats.length < 60) {
|
||||
const spec = fallbackFloat(e, st.settings);
|
||||
if (spec) st.floats.push({ key: `${now}:${Math.random()}`, id: spec.id, text: spec.text, type: spec.type, at: now });
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
|
||||
@@ -20,6 +20,22 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
|
||||
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
||||
|
||||
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
||||
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
||||
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
||||
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
|
||||
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
|
||||
let refreshPending = false;
|
||||
const refreshBusyListeners = new Set();
|
||||
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
||||
|
||||
/**
|
||||
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
||||
* @param {(busy: boolean) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
|
||||
|
||||
/**
|
||||
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||
* @param {string} t
|
||||
@@ -55,7 +71,13 @@ export const actions = {
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
refresh: () => act('g.refresh'),
|
||||
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
||||
refresh: () => {
|
||||
if (refreshPending) return Promise.resolve(false);
|
||||
refreshPending = true;
|
||||
emitRefreshBusy();
|
||||
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
||||
},
|
||||
freeze: () => act('g.freeze'),
|
||||
levelUp: () => act('g.levelUp'),
|
||||
sell: (uid) => act('g.sell', { uid }),
|
||||
|
||||
@@ -201,8 +201,9 @@ export function GIcon({ name, class: cls, title }) {
|
||||
|
||||
/**
|
||||
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
|
||||
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
|
||||
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
|
||||
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
|
||||
* shown in red with a −N tick that pops again on every change (keyed); `note` a
|
||||
* small tag after it (联防中); `tip` the title text.
|
||||
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
|
||||
*/
|
||||
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
|
||||
@@ -211,7 +212,7 @@ export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, not
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
|
||||
data-pending=${p > 0 ? p : null}>
|
||||
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
|
||||
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
|
||||
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
|
||||
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
|
||||
${note ? html`<span class="lp__note">${note}</span>` : null}
|
||||
</span>`;
|
||||
|
||||
@@ -4,12 +4,23 @@ import { GEO, PHASE } from '../../../../shared/constants.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
|
||||
import { int, isObj } from './shared.js';
|
||||
|
||||
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
|
||||
function prepPair(pub, myId) {
|
||||
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
|
||||
if (!alive.some(p => p.playerId === myId)) return [];
|
||||
if (pub.bossPairs !== undefined) {
|
||||
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
|
||||
}
|
||||
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
|
||||
const i = ids.indexOf(myId);
|
||||
return ids.slice(i - i % 2, i - i % 2 + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
|
||||
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
|
||||
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
|
||||
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
|
||||
* finalAssault.js pairPlayers, sent in m.public.bossPairs; the second of a pair is on the mirrored
|
||||
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
|
||||
* @param {any} pub m.public
|
||||
* @param {string|null} myId
|
||||
@@ -19,9 +30,7 @@ export function prepCamera(pub, myId) {
|
||||
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
|
||||
const r = pub?.round;
|
||||
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
|
||||
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
|
||||
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
|
||||
const idx = alive.findIndex((p) => p.playerId === myId);
|
||||
const idx = prepPair(pub, myId).indexOf(myId);
|
||||
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
|
||||
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
|
||||
}
|
||||
@@ -32,7 +41,7 @@ const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]
|
||||
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
|
||||
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
|
||||
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
|
||||
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* in the server's saved pairs — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
|
||||
* round. PR #191 framed the partner alone, in gold (not merged).
|
||||
* @param {any} pub m.public @param {string|null} myId
|
||||
@@ -51,9 +60,7 @@ export function teamFrameIds(pub, myId) {
|
||||
return f && f.players.length > 1 ? new Set(f.players) : none;
|
||||
}
|
||||
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
|
||||
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
|
||||
const idx = alive.indexOf(myId);
|
||||
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
|
||||
const pair = prepPair(pub, myId);
|
||||
return pair.length > 1 ? new Set(pair) : none;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
|
||||
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
@@ -44,6 +44,7 @@ export function sanitizeSettings(raw) {
|
||||
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
officialDamageNumbers: typeof r.officialDamageNumbers === 'boolean' ? r.officialDamageNumbers : false,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
|
||||
keys: sanitizeHotkeys(r.keys),
|
||||
|
||||
+9
-7
@@ -12,13 +12,14 @@
|
||||
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
|
||||
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
|
||||
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
|
||||
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower marks the own battle's leaks live as its enemies enter the blue
|
||||
// gate — the settled lp stays until settlement, in red with a −min(lpCapPerRound, counted leaks) tick (GitHub #479: the
|
||||
// official keeps the current value until settlement), 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
|
||||
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
|
||||
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
|
||||
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower marks lp with −min(lpCapPerRound, N).
|
||||
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
|
||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||
@@ -183,9 +184,10 @@ export function uniteRemaining(local, server) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
|
||||
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
|
||||
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is marked at once (red, −N) instead
|
||||
* of only at settlement — the number itself keeps the settled LP until settlement (GitHub #479: the official shows the
|
||||
* current value, not the deduction, until the round settles). `base` — kept by the caller between renders — is the
|
||||
* settled m.private state the pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
|
||||
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
|
||||
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
|
||||
@@ -352,7 +354,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
|
||||
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
|
||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
||||
* live: the own battle's pending LP loss (liveLp): the tower keeps the settled lp and adds a red −N tick, 联防中 during 联防;
|
||||
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
||||
*/
|
||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||
|
||||
@@ -169,6 +169,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
const tell = notify || ((text) => toast(t(text), 'warn'));
|
||||
let timer = null;
|
||||
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let revision = 0; // edits and welcome invalidate a preparation still awaiting game data (GitHub #540)
|
||||
let pendingJson = null; // JSON of the newest request still awaiting its reply
|
||||
let lastSent = null; // JSON of the last payload the server accepted (on this session)
|
||||
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
|
||||
@@ -189,9 +190,12 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
async function flush() {
|
||||
if (disposed) return;
|
||||
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
|
||||
const preparing = revision;
|
||||
try {
|
||||
const payload = await prepare();
|
||||
if (disposed) return;
|
||||
// a newer edit or welcome happened while prepare awaited chess/backups: do not send, and do not touch the
|
||||
// sync state that newer flush owns (an older skill must not overwrite a restore-defaults)
|
||||
if (disposed || preparing !== revision) return;
|
||||
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
|
||||
if (payload == null) { setState('error'); return; }
|
||||
const json = JSON.stringify(payload);
|
||||
@@ -221,15 +225,16 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
|
||||
}
|
||||
} catch (e) {
|
||||
if (disposed || preparing !== revision) return;
|
||||
console.warn(`[${tag}] sync failed`, e);
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const offWelcome = net.on('welcome', () => { revision++; lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const offStore = target.subscribe((s, prev) => {
|
||||
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
|
||||
if (keys.some((k) => s[k] !== prev[k])) { revision++; edited = true; schedule(); }
|
||||
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
||||
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
||||
|
||||
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
|
||||
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
|
||||
* one highlighted); null when none are banned.
|
||||
* @param {any} bond @param {MatchInfoModel} model @param {Set<string>} banned
|
||||
*/
|
||||
function BondBannedAside(bond, model, banned) {
|
||||
const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
|
||||
if (!ids.length) return null;
|
||||
const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
|
||||
return html`<div class="brief-bantip">
|
||||
<div class="brief-bantip__h">${t('本局禁用干员')}</div>
|
||||
${ids.map((id) => {
|
||||
const c = data.lookup('chess', id);
|
||||
const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
|
||||
return html`<div key=${id} class="brief-bantip__op" data-chess=${id}>
|
||||
<${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
|
||||
<div class="brief-bantip__txt"><b>${c?.name || id}</b>
|
||||
<span>${bonds.map((b, i) => html`${i ? ' · ' : ''}<span class=${cx(b === bond.bondId && 'is-this')}>${nameOf.get(b)}</span>`)}</span></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
|
||||
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
|
||||
* which (briefingBondTip).
|
||||
* which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
|
||||
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
|
||||
*/
|
||||
export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const m = data.get('assets');
|
||||
const banned = new Set(model.banned);
|
||||
return html`<div class="brief-bonds">
|
||||
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
|
||||
<div class="brief-bonds__row">
|
||||
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const state = model.stateOf(b.bondId);
|
||||
const off = !!state;
|
||||
const bannedN = model.perBond.get(b.bondId) || 0;
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
|
||||
aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
|
||||
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
|
||||
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
|
||||
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
|
||||
|
||||
@@ -263,6 +263,8 @@ export function SettingsModal({ open, onClose }) {
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<${Toggle} label=${t('官方伤害数字')} micro="OFFICIAL NUMBERS" value=${s.officialDamageNumbers} onChange=${(v) => updateSettings({ officialDamageNumbers: v })} />
|
||||
<p class="set-hint">${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}</p>
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||||
<div class="set-seg" role="radiogroup">
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
||||
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
|
||||
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
|
||||
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
|
||||
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
|
||||
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
|
||||
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
|
||||
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
|
||||
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
|
||||
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
|
||||
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||
</button>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||
<span>${t('刷新')}</span>
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||
<kbd>${hk.refresh}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ cd /d "%~dp0.."
|
||||
|
||||
where node >nul 2>nul
|
||||
if errorlevel 1 goto :nonode
|
||||
node -e "process.exit(Number(process.versions.node.split('.')[0])>=22?0:1)"
|
||||
node -e "process.exit(Math.max(0, Math.sign(22 - parseInt(process.versions.node))))"
|
||||
if errorlevel 1 goto :oldnode
|
||||
|
||||
if not exist "node_modules\ws\package.json" (
|
||||
|
||||
@@ -373,6 +373,7 @@ export class Match {
|
||||
this.sp = null;
|
||||
this.wave = null;
|
||||
this.bossWaves = null;
|
||||
this.bossPairs = null; // drawn once per match; the hidden round retains partners and sides
|
||||
this.fields = [];
|
||||
this.runner = null;
|
||||
/** playerId → fieldId */
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
|
||||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite; equipment returns to the hand; a
|
||||
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
|
||||
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
|
||||
// playtest #6 item 19; a short tier tops up from the tier below; the player's stocked 自选 of that tier joins,
|
||||
// §30.23; pick 1, expires at prep end; an offer earned
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Where the elite goes (PRTS
|
||||
// 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)", user
|
||||
// playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's tile with its facing (of several,
|
||||
@@ -116,7 +117,7 @@
|
||||
// battleInput
|
||||
// views.js pieceView, effectsView, m.private (privateView)
|
||||
// diy.js 自选编队: the picks (setDiy), the player's data view, the DIY stock (initDiyStock, poolOf), the shop's
|
||||
// DIY draws (diyRollEntries), the pick of a piece (diyPickOf)
|
||||
// DIY draws (diyRollEntries) and the reward / grant draws (diyStockEntries), the pick of a piece (diyPickOf)
|
||||
// common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail
|
||||
|
||||
import { FIELD } from './board.js';
|
||||
|
||||
+12
-4
@@ -30,7 +30,7 @@
|
||||
// units > seat, research 08 §5); leakers = players with counted leaks
|
||||
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
|
||||
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
|
||||
// final assault fields pair alive players by seat, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// final assault fields follow the saved per-match pairs, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// after a win when hiddenEligible() holds
|
||||
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
|
||||
// Σ alive players' LP = the merged team LP after the Final Assault
|
||||
@@ -44,7 +44,7 @@
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
|
||||
@@ -449,15 +449,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
});
|
||||
wrap(m, 'startFinalAssault', function (orig, hidden) {
|
||||
const alive = m.alivePlayers();
|
||||
const before = m.bossPairs?.map(g => g.slice()) ?? null;
|
||||
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
|
||||
const teamLp0 = m.teamLp;
|
||||
const res = orig(hidden);
|
||||
runInvariants();
|
||||
check('final assault', () => {
|
||||
if (!alive.length) return;
|
||||
const groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(','));
|
||||
const pairs = m.bossPairs || [];
|
||||
const memberIds = pairs.flat();
|
||||
if (pairs.length !== Math.ceil(alive.length / 2) || pairs.some(g => g.length < 1 || g.length > 2)
|
||||
|| new Set(memberIds).size !== memberIds.length
|
||||
|| JSON.stringify([...memberIds].sort()) !== JSON.stringify(alive.map(p => p.playerId).sort())) fail('boss pairing does not cover every alive player once');
|
||||
if (before && before.flat().every(id => alive.some(p => p.playerId === id)) && before.flat().length === alive.length
|
||||
&& JSON.stringify(before) !== JSON.stringify(pairs)) fail('boss pairing changed after preparation');
|
||||
const groups = pairs.map(g => g.join(','));
|
||||
const fields = m.fields.map((f) => f.players.join(','));
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat pairs ${groups.join(' | ')}`);
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != saved pairs ${groups.join(' | ')}`);
|
||||
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
|
||||
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
|
||||
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
|
||||
|
||||
+21
-8
@@ -138,16 +138,26 @@ const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
|
||||
* preferred). Alone, a band that withholds the first rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy
|
||||
* leaks) is avoided — only 联防 teammates cover that. A band whose mechanic rides on a bond the mode switches off
|
||||
* (gd.bandBondIds ∩ gd.modeInactiveBonds — 标准: 潘格尼尼 <拉特兰>, 克莱门莎 <阿戈尔>, 玛恩纳 <卡西米尔>) weighs 0, never taken
|
||||
* (DESIGN §21.26); with every band excluded, the default band. One rng draw per call (deterministic per seed); modes
|
||||
* without inactive bonds keep exactly the earlier picks.
|
||||
* (DESIGN §21.26). [ASSUMED] A living teammate's opening strategy soft-reserves those bonds before anyone is deployed:
|
||||
* the weight stays positive, at a quarter of the unreserved weight, so the strategy can still be drawn, and the
|
||||
* reservation ends when that teammate is eliminated (alivePlayers). With every band excluded, the default band. One
|
||||
* rng draw per call (deterministic per seed); a solo match has no teammate to reserve against.
|
||||
*/
|
||||
const SOFT_BAND_RESERVE = 0.25;
|
||||
export function botPickBand(m, ps) {
|
||||
const gd = ps?.gd || m.gd;
|
||||
const ids = gd.bandIds();
|
||||
if (!ids.length) return gd.defaultBandId;
|
||||
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || ''));
|
||||
const offBond = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b));
|
||||
const pairs = ids.map((id) => [id, offBond(id) ? 0 : Math.max(1, (gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
|
||||
const reserved = new Set(ps && typeof m.alivePlayers === 'function'
|
||||
? m.alivePlayers().filter((p) => p !== ps).flatMap((p) => gd.bandBondIds(p.bandId))
|
||||
: []);
|
||||
const inactive = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b));
|
||||
const pairs = ids.map((id) => {
|
||||
if (inactive(id)) return [id, 0];
|
||||
const w = Math.max(1, (gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1);
|
||||
return [id, gd.bandBondIds(id).some((b) => reserved.has(b)) ? w * SOFT_BAND_RESERVE : w];
|
||||
});
|
||||
let total = 0;
|
||||
for (const [, w] of pairs) total += w;
|
||||
let r = m.rngBots() * total;
|
||||
@@ -348,12 +358,14 @@ function mainCoreBond(m, p) {
|
||||
* The bonds the bot builds around (cached per round and owned set): `focus` = the core bond with the best
|
||||
* owned members × 10 + reachable members (bondPoolStats, ≤ 4) + layers already banked (≤ 6) + 6 for last round's focus
|
||||
* (commit instead of flip-flopping between equal bonds) − 6 when a teammate already builds it (its main core bond on
|
||||
* its public bond strip, mainCoreBond — the shop pool is shared); `second` = the other live bond (core or add-on) with
|
||||
* the most owned members whose next threshold is within reach (≥ 1 owned member). `ps._botFocusId` keeps the focus
|
||||
* across rounds.
|
||||
* its public bond strip, mainCoreBond — the shop pool is shared) or has selected that bond's opening strategy, even
|
||||
* before anyone is deployed. The strategy reservation ends when that teammate is eliminated. `second` = the other live
|
||||
* bond (core or add-on) with the most owned members whose next threshold is within reach (≥ 1 owned member).
|
||||
* `ps._botFocusId` keeps the focus across rounds.
|
||||
*/
|
||||
export function bondPlan(m, ps, owned = ownedBonds(m, ps)) {
|
||||
const key = `${m.round}|${ps.shop.level}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`;
|
||||
const livingBands = m.alivePlayers().filter((p) => p !== ps).flatMap((p) => m.gd.bandBondIds(p.bandId)).sort().join();
|
||||
const key = `${m.round}|${ps.shop.level}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}|${livingBands}`;
|
||||
if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus;
|
||||
const prev = ps._botFocusId ?? null;
|
||||
const mates = new Set();
|
||||
@@ -361,6 +373,7 @@ export function bondPlan(m, ps, owned = ownedBonds(m, ps)) {
|
||||
if (p === ps) continue;
|
||||
const b = mainCoreBond(m, p);
|
||||
if (b) mates.add(b);
|
||||
for (const id of m.gd.bandBondIds(p.bandId)) mates.add(id);
|
||||
}
|
||||
let focus = null;
|
||||
let bestS = 0;
|
||||
|
||||
@@ -480,6 +480,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
lp: () => ps.lp,
|
||||
alive: () => ps.alive,
|
||||
bandId: () => ps.bandId,
|
||||
teamMedicGranted: () => m.teamMedicGranted(),
|
||||
shopLevel: () => ps.shop.level,
|
||||
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
|
||||
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
|
||||
@@ -569,6 +570,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
source: opts.source || source.key || 'effect',
|
||||
toTemp: !!opts.toTemp,
|
||||
deferMerge: ps._deferItemMerge > 0,
|
||||
requirePool: opts.requirePool !== false,
|
||||
});
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
|
||||
// continues with what is left.
|
||||
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
|
||||
// * Pairing: shuffled once per match before Final Assault prep; Hidden Core keeps those pairs. If players leave,
|
||||
// intact pairs stay together and the remaining single players pair up. An odd player uses the `_s` template
|
||||
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
|
||||
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
|
||||
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
|
||||
@@ -55,11 +56,22 @@ export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
|
||||
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
|
||||
}
|
||||
|
||||
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
|
||||
export function pairPlayers(alive) {
|
||||
/** Seeded initial pairing; `previous` carries player-id pairs through prep, combat and Hidden Core. */
|
||||
export function pairPlayers(alive, rng, previous = null) {
|
||||
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
|
||||
const groups = [];
|
||||
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
|
||||
let singles;
|
||||
if (Array.isArray(previous)) {
|
||||
const remaining = new Map(sorted.map(p => [p.playerId, p]));
|
||||
singles = [];
|
||||
for (const ids of previous) {
|
||||
const group = [];
|
||||
for (const id of ids) if (remaining.has(id)) { group.push(remaining.get(id)); remaining.delete(id); }
|
||||
if (group.length === 2) groups.push(group); else singles.push(...group);
|
||||
}
|
||||
singles.push(...remaining.values());
|
||||
} else singles = sorted.length > 1 ? rng.shuffle(sorted) : sorted;
|
||||
for (let i = 0; i < singles.length; i += 2) groups.push(singles.slice(i, i + 2));
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ export class GameData {
|
||||
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
|
||||
this._chess = chess;
|
||||
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
|
||||
// Fixed operator gifts are unlimited outputs, but every produced normal/advanced item still occupies 1/2 copies
|
||||
// (card-pool experiment BV1eLXXBqEgF, maintainer-confirmed 2026-10-11). Random subset gifts are not exempt.
|
||||
this._fixedGiftItems = new Set(Object.values(this.raw.garrisons || {})
|
||||
.filter(g => g.effectKey === 'SERVER_GAIN_EQUIP').map(g => g.bbStr?.chess).filter(id => this._items[id]));
|
||||
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
|
||||
/** visible, shop-eligible base (normal) chess ids */
|
||||
this.visibleChess = Object.keys(chess).filter((id) => {
|
||||
@@ -312,6 +316,9 @@ export class GameData {
|
||||
return Number.isInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
/** Whether a fixed trait gift may be produced beyond stock (including the advanced version of that item). */
|
||||
isUnlimitedGiftItem(id) { return this._fixedGiftItems.has(this.baseIdOf(id)); }
|
||||
|
||||
/** Copies needed to merge (0 = never merges: golden chess). */
|
||||
mergeCount(id) {
|
||||
const c = this.chess(id);
|
||||
|
||||
@@ -202,14 +202,15 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
|
||||
|
||||
// Shared shop equipment. The chess pool above may go negative (Mimic). Item stock may not: held ≤ cap.
|
||||
// Fixed operator gifts may overdraw equipment stock; ownership still counts and keeps shop availability at zero.
|
||||
// Other item families retain their cap. The chess pool above also permits explicit overdraw (Mimic).
|
||||
if (m.itemPool && gd.raw.items) {
|
||||
for (const [id, rec] of Object.entries(gd.raw.items)) {
|
||||
if (!rec || rec.isGolden) continue;
|
||||
const cap = m.itemPool.cap(id);
|
||||
if (!Number.isInteger(cap)) continue;
|
||||
const h = m.itemPool.held(id);
|
||||
if (h > cap) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
if (h > cap && !gd.isUnlimitedGiftItem(id)) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { deriveSeed } from '../../sim/rng.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { buildBossWave, bountySpawns } from '../waves.js';
|
||||
import { pairPlayers, bossPoolHp, SharedBossPool, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
|
||||
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js';
|
||||
@@ -66,7 +66,8 @@ export class MatchBoss {
|
||||
},
|
||||
});
|
||||
this.bossPool = pool;
|
||||
const groups = pairPlayers(alive);
|
||||
const groups = pairPlayers(alive, createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
|
||||
this.fields = groups.map((g, i) => {
|
||||
const solo = this.isSolo || g.length === 1;
|
||||
|
||||
@@ -88,8 +88,11 @@ export class MatchInfra {
|
||||
}
|
||||
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
|
||||
|
||||
/** [ASSUMED] Touch's initial, match-long team grant survives its holder's elimination/quit; focus alone grants none. */
|
||||
teamMedicGranted() { return this.order.some(p => p.bandId === 'band_amedic'); }
|
||||
|
||||
/**
|
||||
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
|
||||
* The boss-round group of a player (`bossWaves`: the per-match random pairs, planned in startRound
|
||||
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
|
||||
* half) — or null outside a boss round / for a player without a field.
|
||||
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { buildNormalWave, buildBossWave } from '../waves.js';
|
||||
import { pairPlayers } from '../finalAssault.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { botPickBand } from '../bot.js';
|
||||
import { OK, fail, DELAYS, BAND_TURN_SECONDS } from './common.js';
|
||||
|
||||
@@ -288,11 +289,14 @@ export class MatchPhases {
|
||||
for (const ps of this._viewers()) this._followScout(ps);
|
||||
}
|
||||
|
||||
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
|
||||
/** Stable per-match pairs and their templates, generated before the prep preview / board validation. */
|
||||
_planBossWaves() {
|
||||
const r = this.round;
|
||||
const bossId = r === this.gd.hiddenRound && r !== this.gd.bossRound ? this.hiddenBossId : this.bossId;
|
||||
this.bossWaves = pairPlayers(this.alivePlayers()).map((g) => ({
|
||||
// Pairing uses an independent seeded draw, consuming no shop, wave, draft or battle randomness.
|
||||
const groups = pairPlayers(this.alivePlayers(), createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
this.bossWaves = groups.map((g) => ({
|
||||
players: g.map((p) => p.playerId),
|
||||
wave: buildBossWave(this.gd, this.rngWaves, this.factions, r, { bossId, solo: this.isSolo || g.length === 1 }),
|
||||
}));
|
||||
|
||||
@@ -86,6 +86,7 @@ export class MatchViews {
|
||||
hiddenBossId: this.hiddenBossId,
|
||||
bossRound: this.gd.bossRound,
|
||||
hiddenRound: this.gd.hiddenRound,
|
||||
bossPairs: this.bossWaves ? this.bossWaves.map(w => w.players.slice()) : this.bossPairs ? this.bossPairs.map(ids => ids.slice()) : null,
|
||||
spRound: this.gd.spRounds().includes(this.round),
|
||||
// DESIGN §14: 'client' = battles are simulated by the browsers (b.start specs), 'server' = legacy streaming
|
||||
combatMode: this.clientCombat ? 'client' : 'server',
|
||||
@@ -248,7 +249,7 @@ export class MatchViews {
|
||||
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
|
||||
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
|
||||
prepMapChars(ps) {
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.teamMedicGranted()) return [];
|
||||
const id = 'char_605_cmedic';
|
||||
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
|
||||
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
|
||||
|
||||
@@ -215,8 +215,11 @@ export class PlayerAcquire {
|
||||
* copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops
|
||||
* up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice —
|
||||
* the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one).
|
||||
* The player's slotted 自选 pieces join the draw like in its shop (player/diy.js diyRollEntries: once the 调度中心 is at
|
||||
* the slot's level) — the reward is a temporary refresh of the shop ("临时刷新3名…干员") [ASSUMED].
|
||||
* The player's slotted 自选 pieces of the reward tier join the draw (player/diy.js diyStockEntries: copies left, then
|
||||
* the roll's tier — not the ordinary shop's shopLevel gate). 卫戍协议/帮助 干员晋升 names one temporary refresh one tier
|
||||
* above the 调度中心 and no separate pool; the owner decided on 2026-10-12 that the refresh includes 自选 of the newly
|
||||
* reached tier. 0.2.0 had [ASSUMED] the shop-level gate on this refresh too (DESIGN §25); §30.23 reverses that.
|
||||
* An offer built from explicit `ids` does not roll.
|
||||
*/
|
||||
pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) {
|
||||
const ro = this.gd.rewardOffer();
|
||||
@@ -228,7 +231,7 @@ export class PlayerAcquire {
|
||||
const fresh = (id) => !list.includes(id);
|
||||
for (let i = 0; i < ro.count; i++) {
|
||||
let id = null;
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyRollEntries() });
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyStockEntries() });
|
||||
if (id) list.push(id);
|
||||
}
|
||||
}
|
||||
@@ -285,10 +288,13 @@ export class PlayerAcquire {
|
||||
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
|
||||
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
|
||||
*/
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false, requirePool = true } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
if (!this.m.itemPool.canGain(itemId)) {
|
||||
// Only the fixed operator-gift families can overdraw. Several unrelated strategy grants also pass
|
||||
// requirePool:false; they must retain their stock limit (for example commercial packaging).
|
||||
const unlimitedGift = !requirePool && this.gd.isUnlimitedGiftItem(itemId);
|
||||
if (!unlimitedGift && !this.m.itemPool.canGain(itemId)) {
|
||||
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,16 @@
|
||||
// gets no stock and so leaves the shop, like a preset chess whose bonds are all off (PRTS 卫戍协议:盟约 "禁用盟约有可能
|
||||
// 影响自选的支援干员"); 协防 (emptyShip) is never banned, so a prototype always stays. poolOf(baseId) routes every copy
|
||||
// taken or returned for a slotted slot to that stock (buy, reward picks, merges, promotions, sells, temp, elimination).
|
||||
// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted rolls — the
|
||||
// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once
|
||||
// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at
|
||||
// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any
|
||||
// chess of its tier: 4).
|
||||
// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted shop rolls,
|
||||
// weighted like any chess of its tier, once the 调度中心 has reached the slot's shopLevel (activity_table
|
||||
// shopLevelDisplayDataDict lists the tier-5 slots at level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心
|
||||
// 等级 ≥ 干员所在等阶" is that persistent shop). The price is the slot's (any chess of its tier: 4).
|
||||
// * the promotion reward (pushRewardOffer with no explicit ids) draws diyStockEntries() under the reward tier
|
||||
// min(shopLevel + 1, 6): a tier-5 自选 piece can appear when the 调度中心 reaches 4, a tier-6 piece when it reaches
|
||||
// 5, one shop level before the ordinary shop. 卫戍协议/帮助 干员晋升 names that refresh and no second pool; the owner
|
||||
// decided on 2026-10-12 that it includes 自选 of the newly reached tier. 0.2.0 had [ASSUMED] the shop-level gate
|
||||
// for this refresh too (DESIGN §25); §30.23 reverses it. A piece is not guaranteed. Banned slots and `left <= 0`
|
||||
// stay out. Explicit-id offers do not use this draw.
|
||||
// * random grants (diyStockEntries, 0.2.0 WE2 #9): an effect, reward or 机变 card that grants this player a random
|
||||
// operator from the pool draws its stock too — 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」 (bilibili), "调度中心
|
||||
// 随机资源的范围将被扩大" (PRTS 新手教程) — under the roll's own tier rules (the effect's tier / maxTier: no 调度中心 gate,
|
||||
@@ -206,8 +211,9 @@ export class PlayerDiy {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock entries this player's shop draws from now ([baseId, entry] with copies left and the 调度中心 at the slot's
|
||||
* shopLevel), or null (none — the shared pool's roll alone, exactly as without picks).
|
||||
* The stock entries this player's ordinary shop draws from now ([baseId, entry] with copies left and the 调度中心 at
|
||||
* the slot's shopLevel), or null (none — the shared pool's roll alone, exactly as without picks). The promotion
|
||||
* reward does not use this gate (`diyStockEntries`).
|
||||
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
|
||||
*/
|
||||
diyRollEntries() {
|
||||
@@ -218,8 +224,9 @@ export class PlayerDiy {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock entries a random grant of this player draws besides the shared pool (see the header): every slotted piece
|
||||
* with copies left, whatever the 调度中心 level (the roll's own tier rules apply), or null (none).
|
||||
* The stock entries a random grant, and the promotion reward's temporary refresh, draw besides the shared pool
|
||||
* (see the header): every slotted piece with copies left, whatever the 调度中心 level (the roll's own tier rules
|
||||
* apply), or null (none).
|
||||
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
|
||||
*/
|
||||
diyStockEntries() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { ERR, PHASE, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { freeSlot } from '../board.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -47,6 +47,10 @@ export class PlayerEconomy {
|
||||
if (add <= 0) return 0;
|
||||
this.layers[bondId] = before + add;
|
||||
this.recompute();
|
||||
// A live prep gain, not a view delta: reconnects and combat-result backfill must not replay the cue.
|
||||
if ([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(this.m.phase)) {
|
||||
this.m.sendTo(this.playerId, { t: 'm.layer', playerId: this.playerId, bondId, add });
|
||||
}
|
||||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||||
return add;
|
||||
}
|
||||
@@ -141,10 +145,9 @@ export class PlayerEconomy {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base);
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Already displayed operators stay purchasable after stock runs out (#498; 路标月报#2,
|
||||
// BV1eLXXBqEgF 2:50–2:57: nine owned + four shop offers => thirteen owned).
|
||||
// acquireChess records every copy, including the deficit; future rolls still exclude empty stock.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
this.spend(price);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -33,15 +33,13 @@ export class PlayerPrep {
|
||||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||||
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
} else {
|
||||
} else if (slot.kind === 'chess') {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Owner decision 2026-10-11 (#498): already displayed operator rewards survive stock exhaustion.
|
||||
// acquireChess records the deficit. Item rewards above still require itemPool.canGain.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
}
|
||||
} else return fail(ERR.BAD_TARGET);
|
||||
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
|
||||
if (price > this.funds) return fail(ERR.NO_FUNDS);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// (config.economy.poolCopies[tier], measured identity overrides; co-op shared stock doubled). `left` is the
|
||||
// signed capacity minus occupation; the public left() and shop sampling clamp a deficit to zero.
|
||||
// * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays
|
||||
// do NOT reserve copies; buying fails (SOLD_OUT) when left = 0.
|
||||
// do NOT reserve copies. An item buy fails (SOLD_OUT) when left = 0; an operator card already shown in the shop or a
|
||||
// reward can still be taken and overdraws (a negative raw left, the debt — GitHub #498, the owner's decision).
|
||||
// * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return
|
||||
// exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0.
|
||||
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
|
||||
@@ -14,8 +15,9 @@
|
||||
// Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level
|
||||
// ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a
|
||||
// uniform shop-eligible item of that tier (falling back to lower tiers). A roll may add entries outside the pool
|
||||
// (`extra`, after its own): one player's 自选 stock (0.2.0, player/diy.js diyRollEntries) — weighted by its copies like
|
||||
// any chess, drawn by that player's shop only.
|
||||
// (`extra`, after its own): one player's 自选 stock — the ordinary shop passes diyRollEntries (the shopLevel gate);
|
||||
// the promotion reward and random grants pass diyStockEntries (the roll's own tier, DESIGN §30.23). Weighted by its
|
||||
// copies like any chess, drawn by that player only.
|
||||
|
||||
/**
|
||||
* Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon`
|
||||
|
||||
@@ -139,7 +139,7 @@ export function uniteBattleOpts(m, plan, timeLimit) {
|
||||
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
|
||||
// HP ratio and SP only: a skill still running at the end is not carried (unitsEnd `skillActive` stays unused —
|
||||
// community report #34, GitHub #82)
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp });
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0, Math.min(1, u.hpPct)) : 1, sp });
|
||||
}
|
||||
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
|
||||
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
|
||||
|
||||
+30
-3
@@ -171,6 +171,16 @@ export function acquireTargets(b, u, prof) {
|
||||
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
// an ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon with 嘲讽等级 −2) after every enemy
|
||||
const extra = b._allyTargets && b._allyTargets.size ? b.allyTargetsInKeys(u.rangeKeys, u) : null;
|
||||
// Fortress has an extra melee tile even against enemies it cannot block (PRTS branch / 号角). Its ordinary
|
||||
// ranged targets still take priority; this does not fill the other tiles in its ranged blind spot.
|
||||
if (prof.fortress && !cands.length && !extra?.length) {
|
||||
const here = [u.tileR * COLS + u.tileC];
|
||||
const near = b.enemiesInKeys(here, u, prof);
|
||||
if (!near.length && b._allyTargets?.size) near.push(...b.allyTargetsInKeys(here, u));
|
||||
prof._fortressMelee = near.length > 0;
|
||||
sortEnemyTargets(b, u, near, prof.priority);
|
||||
return near.slice(0, 1);
|
||||
}
|
||||
if (extra && extra.length) {
|
||||
if (prof.allInRange) return cands.concat(extra);
|
||||
sortEnemyTargets(b, u, cands, prof.priority);
|
||||
@@ -225,7 +235,8 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
const t = targets[i];
|
||||
b._ev(['atk', u.id, t.id, vis]);
|
||||
if (isHeal) { doHeal(b, u, prof, t); continue; }
|
||||
const info = { isSkill, index: i, attackId, energy: i === 0 ? energy : 0 };
|
||||
// Target acquisition mutates the fortress profile. Keep this shot's mode while its shell is in flight.
|
||||
const info = { isSkill, index: i, attackId, fortressMelee: !!prof._fortressMelee, energy: i === 0 ? energy : 0 };
|
||||
const foe = t.side === 'enemy' || b.isAllyTarget(t);
|
||||
if (ranged && foe && prof.projectile === 'boomerang') {
|
||||
throwBoomerang(b, u, prof, t, info);
|
||||
@@ -307,13 +318,29 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
if (target && target.alive) {
|
||||
let mulT = skillMul;
|
||||
if (prof.dmgMul) { const m = typeof prof.dmgMul === 'function' ? prof.dmgMul(b, u, target) : prof.dmgMul; if (Number.isFinite(m)) mulT *= m; }
|
||||
const funnelMul = prof.funnelMul ? prof.funnelMul(b, u, target) : 1;
|
||||
// Unreleased drones are additional damage on the caster's attack (PRTS 驭械术师), including a skill that only
|
||||
// buffs or retargets that attack. `info.isSkill` is set for every attack or targeting override, so it must not
|
||||
// decide this. `funnelBodyHits === 0` is the drone-only opt-out (the ramp stays inside the hits, no body).
|
||||
// A volley shares one talent roll and attackId; extra drones never duplicate the body.
|
||||
// [ASSUMED] Body instances precede additions at the existing projectile impact; a multi-hit volley keeps one ramp.
|
||||
const attachedFunnel = !!prof.funnelMul && prof.funnelBodyHits !== 0;
|
||||
if (!attachedFunnel) mulT *= funnelMul;
|
||||
const hits = prof.hitsFn ? prof.hitsFn(b, u, info) : Math.max(1, prof.hits || 1);
|
||||
// `hitDmgMul` (professions.js header): each instance's 伤害倍率 — the DamageInfo `mul`, after DEF / RES — and only the
|
||||
// first instance may give 受击回复 (砾's two 50 % hits, PRTS 砾 特性备注)
|
||||
const split = Number.isFinite(prof.hitDmgMul) && prof.hitDmgMul > 0 ? prof.hitDmgMul : null;
|
||||
let dealtMain = 0;
|
||||
if (attachedFunnel && info.index === 0) {
|
||||
const bodyHits = Math.max(1, prof.funnelBodyHits > 0 ? prof.funnelBodyHits : 1);
|
||||
for (let h = 0; h < bodyHits && target.alive; h++) {
|
||||
dealtMain += b.dealDamage(u, target, { amount: atk * scale * mulT, type: baseType,
|
||||
isAttack: true, isSkill: info.isSkill, tags: prof.tags || [], attackId });
|
||||
}
|
||||
}
|
||||
for (let h = 0; h < hits && target.alive; h++) {
|
||||
const d = { amount: atk * scale * mulT, type: baseType, isAttack: true, isSkill: info.isSkill, tags: prof.tags || [], attackId };
|
||||
const d = { amount: atk * scale * mulT * (attachedFunnel ? funnelMul : 1), type: baseType,
|
||||
isAttack: !attachedFunnel, isSkill: info.isSkill, tags: attachedFunnel ? [...(prof.tags || []), 'addition', 'funnel'] : (prof.tags || []), attackId };
|
||||
if (split != null) { d.mul = split; if (h > 0) d.noSp = true; }
|
||||
dealtMain += b.dealDamage(u, target, d);
|
||||
}
|
||||
@@ -323,7 +350,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
x = target.x; y = target.y;
|
||||
}
|
||||
// splash: a 中点判定 radius around the target's position — a huge enemy counts by its 判定中心 (Battle.enemiesInRadius)
|
||||
if (prof.splashRadius > 0) {
|
||||
if (prof.splashRadius > 0 && (!info.fortressMelee || prof.fortressMeleeSplash)) {
|
||||
const r = prof.splashRadius;
|
||||
const sc = prof.splashScale ?? 1;
|
||||
for (const e of b.foesInRadius(x, y, r, true)) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export class BattleDeploy {
|
||||
* operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges }
|
||||
* restored right after the skill reset, before the `deploy` hook fires.
|
||||
*/
|
||||
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) {
|
||||
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null, preBattleAggroSeq = null } = {}) {
|
||||
if ((u.alive && u.deployed) || u._removing) return false;
|
||||
const R0 = tile ? tile[0] : u.homeR, C0 = tile ? tile[1] : u.homeC;
|
||||
if (!this.grid.inRect(R0, C0)) {
|
||||
@@ -37,7 +37,9 @@ export class BattleDeploy {
|
||||
u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0;
|
||||
u.blocking = [];
|
||||
u.deploySeq = ++this._deploySeq;
|
||||
u.aggroSeq = u.deploySeq;
|
||||
// A prepared map character is instantiated after the board deployment but keeps an earlier target order.
|
||||
// Its deploySeq remains the identity of this actual deployment for scheduled effects and redeploy checks.
|
||||
u.aggroSeq = preBattleAggroSeq ?? u.deploySeq;
|
||||
u.deployedAt = this.time;
|
||||
u.atkCd = 0;
|
||||
u.lastAttackAt = -Infinity;
|
||||
@@ -46,13 +48,15 @@ export class BattleDeploy {
|
||||
u.markDirty();
|
||||
u.hp = u.s.maxHp;
|
||||
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"):
|
||||
// an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only
|
||||
// an operator restores HP/SP, a summon SP only; actual 联防 opening restoration is deferred until Battle.start ends
|
||||
const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null;
|
||||
if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
|
||||
// 联防 reconstructs deployment/battle-start effects first, then restores HP/SP once in Battle.start.
|
||||
const restoreLater = this.kind === 'unite' && this._startDeploying;
|
||||
if (!restoreLater && cs && u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
|
||||
this._occ[k] = u;
|
||||
this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend)
|
||||
if (!u.skill) this._setupUnit(u);
|
||||
u.skill.reset(cs);
|
||||
u.skill.reset(restoreLater ? null : cs);
|
||||
const sk = u.skill;
|
||||
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
|
||||
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
|
||||
@@ -68,9 +72,9 @@ export class BattleDeploy {
|
||||
if (first) this._ev(['spawn', unitInfo(u)]);
|
||||
this._ev(['deploy', u.id]);
|
||||
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial });
|
||||
// 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is
|
||||
// done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts
|
||||
if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
// Explicit carry outside the 联防 opening follows this one deployment. The opening itself restores at the end
|
||||
// of Battle.start, so deployment/battle-start gifts do not come on top of it; later redeploys keep their gifts.
|
||||
if (!restoreLater && cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ export class BattleLifecycle {
|
||||
}
|
||||
}
|
||||
this.emit('battleStart', {});
|
||||
// 联防 restores the previous phase's state after all opening effects have established the new stats. In
|
||||
// particular, Egir rebuilds devour attributes without charging carried targets for its opening damage again.
|
||||
// Keep fractional HP: a legal survivor below 1% (or below 1 HP after max-HP changes) must not be healed by carry.
|
||||
if (this.kind === 'unite') for (const u of this.allyUnits) {
|
||||
const cs = u.carry;
|
||||
if (!cs || cs.down || !u.alive || !u.deployed || u.deploySeq <= seq0) continue;
|
||||
if (u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
|
||||
if (Number.isFinite(cs.sp) && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
}
|
||||
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
|
||||
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
|
||||
for (const u of this.allyUnits) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ROWS, COLS } from '../constants.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, enemyStealthed } from '../targeting.js';
|
||||
import { bodyKeys, bodyInKeys, bodyInRadius } from '../body.js';
|
||||
import { roundHalfEven } from '../detmath.js';
|
||||
|
||||
export class BattleQueries {
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ export class BattleQueries {
|
||||
for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); }
|
||||
continue;
|
||||
}
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
const r = roundHalfEven(e.y), c = roundHalfEven(e.x); // the tile body.js gives a point enemy
|
||||
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
|
||||
const k = r * COLS + c;
|
||||
const b = this._eb[k];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// and the list compaction.
|
||||
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
||||
|
||||
import { MAX_ALIVE_ENEMIES } from '../constants.js';
|
||||
import { MAX_ALIVE_ENEMIES, FIELD_EDGE_INSET } from '../constants.js';
|
||||
import { Unit } from '../units.js';
|
||||
import { compileRoute } from '../ai.js';
|
||||
import { unitInfo } from '../snapshot.js';
|
||||
@@ -201,11 +201,11 @@ export class BattleSpawns {
|
||||
}
|
||||
|
||||
_clampPos(e) {
|
||||
const R = this.rect;
|
||||
if (e.x < R.c0 - 0.5) e.x = R.c0 - 0.5;
|
||||
if (e.x > R.c1 + 0.5) e.x = R.c1 + 0.5;
|
||||
if (e.y < R.r0 - 0.5) e.y = R.r0 - 0.5;
|
||||
if (e.y > R.r1 + 0.5) e.y = R.r1 + 0.5;
|
||||
const R = this.rect, m = 0.5 - FIELD_EDGE_INSET; // inside the edge: on the edge tile, never halfway (body.js)
|
||||
if (e.x < R.c0 - m) e.x = R.c0 - m;
|
||||
if (e.x > R.c1 + m) e.x = R.c1 + m;
|
||||
if (e.y < R.r0 - m) e.y = R.r0 - m;
|
||||
if (e.y > R.r1 + m) e.y = R.r1 + m;
|
||||
}
|
||||
|
||||
_compactEnemies() {
|
||||
|
||||
@@ -60,7 +60,7 @@ export class BattleSummons {
|
||||
if (opts.stats) for (const [k, v] of Object.entries(opts.stats)) if (Number.isFinite(v)) u.base[k] = v;
|
||||
this._setupUnit(u, opts.kit ?? null);
|
||||
if (opts.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true });
|
||||
if (!this._deploy(u, { initial: false })) {
|
||||
if (!this._deploy(u, { initial: false, preBattleAggroSeq: opts.preBattleAggroSeq })) {
|
||||
// tile still busy (a unit stands there even with `force`): drop the half-built token and its hooks
|
||||
u.removed = true;
|
||||
this.offOwner(u);
|
||||
|
||||
+7
-6
@@ -1,8 +1,9 @@
|
||||
// server/sim/body.js — where a unit can be hit: the ONE helper behind every range test that targets enemies (user
|
||||
// playtest #5 item 10: "大体型的关底boss实际受击判定范围很小…原版是很大的一片区域").
|
||||
//
|
||||
// Regular units are a point: in a grid range when the tile containing their position (round(y), round(x)) is a range
|
||||
// tile, in a radius when their position is (DESIGN §3). Huge units (巨型单位 — enemy data `hitArea`, tools/build-data.mjs
|
||||
// Regular units are a point: in a grid range when the tile containing their position is a range tile — the position
|
||||
// rounded half to even (detmath roundHalfEven; PRTS 作战机制 "受击方坐标经round函数取整(四舍六入五成双)得到其所属格
|
||||
// 坐标") —, in a radius when their position is (DESIGN §3). Huge units (巨型单位 — enemy data `hitArea`, tools/build-data.mjs
|
||||
// HIT_AREAS, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0") are hit anywhere on a rectangle
|
||||
// `w` tiles along the columns × `h` along the rows, centred on their position moved `dx` columns right and `dy` rows
|
||||
// up:
|
||||
@@ -24,7 +25,7 @@
|
||||
// `hitRect`).
|
||||
|
||||
import { ROWS, COLS } from './constants.js';
|
||||
import { hypot } from './detmath.js';
|
||||
import { hypot, roundHalfEven } from './detmath.js';
|
||||
|
||||
/**
|
||||
* Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit.
|
||||
@@ -47,7 +48,7 @@ export function hitRect(u) {
|
||||
|
||||
/** Tile key of a unit's position (-1 off the grid). */
|
||||
function posKey(u) {
|
||||
const r = Math.round(u.y), c = Math.round(u.x);
|
||||
const r = roundHalfEven(u.y), c = roundHalfEven(u.x);
|
||||
return r < 0 || r >= ROWS || c < 0 || c >= COLS ? -1 : r * COLS + c;
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ export function bodyKeys(u) {
|
||||
/** Does the unit occupy tile (r, c)? */
|
||||
export function bodyOnTile(u, r, c) {
|
||||
const R = hitRect(u);
|
||||
if (!R) return Math.round(u.y) === r && Math.round(u.x) === c;
|
||||
if (!R) return roundHalfEven(u.y) === r && roundHalfEven(u.x) === c;
|
||||
return c + 0.5 > R.x0 + 1e-9 && c - 0.5 < R.x1 - 1e-9 && r + 0.5 > R.y0 + 1e-9 && r - 0.5 < R.y1 - 1e-9;
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ export function bodyInRadius(u, x, y, r) {
|
||||
* around it; …): "周围八格"-style square areas centred on a tile.
|
||||
*/
|
||||
export function bodyTileReach(u, r, c) {
|
||||
if (!u.hitArea) return Math.max(Math.abs(Math.round(u.y) - r), Math.abs(Math.round(u.x) - c));
|
||||
if (!u.hitArea) return Math.max(Math.abs(roundHalfEven(u.y) - r), Math.abs(roundHalfEven(u.x) - c));
|
||||
let best = Infinity;
|
||||
for (const k of bodyKeys(u)) {
|
||||
const kr = Math.floor(k / COLS), kc = k % COLS;
|
||||
|
||||
@@ -220,6 +220,15 @@ export const MAX_ALIVE_ENEMIES = 600;
|
||||
/** Heal-over-time / regen events are aggregated and only emitted when they reach this amount. */
|
||||
export const REGEN_EVENT_MIN = 1;
|
||||
|
||||
/**
|
||||
* How far inside the field rectangle an enemy is held (tiles; Battle._clampPos). [ASSUMED] The rectangle's edge lies
|
||||
* exactly halfway between two tiles, where the tile of a position — rounded half to even (body.js; PRTS 作战机制
|
||||
* 四舍六入五成双) — would depend on whether the row or column number is even. Held this little inside, an enemy at the
|
||||
* edge stays on the edge tile. 2^-20 is exact in binary and too small to move a slide of 0.1-tile steps; a 1/1024 inset
|
||||
* did move those steps.
|
||||
*/
|
||||
export const FIELD_EDGE_INSET = 1 / 1048576;
|
||||
|
||||
/** Row offset mapping board rows (9..12) onto boss-field rows (2..5). */
|
||||
export const BOSS_ROW_OFFSET = -7;
|
||||
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
// elite or not) stand on the field get ATK +atk
|
||||
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
|
||||
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
|
||||
// holds band_amedic, every player's field gets the map character of the
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": once selected in this match, its
|
||||
// retained team grant gives every player's field the map character of the
|
||||
// variant whose common_condition holds for that player's elites on the field
|
||||
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
|
||||
// combat start on its stage slot (tokens.spawnMapChar)
|
||||
// combat start on its stage slot (tokens.spawnMapChar). [ASSUMED] that slot's
|
||||
// aggroSeq precedes ordinary initial operators; deploySeq stays the runtime spawn
|
||||
// Prep-only bands (economy / shop / grants / layers at prep end or round start) live in bands/meta.js.
|
||||
|
||||
import {
|
||||
@@ -238,12 +239,17 @@ function installMapChars(battle) {
|
||||
const holders = battle.players.filter((ps) => matchBands(battle, ps.playerId).includes(AMEDIC_BAND));
|
||||
if (!holders.length) return;
|
||||
battle.on('battleStart', () => {
|
||||
const chars = [];
|
||||
for (const ps of holders) {
|
||||
const elites = deployedOps(battle, ps.playerId).filter(isElite).length;
|
||||
for (const id of amedicCharsFor(elites, variants)) {
|
||||
const u = spawnMapChar(battle, ps.playerId, id);
|
||||
if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND);
|
||||
}
|
||||
for (const id of amedicCharsFor(elites, variants)) chars.push({ playerId: ps.playerId, id });
|
||||
}
|
||||
for (const [i, { playerId, id }] of chars.entries()) {
|
||||
// The reserve medic occupies this stage slot in preparation; Touch replaces it at battle start. [ASSUMED]
|
||||
// Replacement preserves the slot's priority before ordinary deployments. Shared-field ordering between
|
||||
// prepared characters follows player order, as for other initial placements (lifecycle.js).
|
||||
const u = spawnMapChar(battle, playerId, id, { preBattleAggroSeq: i - chars.length });
|
||||
if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -457,7 +457,11 @@ function devour(battle, pid, bb, members) {
|
||||
// start, resolves nothing (`knocked`: it is off the field)
|
||||
if (knocked(t)) continue;
|
||||
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
|
||||
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
|
||||
// The maintainer's 2026-10-11 联防 carry decision: reconstruct marks/attributes, but do not apply a second
|
||||
// opening HP loss (or trigger deaths/revives) to an operator restored from the previous field. Battle.start
|
||||
// applies its exact HP ratio and SP after the opening hooks. Fresh units and all other battle kinds still pay.
|
||||
const restored = battle.kind === 'unite' && Number.isFinite(t.carry?.hpPct) && t.carry.hpPct > 0;
|
||||
if (amount > 0 && !restored) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
|
||||
if (!layered.has(t)) {
|
||||
layered.add(t);
|
||||
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
// hidden from later 'kill' handlers) · death(c,…) · status(c,…) (statusApplied on e) · burst(c,…) (elementBurst on e)
|
||||
// · tick(b,e,a,dt) every `iv` s (every tick when iv is 0) · skill: `cd`/`left`(=icd)/`cond`/`fire` (cooldown runs
|
||||
// continuously; fires when ready, the enemy can act and `cond` holds). `sil: true` = the ability's handbook line
|
||||
// is flagged SILENCE (沉默 disables it: exactly the data's `format: "SILENCE"` lines — and 萨卡兹枯朽战士 / 组长's
|
||||
// 死亡爆炸, a NORMAL line whose client template checks 沉默: archetypes.js kitPolluted); stun / freeze / sleep block
|
||||
// is flagged SILENCE (沉默 disables it: exactly the data's `format: "SILENCE"` lines). 萨卡兹枯朽战士 / 组长's
|
||||
// 【污染秽蚀】 death fog is the one NORMAL line also silenced (archetypes.js kitPolluted, `enemy_1267_nhpbr` and
|
||||
// `_2` only): the owner's official-play recall of 2026-10-12 is that a death while silenced creates no fog, and
|
||||
// that recall contradicts the PRTS / in-game text 「不可沉默」 (DESIGN §30.23). stun / freeze / sleep block
|
||||
// skills. Enemy SP skills ("数次攻击后…"): SP +1 per attack, the (spCost+1)-th attack is the skill attack (official
|
||||
// 粉碎攻坚手 text: spCost 2 = "攻击2次后,下一次攻击").
|
||||
// Shared mechanics: reborn() — "首次被击倒后重生 / 第二形态" (the first KO is hidden from kill credit and bounty; a
|
||||
|
||||
@@ -337,10 +337,11 @@ function rebirthCleanse(b, e) {
|
||||
* (no move / attack / skill).
|
||||
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
|
||||
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
|
||||
* `invincibleUntargetable` opts a verified enemy into hostile selection immunity for that same window (Xi, #510).
|
||||
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
|
||||
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
|
||||
*/
|
||||
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
|
||||
function reborn({ dur = 0, hpRatio = 1, invincible = 0, invincibleUntargetable = false, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
|
||||
const finish = (b, e, a) => {
|
||||
if (!e.alive || a.state !== 'reborn') return;
|
||||
a.state = 'form2';
|
||||
@@ -348,7 +349,8 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
|
||||
e.profile.noAttack = a.noAtk;
|
||||
if (onReborn) safe(b, e, () => onReborn(b, e, a));
|
||||
e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio)));
|
||||
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
|
||||
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true,
|
||||
flags: { invulnerable: true, ...(invincibleUntargetable ? { untargetable: true } : {}) } });
|
||||
if (e.route) e.route.pts = null;
|
||||
e.atkCd = 0;
|
||||
rebirthCooldowns(e);
|
||||
@@ -656,13 +658,16 @@ const kitSelfFear = (ab) => [selfFear(ab)];
|
||||
const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })];
|
||||
|
||||
/**
|
||||
* 萨卡兹枯朽战士 / 组长 · death: 【污染秽蚀】 (PRTS 天赋 "{{特殊机制|死亡爆炸}}(释放半径2.0,持续8s的【污染秽蚀】)"). Silenced at its
|
||||
* death it releases nothing: PRTS 特殊机制 死亡爆炸 "如无特殊说明,此能力默认可沉默…若不处于沉默状态,将会…释放", and the
|
||||
* client agrees — its PollutedDie ability is a buff of template `projectile_on_killed` (battle prefab
|
||||
* enemy_1267_nhpbr / _2), whose ON_OWNER_KILLED first checks `CheckAbnormalFlag SILENCED` (unset) before it emits the
|
||||
* pollution projectile — the template of 高能源石虫's SILENCE-flagged blast too. Its handbook line alone is NORMAL, the one
|
||||
* exception to the handbook rule of content/enemies.js (community report of 2026-10-06, item 10: until 0.2.0 a
|
||||
* silenced one still poisoned its blockers).
|
||||
* 萨卡兹枯朽战士 / 组长 (`enemy_1267_nhpbr`, `enemy_1267_nhpbr_2`) · death: 【污染秽蚀】 (PollutedDie; PRTS 天赋
|
||||
* "{{特殊机制|死亡爆炸}}(释放半径2.0,持续8s的【污染秽蚀】)"). These two keys are the only enemies with this ability.
|
||||
* Not this fog: 萨卡兹枯朽战车 / 尖端's 秽蚀轰击 (an attack skill, `_ignoreSilence`), 枯朽之种's self-destruct,
|
||||
* 枯朽萃聚使徒's summons, 萨卡兹枯朽前锋 / 辟路前锋's arts while in a 源石污染区, 假想敌:蚀裂's death cloud, and
|
||||
* 高能源石虫's 死亡爆炸.
|
||||
* A death while silenced creates no fog. The owner's official-play recall of 2026-10-12 (GitHub #470) is that
|
||||
* killing one that already shows the silence icon leaves no 污染秽蚀. That recall contradicts the PRTS / in-game
|
||||
* text 「不可沉默」. Both handbook lines are NORMAL ("被击倒时释放污染秽蚀…"), not SILENCE. 0.2.0 already withheld
|
||||
* the fog, from the general 死亡爆炸 line "如无特殊说明,此能力默认可沉默" and the client template
|
||||
* `projectile_on_killed` (DESIGN §25.18.2). The recorded reason is now the owner's recall, against 「不可沉默」.
|
||||
*/
|
||||
const kitPolluted = (ab) => [{
|
||||
sil: true,
|
||||
|
||||
@@ -473,6 +473,9 @@ function kitXi(ab) {
|
||||
};
|
||||
const re = reborn({
|
||||
dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0,
|
||||
// #510: Xi's post-rebirth 无敌 also prevents hostile selection (PRTS “自在” / 无敌).
|
||||
// Explicit opt-in: the engine's general invulnerable flag remains damage protection only.
|
||||
invincibleUntargetable: true,
|
||||
onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); },
|
||||
onReborn(b, e) {
|
||||
P.form2 = true;
|
||||
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user