Skip to content

TEMP: 合并可行性评估(查完即关) - #3

Closed
rt334 wants to merge 89 commits into
fork/mainfrom
feat/satellite-phase2
Closed

rt334 wants to merge 89 commits into
fork/mainfrom
feat/satellite-phase2

Conversation

@rt334

@rt334 rt334 commented Oct 1, 2026

Copy link
Copy Markdown
Owner

临时 PR,仅用于确认 fork/main 与 feat/satellite-phase2 的合并可行性,随后关闭。

把上游 `test@bd44662`(SiliconMod#64「退回电力保护器」)并入本 PR 分支,使 PR 与 `test` 重新可合。

冲突处理:
- `PowerProtector.java`:采用上游回退版(417 行)。
- `Blocks.java`:保留本 PR 的方块声明(signalJammer / satelliteLauncher / satelliteConsole /
  signalDetector)与 signalDetector 注册,并入上游的 `petroleumRefinery` 字段与「已暂时禁用注册」
  注释块——**炼油厂/润滑油保持上游的未注册状态,本 PR 不删也不启用它们**。
- `bundle.properties` / `bundle_zh_CN.properties`:保留本 PR 早先删除的铥(Thulium)遗留键
  (双方代码里都已无该物品),其余跟随上游。
- `docs/blocks/PowerProtector.md`:跟随上游(本 PR 不再改这份文档)。
现象:放 2 个信号源再拆掉 1 个,卫星控制台的信号列表里被拆掉的那个编码仍在。

原因:`SignalSource.allSources` / `SignalRelay.allRelays` 走的是「Groups.build 快照 + dirty 标记」
缓存,而 Mindustry 的拆除顺序是 `preChanged()`(先 `onRemoved()` → markDirty)→ `changeBuild()`
(才 `Building.remove()` 真正离开 Groups.build)。只要在这两步之间有任何一次列表查询
(中继器每 tick 的实时判定、频谱面板、H 覆盖、控制台面板每帧刷新),重建出来的缓存就会把
尚未离开 Groups.build 的死源一起带回来,同时把 dirty 清掉——此后死源永久留在列表里,
直到下一次增删源才纠正。

修复(两道保险,任何一次查询之后缓存都不含死源):
- `SignalSourceBuild` / `SignalRelayBuild` 新增 `removed` 标记:`onRemoved()` 里置位并立即把
  自己从缓存 Seq 中摘掉,`onProximityAdded()` 里复位;`rebuildCache()` 跳过带标记的建筑,
  拆除窗口内发生重建也不会把死源加回来。
- `allSources()` / `allRelays()` 增加自愈剔除:按 `removed || !isValid()`(即"是否仍是本格建筑
  且未被标记死亡")逐项过滤,并在真的剔除了条目时同步失效 `SignalChannel` 的上行门控缓存
  (否则 `hasLiveSource` 仍会按旧结果放行卫星广播)。每队信号源数量级很小,开销可忽略。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿(12/12、31/31、优先级路由)。
现象(用户实测):拆掉信号源后,卫星控制台的「当前卫星所属信号」仍挂着那个已删除的编码,
中继器面板同样一直显示「转发信号源:XXXX」+ 信号不足。

语义:信号编码是随机生成的四位串,源被拆掉/被打掉后**无法再建出同名源**,保留绑定只会永久
显示一个不存在的信号。因此改成自动清除:

- `SatelliteConsoleBuild.updateTile()`:`selectedSignal` 在本队已无存活信号源
  (`SignalChannel.hasLiveSource` 为 false)→ 置空并 `configure("")` 同步给各端。
- `SignalRelayBuild.updateTile()`:`selectedSource` 同理清除后照常走 `updateActive()`。
- 判定只看「编码是否还存在」,**不是**「是否在范围内」:源仍在但断电 / 超距 / 被干扰时保留绑定,
  由绑定状态行红字提示(避免把暂时性问题当成源被删)。
- 只在权威端判定(`!Vars.net.active() || SatelliteManager.isAuthority()`),客机跟随 tileConfig
  下发,避免网络延迟导致的误清。
- 文档:`docs/blocks/Signal.md` §4 判定表新增「绑定自动失效」一行。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿(12/12、31/31、优先级路由)。
上游 SiliconMod#64 已把 PowerProtector 核心回退到 417 行版本,fork 之前加在这份文档顶部的
「新版架构」警示横幅随之失效;本 PR 主题是卫星/信号,不顺手改它。
`SignalJammer` 注册的方块名是 `signal-jammer`,构造函数里没有任何 `region`/`uiIcon` 覆写,
但仓库里一直没有 `assets/sprites/blocks/signal-jammer.png`(只有 signal-source / signal-relay /
signal-detector 三张)。`Block.region` 默认取 `Core.atlas.find(name)`,找不到就落到 error 贴图,
所以干扰器在建造菜单与地图上都是占位方块。

新增 32×32 的 `signal-jammer.png`:沿用同族配色(主体 (70,74,88) / 深边 (45,48,60) /
亮部 (160,170,190),与 satellite-launcher、signal-detector 一致),图形为「天线 + 信号波 +
红色斜杠」,一眼可辨"干扰"。
① 轨迹边界(`SatelliteManager.scanInset`):正弦轴的内缩由固定 8px 改为**按该轨道的覆盖半径**
   (`clamp(coverageRadius, 0, 0.8·half)`,下限 `SCAN_MARGIN`)——旧值让卫星贴边时大半个覆盖圆跑到图外,
   名义覆盖率打对折。回绕轴保持全幅(卫星本就该从一侧进场、另一侧离场)。
② 回绕淡入淡出(`WRAP_FADE = 0.06` + `SatelliteManager.presence/presenceOf`):`frac(u)` 落在每圈首尾
   6% 时存在度线性升/降,同时作用于 ① 卫星本体的绘制透明度 ② `satelliteRawAt` 的原始强度——
   覆盖显示、SINR 判定、中继激活随之同步升降,消除回绕瞬间的"瞬移"观感,也不会出现"图没了信号还在"。
③ 贴图:新增 `assets/sprites/units/satellite-{leo,meo,geo,sso}.png`(48×48 = 12 世界单位,本体 + 两侧太阳能板,
   四型按轨道区分轮廓与轨道色)。`SatelliteUnits.load()` 依次尝试「内容名(含 mod 前缀)→ 去前缀名」
   两种图集键;绘制时中心补一个队色点(贴图不含队伍信息);两者都缺时仍走原程序化兜底,不会出 error 白方块。
④ 文档:新增 `docs/blocks/Satellite.md`(轨迹公式 / 淡入淡出 / 覆盖与强度 / 发射与名册 / 视觉 / 常量速查);
   README 卫星章节同步为扫描轨迹模型:周期 90/150/70s、GEO 定点整图(不再是 80 格 / 240s)、
   去掉"圆轨道"与 GEO 0.64 比例,补一条"扫描几何"说明。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿(12/12、31/31、优先级路由)。
**问题**:H 数字模式下数字整体相对地格上移——k=1 时 1.6px(≈0.2 格),字号倍率大时 k=2.5 → 4px(半格),
看起来就是"数字对不齐地面格子",连带着整片覆盖也像被挪了半格。

**原因**:`SignalOverlay.drawNumbersOverlay` 的 `Fonts.def.draw(num, wx, wy - 1.6f * k, Align.center)`——
纵向那个 `-1.6f * k` 是切到 `Align.center` **之前**的锚点残留:横向早已改成 `Align.center`(1/2 位数都精确
居中),纵向却还在手工补偿。而 arc 的 `GlyphLayout` 在 valign 分支本来就按 `FontData.capHeight` 做垂直居中
(`GlyphLayout.setText`),`Align.center` 下文字的视觉中心已经等于传入点,**任何手工偏移都会破坏对齐**。

**改动**:`Fonts.def.draw(num, wx, wy, Align.center)` —— 采样点与绘字点都取格子中心 `gx*8+4`
(tile 索引 gx 覆盖世界坐标 `[gx*8, gx*8+8)`),删除已无用的 `k`,并把"对齐由 arc 的 capHeight 居中负责、
不要再叠加像素偏移"写进注释。范围模式本就用 `Fill.rect(wx, wy, 8f, 8f)` 以格子中心为锚,不受影响。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿。
**问题(实测确认)**:`Fonts.def.draw(num, wx, wy, Align.center)` 并没有把数字居中——arc 的
`Font.draw(str, x, y, align)` 只把 align 当 halign 用、纵向锚在**基线**上,于是 (x,y) 实际是"文字左下角",
数字整体落在格子中心的**右上角**(实测:"向方块右上角偏移")。上一次删掉 `-1.6f * k` 只是去掉了额外的
手工误差,锚点本身没解决。

**修复**:用 `GlyphLayout` 量宽度、`Font.getCapHeight()` 当高度,把左下角锚点挪到
`(格子中心 − 尺寸/2)` —— 对齐不再依赖 arc 的 Align 语义;`GlyphLayout` 静态复用,无逐格分配。

**新增调试方块 `signal-probe`(信号探针,仅沙盒可见)**:
- `SignalSource.radius` 由写死的常量改为**每方块字段**(默认 15 格),覆盖判定/范围绘制/H 覆盖包围盒
  统一读它;探针取 `0.5` 格 ⇒ 只有它自己那一格的中心在覆盖内、四邻 1 格均为 0。
- 按住 H 后全图只会出现这一格的「99」,方块贴图自带**十字准星**,数字中心与格心是否重合一眼可判。
- 探针覆写父类"必须有电才发射"的闸门(`consPower = null`),沙盒里放下即用(仍需设一个 4 位编码)。
- 双语 bundle 补 name/description;贴图 `assets/sprites/blocks/signal-probe.png`(32×32,调试绿 + 准星)。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿(12/12、31/31、优先级路由)。
**电力(真因)**:父类构造里的 `consumePower(150/60)` 只是把 `ConsumePower` 加进 `consumeBuilder`,
而 `Block.init()` 阶段会从 builder 重建 `consPower` 并置 `hasPower = true`——所以只在构造期写
`consPower = null` 会被覆盖回去:方块信息面板照样显示耗电、没接电时照样画断电图标,看起来就是
"探针需要电"。现在改用 `removeConsumers(c -> true)` 直接清空 builder,`init()` 不再生成任何消费者,
`hasPower/consumesPower` 保持 false;`enableDrawStatus = false` 再兜一层(不画任何状态图标)。
发射判定(`strengthAt`/`emitting`)继续覆写掉父类的 `hasPower()` 闸门,放下即用、不接电网。

**真实覆盖可视化**:`drawSelect` / `drawPlace` 不再画父类的 15 格圆(对 0.5 格半径纯属误导),
改画本格 8×8 边框 + 格心十字——正好当"数字是否落在格心"的参照物。

**常驻自评读数**:方块上方一行 `code=XXXX ch1 r0.5 ON raw99`(编码/信道/半径/开关/本格原始强度),
不依赖 H 覆盖就能判断探针自身状态、定位"为什么不发射";文字宽度用 `GlyphLayout` 量、左锚点居中
(与 H 数字同一套锚点算法),绘制前后保存并恢复 z / 字号 / 字色,不留全局副作用。

**描述**:双语 bundle 更正——编码由放置时自动生成(父类 `placed()` 逻辑),无需手动设置。
探针读数扩展为 `code ch r ON raw cell off`:
- `cell` = 用 H 覆盖的同一函数 `SignalChannel.usableAll` 在本格中心算出的可用强度(应等于 raw);
- `off` = 本格中心与最近覆盖层采样点(世界坐标 ≡ 4 (mod 8),即覆盖层逐格采样的位置)的差(单位:格)。
  若非 0,说明采样网格与建筑中心错位——半径 0.5 格的探针会因此整格采不到数字。
另在最近的采样点画一个红圈,便于肉眼比对(贴图中心的绿点/准星就是本格中心)。
**根因(实测定位)**:调试探针读数给出 `off-0.50,-0.50` —— 覆盖层逐格采样的世界坐标是 `gx*8 + 4`,
而引擎里地格的坐标就是**索引 × 8**(1×1 建筑中心在 8 的整数倍上;官方 `PowerNode.drawPlace` 也是
`x * 8f + offset`,不加 4)。两者整整差半格,后果分两级:

1. 普通信号源:数字(与范围色块)整体相对方块/地格偏移半格——就是「数字向方块右上角偏移、
   显示范围与实际范围不一致」那个老问题;
2. 半径 0.5 格的调试探针:四个采样点到探针中心的距离都是 √2/2 ≈ 0.707 格 > 0.5,**每个采样点都在
   覆盖外** → 整格采不到值,表现为「探针不发射信号」(其实 `raw99`、`cell99` 都正常)。

**改动**:
- `SignalOverlay` 数字模式与范围模式的采样点统一改为 `gx * 8f` / `gy * 8f`(= 建筑所在网格),
  使「采样点 = 建筑中心 = 填色格中心」三者一致;
- `SignalSource` / `SignalRelay` 的放置预览圆按官方写法改为 `x * 8f + offset`;
- 探针诊断的 `off` 基准同步改到新网格(预期恒为 0.00,0.00),红圈应正好套在方块中心绿点上。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿(12/12、31/31、优先级路由)。
探针截图逐像素量:红圈(采样点)中心 (899.5, 279),数字 `99` 的 bbox 上边缘 300、中心 319 ——
数字整个沉在格心**下方**约 3.8 世界单位(≈0.47 格),而它的上边缘恰好落在我传入的锚点上。

结论:`Font.draw(str, x, y, Align.left)` 的锚点是文字的**上边缘**(GlyphLayout/FontCache 内部再按
ascent 折算基线)。上一版按"基线"理解、把 y 减了半个字高,等于把整行数字又往下压了半个字。

改动:锚点改为 `(采样点.x − 宽/2, 采样点.y + capHeight/2)` —— 上边缘抬到格心上方半个字高,
数字的**视觉中心**才落在格心上;水平方向本来就是左边缘 + 宽/2,实测已居中(偏差 <0.5px/10),不动。

验证:`gradlew clean jar` 通过。
对齐问题(采样点半格错位 + 数字垂直锚点)已定位并修好,探针完成使命,整体移除:

- 删除 `SignalProbe.java`、`assets/sprites/blocks/signal-probe.png`,`Blocks.java` 去掉注册/字段/import,
  双语 bundle 删除 `block.silicon-signal-probe.name/description`;
- 还原 `SignalSource` 的「每方块半径」改造(`radius` 字段、`radius()` 访问器、带半径的 `strengthAt` 重载)
  ——它当初只为 0.5 格的探针而加,现在恢复常量 `RADIUS`;
- `SignalOverlay.computeCoverBounds` 同步回到统一半径。

**保留**(与探针无关的真修复):H 覆盖采样点由 `gx*8+4` 改为 `gx*8`(建筑网格,修正半格错位)、
数字垂直锚点改为「上边缘 + 半字高」、`SignalSource`/`SignalRelay` 放置预览圆改为官方写法 `x * 8f + offset`。

注:探针是最后注册的方块,删除它不影响其余方块 ID;已有存档里的探针按名字映射失效,会变成空格子(不崩)。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿;jar 内无 SignalProbe/signal-probe 残留。
这 9 个文件是用 GitHub API 直接推的、上传的是本地工作区内容,而本仓库工作区 `core.autocrlf=true`
会把检出内容变成 CRLF —— 于是远端存成了 CRLF,而上游各文件都是 LF。后果是 PR 里这些文件显示为
**整文件重写**(例如 SignalSource.java 的 diff 变成 +321/-293),评审时噪音极大。

本提交只做一件事:把这两个分支上这 9 个文件的 CRLF 全部转回 LF,**不改任何代码/文字内容**
(README 在两分支本就不同,各自按自己的内容转换)。以后所有 API 推送都会先做 LF 归一化。
上游 SiliconMod#64 把石油炼化厂以「注册注释掉」的形式放进了仓库,本分支此前整套保留着,而 fork/main 已删除。
本次让两边一致,删除:

- `src/silicon/world/blocks/production/PetroleumRefinery.java`
- `src/silicon/world/Liquids.java`
- `assets/sprites/blocks/petroleum-refinery.png`、`assets/sprites/liquids/lubricant-oil.png`

以及全部引用:`Blocks.java` 的 import/字段/注释注册、`Silicon.java` 的 `silicon.world.Liquids.load()`、
双语 bundle 的 `block.silicon-petroleum-refinery.name/description`。

说明:这等于把上游 SiliconMod#64 里这套**未启用**的内容从本 PR 移除(上游若日后正式启用,同步时会再引入);
fork 的发布/自动化基础设施(`.github/workflows/fork-publish.yml`、`scripts/*`、`docs/WORKFLOW.md`)
只存在于 fork/main,**不进本 PR**。

验证:`gradlew clean jar` 通过;`tests/` 三套件全绿;jar 内无 PetroleumRefinery / world/Liquids / lubricant-oil 条目。
- `build.gradle`:`mindustryVersion` 由 `v160.1` 提升至 `v160.5`(Steam 客户端现为 `steam build 160.5`);
- README:顶部兼容版本(`v160.5+`)、构建依赖说明同步为 v160.5,更新日志补一条适配记录;
- 结论:同一份源码下 v160.5 与 v160.1 依赖的**构建产物逐字节相同**——160.2~160.5 未触及本模组用到的 API
  (发布内容均为缺陷修复与 UI 调整:target dummy、逻辑处理器、数据补丁、连锁闪电等),无破坏性变更;
- `minGameVersion` 维持 `155.4`,向后兼容不变;
- 验证:本地 `gradlew clean jar`(JDK 17 + v160.5 依赖)通过;`tests/` 三套件全绿;该适配此前在
  `chore/adapt-v160.5` 分支上的 CI(Build Mod @ a028eb9)亦为 success。

本 PR 只取这一个适配提交的改动(改动 2 个文件);fork 侧的发布/自动化基础设施仍然只存在于 fork/main,不进本 PR。
该文件此前在 PR 分支被「交还上游」(不带警告),而 fork/main 上有 8 行过期警告 —— 两边不一致。
现按「PR 与 main 内容保持一致」的约定统一:以 main 版为准(警告内容是事实——文档描述的是 v14 时代
架构、与 v19 重构后的实现严重脱节,upstream 侧同样成立),两分支此文件完全相同。
自查 + 独立复核发现的问题,按严重度修复。

[阻塞] SatelliteConsole 名册读写不对称 → 存档错位
写侧 write.i(list.size) 无上限、读侧 Math.min(..., 64) 截断:名册 >64 条时读侧少消费
(n-64)×(16+码长) 字节。反汇编确认引擎 SaveFileReader.readChunk 是「读长度 → 交给 runner →
直接 return len」,**不做**任何按声明长度的对齐,因此后续每个 tile 的 chunk 前缀都会被当数据读,
整档解析错位。现读写共用 ROSTER_MAX=64,且读侧把写入的 n 条**全部消费**(超限只解析不恢复),
流位置与写侧严格一致,已有 >64 条的存档也能正确读出。

[高] SatelliteUnits.drawFallback 的 tc.a(alpha) 直接改写共享 Team.color 的 alpha(上一行有 cpy、这行漏了)
→ 卫星回绕淡入段把该队全局队色改淡且不恢复。改 tc.cpy().a(alpha)。
[高] SignalChannel.usableAll 的「按编码」分支缺卫星上行门控(自动分支/判定端/频谱占用计数都有)
→ 地面源被拆光后 H 覆盖与频谱仍显示该编码卫星强度,与判定端打架。补 hasLiveSource 门控。
[高] SignalRelay.updateActive 判定用 max(地面,卫星),显示端用非相干合成 √(g²+s²),两者恒不相等
→ 阈值 3.3 附近出现"H 显示 3.4 却不转发"。统一到显示端的合成值(顺带让面板「卫星」列在地面有信号时
不再恒为 0)。
[高] SignalRelayBuild 缺 changeTeam → 换队/夺取后中继缓存不失效(旧队仍当发射机、新队漏掉它)
→ 补 changeTeam + markDirty(与 SignalSource/SignalJammer 同款)。
[高] SignalJammer 全局缓存无 removed 过滤、无自愈剔除 → 借「onRemoved 早于离开 Groups.build」的窗口,
死干扰器会被固化进缓存并持续压制该信道。加 removed 标记 + 重建过滤 + allJammers 自愈剔除。

[中] SignalJammer 的 jamChannel 允许 0(下界错写为 -1):会给信道 1/2 泄漏 40%/12% 干扰却在占用列隐身
→ configure/read 统一 (== ALL) ? ALL : clamp(1..5)。
[中] SignalSource.read / SignalRelay.read 不夹取 channel、不校验绑定编码 → clamp 到 [1,5]、编码走 Signal.isValidCode。
[中] SatelliteManager.onWorldLoaded 剪除只判 getByID==null:id 被复用给别的单位时留幽灵记录 → 追加控制器类型判定。
[中] SatelliteManager.restoreRecord 对 orbit/channel/phase/code 零夹取 → 统一夹取(phase 规整到 [0,1) 并过滤 NaN/Inf)。
[中] SatelliteManager.onWorldLoaded 的补建分支缺权威端守卫 → 加 isAuthority(),客机名册完全交给 sat-state。
[中] Silicon 的 sat-launch 处理:空编码没有回退到控制台自身的 selectedSignal → 补回退(cb 就在手里)。
[中] SatelliteConsole UI_REFRESH 8 → 30 tick(原值每次刷新都要跑「遍历全部建筑 × 逐建筑信源扫描」)。
[中] SignalSource.strengthAt 每次调用重算常量 ln(1+R/σ) → 提为 static final LOG_SCALE。

[低] 注释/文档同步:SignalSource 与 SignalJammer 类注释仍是「线性/高斯衰减」、SignalSpectrum 注释写
「15 tick 一轮」(实际 5)、SignalRelay 类注释「约 12.5 格」(按公式解为约 12.8)、mod.hjson 简介
「15 格正态衰减」;README 三处(卫星"沿地图中心圆轨道飞行"→扫描轨迹、"40/60/80/25"→40/60/25 + GEO 整图、
"每格只画最强一路"→√(g²+s²) 合成);docs/blocks/Signal.md「约 14.0 格」→ 约 12.8 格。

[清理] 删除本 PR 带回但无任何代码引用的死贴图 assets/sprites/effects/satellite-buff.png(该状态效果已移除)。

验证:gradlew clean jar(JDK 17 + v160.5 依赖)通过;tests/ 三套件全绿;jar 内无已删内容残留。
- 身份串缓存:`Signal.id`("S"+名称,构造时算一次)、`SignalRelayBuild.cacheId()`(绑定时缓存 "S"+编码,
  未绑定用坐标派生 id 并缓存)——H 覆盖每帧数万格、每格都要取身份串做比较,原先每格现拼字符串
  (几十万次临时分配/帧,与 SignalOverlay 里"预计算字符串避免每帧分配"的注释自相矛盾)。
  `SignalChannel.effectiveAll` 与 `SignalOverlay.relayColor` 同步改用缓存。
- `SignalSpectrum` 的占用计数从「信道循环内 5×(全表源 + 全表中继 + 全表干扰器)」改为循环外一次算完
  (新增 srcCnt/jamCnt 缓冲);`signalChannel()` 对每台中继的求值从 5 次降为 1 次。
- `sat-launch` 的限流分支回独立结果码 `busy`(原先与真实失败共用 `fail`,合法双击的第二次会弹
  "发射失败",把"点太快"误导成"配置有问题"),客机侧补对应提示;双语新增
  `block.silicon-satellite-console.busy`。
- 在频谱面板注明两列口径差异(有意):"占用"只数直接打在该信道的干扰器,而"干扰"列含邻信道泄漏
  与全信道干扰器,因此一台只打信道 2 的干扰器会在信道 1 的干扰列显示非 0、占用列显示 0。

验证:gradlew clean jar(JDK 17 + v160.5)通过;tests/ 三套件全绿;jar 内双语含新键。
与该 PR 里 SatelliteConsole 的 v2 机制对齐:
- 覆写 `version()` 返回 1,并在 `read` 里显式写 `revision <= 1` 分支——历史存档 revision 恒为 0(当初未声明版本),
  字段集与 v1 相同;**今后增删字段必须新增 revision 分支**,否则多读/少读的字节会让后续 tile 的 chunk
  前缀被当数据读(引擎 readChunk 不做按长度对齐,与 SatelliteConsole 名册那条同源)。
- `selectedType` 读入时夹到 [TYPE_SIGNAL, TYPE_TEST],畸形档不会把越界档位带进 UI。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
- `UpdateChecker`:内容校验失败(截断/空响应;第三方反代典型故障是"200 + HTML 错误页")原先直接判死,
  与"CDN 优先、直连兜底"的声明矛盾 → 改为与网络错误一样继续下一个源(最后一源仍失败才收尾)。
- `UpdateChecker`:`check(force)` 重置 `downloading` → 下载途中点手动检查会让按钮回到初始态、
  再点一次即起第二个并发下载(同一 `.part`/`moveTo`/`delete` 竞争,可能留下截断 jar 且两边都报成功)
  → 不再外部重置,各结束路径自己复位。
- `GeneratorPump`:自举守卫只看 `getLastPowerProduced()`——满负荷(有电池且唯一产者产量被吃满)时它同样为 0,
  会被误判成冷启动、绕过欠压停转从电池倒抽电 → 补 `getLastPowerStored()` 判断。
- `RollGenerator` / `ItemTransferHub`:两处注释与实现不符("随 sync 快照分发"实为随存档读写;
  "客户端挂起项由服务端回包消费"实为本地有意提前消费)→ 改正,并补上"断电不等于停 updateTile"的依据。

复核后判为不成立:「客户端提前删挂起项导致主机有线、成员无线」——客户端本就被禁止 configure,
重试对它只是空转,本地状态由 tileConfig 广播更新,与挂起项无关。

验证:gradlew clean jar(JDK 17 + v160.5)通过;tests/ 三套件全绿。
- 对话框顶部新增两个页签,共用同一个内容容器,切换时只重建内容;页签选择保存在方块实例上,重开窗口保持上次所在页。
- 新页面「在轨管理」:概览(在轨总数 / 待发射 / 按轨道分组计数)+ 每颗卫星一行——编码(未绑定显示"未绑定")/ 轨道 /
  固化信道 / 状态 / 进度 / 「定位」按钮。状态列把三件事合成一句给人看:① 上行门控(编码是否还有存活地面源 →
  "静默(无地面源)");② 实体是否还在(名册有记录但单位丢失 → "实体已丢失");③ 此刻是否覆盖本控制台
  (satelliteEffAt > 0 → "覆盖本台(强度 N)",否则 "在轨(未覆盖本台)")。进度列对 GEO 显示"定点"(其相位是方位角,不是扫描进度)。
- 「定位」把相机移到该卫星当前星下点,只动视图,不改任何模拟状态。
- 刷新策略:行内文本走 label(Prov) 每帧求值,只有**行数变化**(新发射 / 被击落 / 读档换图)才重建整页;行数检查 15 tick 节流。
- 双语补齐 16 个键;类注释同步。

验证:gradlew clean jar(JDK 17 + v160.5)通过;tests/ 三套件全绿。
类型(信号卫星 / 测试卫星)此前不是名册字段,只是发射中枢的属性,发射完就丢了;`launchedCount(team, type)`
只能用"轨道==SSO"近似,而类型与轨道**正交**(测试卫星能发任意轨道),所以那个近似两头都会算错。

- `SatelliteRecord` 新增 `type`(默认 TYPE_SIGNAL),`launch()` 用中枢的所选种类赋值;
- `SatelliteConsole` 存档块 v2 → **v3**:每条记录在 `code` 与 `phase` **之间**追加 `type`,读侧按
  `revision >= 3` 读取(旧档无此字段,按信号卫星处理)。读写两侧顺序严格一致——这正是上一轮刚修过的
  "读少一条就整档错位"那类问题,这次专门按同一顺序逐行核对;
- `SatelliteManager` 广播串的名册条目由 `unitId:code:channel:orbit:phase` 扩为
  `unitId:code:channel:orbit:type:phase`,`applyState` 字段校验同步改为 6(客机镜像才能显示类型);
- `launchedCount(team, type)` 改为按真实 `type` 计数,去掉"轨道==SSO"近似;
- 在轨管理页新增「类型」列(复用既有 `type.short.signal` / `type.short.test` 键),表头与列宽同步;
- 双语新增 `block.silicon-satellite-console.roster.type`。

⚠️ 存档兼容是单向的:v3 存档在**旧版 mod(第一阶段 jar)**里读会少读一个 int 导致 chunk 错位。

验证:gradlew clean jar 通过;tests/ 三套件全绿;读写字段顺序逐行核对一致。
- 删除「进度」列与「定位」按钮(及 `focusOn`、`roster.progress/geo/focus` 键);
- 状态列改为**血量**:直接读实体 `health/maxHealth`,label 每帧求值 → 掉血立刻可见;
  实体不在名册里显示"实体已丢失",编码地面源全没了则加"(静默)"后缀(卫星仍在轨但不广播);
- **修错位**:arc 的 Table 默认把子表居中,表头与数据行总宽不同就会整列错位——上一版数据行末尾多一个
  按钮(80px),于是表头整体右移、**"信道"那一列下面是空的**(这正是"信道没有显示"的成因)。
  现在表头与数据行都 `.left()` 显式左对齐,列宽逐列一致(64/72/48/40/150),不再依赖总宽相同。
- 双语:新增 `roster.health`,删除 `roster.state/progress/geo/focus/state.here/state.elsewhere`,
  `roster.state.muted` 改为短后缀 `(静默)`。

验证:gradlew clean jar 通过;tests/ 三套件全绿;已核对删掉的键在代码里零引用。
概览从两行减为一行:`在轨 N 颗 · 待发射 M 颗`。
删除 `rebuildRoster` 里的按轨道计数 label 与对应键 `block.silicon-satellite-console.roster.byOrbit`(双语)。

验证:gradlew clean jar 通过;tests/ 三套件全绿;代码与双语均无 byOrbit 残留。
- 把「表格整体左对齐」与「列内对齐」两件事分开:外层 cell 仍用 `.left()`(表头与数据行因此严格对齐,
  总宽不同也不会错位),列内去掉 `.left()` 回到 arc 默认居中;
- 每列统一 `pad(4f)`(左右各 4px)→ 相邻列空隙一致;表头与数据行逐列同宽同空隙(64/72/48/40/150)。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
第一阶段把卫星定成 `hittable/targetable = false`,并留下了「只能被 scripted 伤害击落(ASAT 拦截塔)」
的入口与 400 血。本提交补上那个入口:

- 新方块 `AsatInterceptor`(2×2,Category.defense):射程 **40 格**、**40 伤害/秒**、10 tick 索敌节流,
  自动锁定射程内**最近的敌方**卫星并持续照射(己方与 derelict 不受影响);
- 伤害走 `unit.damage()`(scripted),且只在权威端施加——客机不重复计算;血量随单位快照同步,
  客机无需任何额外协议即可看到掉血、光束与击落;断电或被逻辑门关闭即停火;
- 击落后的收尾复用既有链路:引擎销毁单位 → `UnitDestroyEvent` → `SatelliteManager.onUnitDestroyed`
  名册除名 + 同队广播;控制台在轨列表逐帧读实体血量,那一行自然消失;
- 光束画在方块自身 `draw()` 里(与 `SatelliteLauncher` 的发射动画同一手法:绕开 Effect 管线),
  抬 z 到方块层之上后立即复位;
- 新增 64×64 贴图(对齐 `signal-detector` 的深灰 + 白线语汇)、双语键 3 个、
  `docs/blocks/AsatInterceptor.md`,并把 README 里那条「预留」改写成已接入(历史更新日志条目保持原样);
- 新方块**追加在注册末尾**,不动任何已有方块的内容 ID。

验证:gradlew clean jar(JDK 17 + v160.5)通过;tests/ 三套件全绿;打包核对通过(贴图、双语键、类文件均在包内)。
按反馈把「持续照射」改成炮塔形态:

- `AsatInterceptor` 改为 `extends Turret`:得到炮管与转向、装填条(`reload = 60` → 1 秒/发)、
  射界(`shootCone = 8°`)、开火前的炮口对准、悬停面板的炮塔统计、逻辑处理器控制;
- 但仍**不开子弹**:卫星 `hittable = false`,引擎所有常规伤害路径对它们失明(子弹直接穿透)。
  命中结算继续走 `unit.damage()`,每发 `damagePerShot = 40`(40 × 1/s = 40 伤害/秒,400 血仍 10 秒击落);
- 覆写三处,原因都写在代码注释里:
  `findTarget()`(引擎索敌的候选集不含卫星)、
  `validateTarget()`(基类默认走 `Units.invalidateTarget`,对卫星一律判"失效"——不覆写会把上一步刚找到的目标立刻清掉)、
  `shoot(BulletType)`(不发子弹,直接结算伤害并播命中特效);
- 贴图拆成两张:`asat-interceptor-base`(固定底座)+ `asat-interceptor`(随 rotation 旋转的本体,
  炮管朝右,符合炮塔贴图约定);
- 文档按炮塔口径改写:第二节改为"为什么是炮塔却不开子弹",参数表补上 reload / 每发伤害 / 射界 / 转向。

验证:gradlew clean jar(JDK 17 + v160.5)通过;tests/ 三套件全绿;打包核对含两张贴图。
按反馈处理两点:攻击要消耗电力;以及"地面随便一座塔就能 10 秒打下一颗昂贵卫星"的成本不对等。

**新增:电力即弹药**
- `powerPerShot = 8000`:每发从电网**电池**扣(`power.graph.useBatteries`),击落一颗 400 血卫星 = 10 发 = **8 万电力**;
- 覆写 `updateShooting()`:电量不足时**不推进装填、也不空放**,炮塔只是待机等电池攒够
  —— 这样"没电力基建就打不动卫星"是真的,而不是"打出去但没效果";
- 常驻耗电 300 → **600/秒**(待机也在烧电)。

**缩射程 40 → 24 格**:一颗卫星扫过全图,一座塔只能守一小片空域;要保护整片基地区域必须复制多座,
材料与电力随之线性上升。这是本次最有效的平衡杠杆。

**提高门槛**:成本 250/180/200/80/120 → 铜 500 · 铅 350 · 硅 450 · 钛 250 · 钍 150 · **塑钢 120 · 巨浪合金 80**,
血量 900 → 1200、护甲 4 → 6,与卫星发射中枢同级别的材料门槛。

**加预热**:`minWarmup = 0.75` + `shootCone = 8°` —— 开火前炮口必须基本对准,掠过的卫星不会一进射程就挨打。

对照卫星一侧的投入(中枢 5000 铜/5000 硅/1250 塑钢/1250 巨浪 + 生产 5000 电力/秒 × 60 秒 ≈ 30 万电力 +
燃油 + 10000 缓冲),击落一颗的代价约在对方发射成本的 1/4 ~ 1/3,属"值得做但不便宜"的区间;
文档新增第 8 节写明推理过程与三个可调旋钮(powerPerShot / range / damagePerShot)。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语描述同步。
按反馈重做拦截链路:塔打得远,但"看不见"——目标必须由定位器提供。

**新增:卫星定位器(2×2)**
- 探测 80 格内的**敌方**在轨卫星,把位置情报挂到自己的**信号编码**上(放置时自动分配唯一编码,同信号源);
- 耗电 400/秒;断电/被关闭/被拆/换队 → 立即撤稿,情报 **3 秒**(180 tick)自动过期;
- 探测有可视化:范围环 + 指向已定位目标的连线。

**新增:SatelliteIntel(定位情报层)**
- 以「队伍 id + 编码」为键保存敌星快照 + 刷新时间,定位器每 10 tick 覆盖写入,塔读取时校验时效;
- 换图/读档由 `SatelliteManager.reset()` 整体清空。

**拦截塔改造**
- 射程 24 → **48 格**(射程不再是平衡杠杆——"看不看得见"由定位器决定);
- 伤害 40 → **200/发**,`reload` 60 → **180**(3 秒/发,两发 6 秒击落);
- 每发电力 8000 → **40000**(两发 8 万,维持与之前同量级的总成本);
- `configurable = true`:配置面板列出**本队正在工作的定位器编码**,点选绑定 —— 这就是"塔与定位器之间的信号通讯"入口;
- **锁定机制**:持续瞄准 `lockTimeMin..lockTimeMax`(1~6 秒)后才开火,锁定用时随
  `SignalChannel.usableAll(team, x, y, …, scopeCode = 本塔编码)` 给出的**信号可用度**插值
  —— 与 H 覆盖/频谱面板同一实现,不存在两套算法;
- 四道门(情报 / 射程 / 锁定 / 电力)任一不满足就整塔待机:不攒装填、不空放。

**README / 文档**:方块表新增两行;`docs/blocks/SatelliteLocator.md` 新增;
`docs/blocks/AsatInterceptor.md` 重写(信息依赖、四道门、可用度→锁定、平衡表与可调旋钮)。

验证:gradlew clean jar 通过;tests/ 三套件全绿;打包核对(3 张贴图、双语键、6 个类文件)。
rt334 added 28 commits October 1, 2026 15:25
诊断日志(596 次武器更新)给出了决定性证据:

```
[update] unit=916 ammo=5.0 auto=true sats=true canShoot=true mountTarget=null reload=0.0 mountShoot=false
```

`update` 被正常调用、`canShoot=true`,但 **`mountTarget` 恒为 null**,且 `[findTarget]` **0 行** ——
我覆写的索敌方法**从未被调用过一次**。

引擎源码(Weapon.java):

```java
L59: public boolean controllable = true;     // 默认 true
L64: /** ... only works when controllable = false. */
L65: public boolean autoTarget = false;      // 默认 false
L318: if(!controllable && autoTarget){ mount.target = findTarget(...); }
```

`ionWeapon()` 里这两个字段都没设,于是 `!controllable` 与 `autoTarget` **同时为 false**,
索敌分支永不进入。而卫星用的是自定义 `OrbitSatelliteController`(不是 `AIController`),
`AIController.updateWeapons()` 这条备选路径也不会跑 —— 结果是**没有任何代码负责索敌**。

修复:在武器定义里显式设置

```java
controllable = false;
autoTarget = true;
```

**这也解释了此前所有无效的修复**:hasAmmo(那是拦截塔的问题)、checkTarget 语义、沙盒放宽、
射程、弹药开关 —— 它们全部位于 `findTarget` 内部或其下游,而 `findTarget` 根本没被调用。

同时核对了开火链上其余各门,确认对零速卫星不构成阻碍:
`minWarmup = 0f`、`minShootVelocity = -1f`(恒满足)、`shootCone = 360f`、`reload` 可归零。

顺带把诊断改为默认开启并强制 flush(此前默认关且未勾选,导致连续多轮拿不到运行时数据),
新增 `[worldLoaded]` 探针(不受开关控制)。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
按"只删除测试卫星、轨道不要动"执行:

**删除**
- 类型常量 `TYPE_TEST`(编号 1 留空,`TYPE_ION` 保持 2,不改动存档里的类型编号)
- 生产材料 `TEST_PRODUCTION_ITEMS`(1 硅)、耗时 `PRODUCE_TIME_TEST`(1 秒)
- `productionItems` / `productionCryofluid` / `produceTime` 中的测试卫星分支
- 发射中枢面板的测试卫星按钮;控制台类型按钮循环改为遍历有效类型数组
- 生产流程与发射流程中的测试卫星沙盒拦截、`LAUNCH_TEST_SANDBOX` 原因码
- 广播状态中的测试卫星计数字段(协议由 7 字段调整为 6 字段,读写两端同步)
- 在轨列表中测试卫星的 LOIC 操作栏(操作栏现在只属于离子炮卫星)
- 双语 bundle:`type.test`、`type.short.test`、`launcher.type.test`、`sandboxOnly`,
  以及中枢/控制台描述里的测试卫星段落
- 新增 `isValidType()`:读档时旧存档残留的编号 1 一律归为信号卫星

**保持不动(按要求)**
- SSO 轨道:`ORBIT_SSO` / `ORBIT_COUNT` 原样
- `orbitAllowed()` 原样——信号卫星仍不能发射到 SSO
- `testSso` 机型原样保留(SSO 轨道的载体,不改名、不删除)
- 沙盒自测放宽判据 `testSatelliteAvailable()` 保留(拦截塔/定位器/LOIC 仍用它放行己方目标),仅更新注释
- `satellite-sso.png` 贴图保留

**顺带修正**
- SSO 机型显示名由"测试卫星·SSO"改为"SSO 卫星"(该机型已不对应任何可生产类型)
- 清理多处过时注释(提到测试卫星可发任意轨道等)

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 365/365 对称、无拼接行。
新增 `SatelliteUnits.targetSatellite`(内容名 `satellite-target`),用于验证攻击链路:

- 走与普通卫星相同的 `OrbitSatelliteController`,因此天然满足攻击侧的全部索敌条件:
  LOIC 的 `isLowOrbitSatellite()` 只认控制器类型与轨道,本机型取 **LEO**,可被索敌;
  卫星定位器按控制器类型全图探测,所以它同样会被探测、被拦截塔锁定;
- 血量 30000(`TARGET_HEALTH`):LOIC 单发 10000,即 **3 发击落**——
  普通卫星 400 血是一发就没,看不出弹夹消耗与连续命中,靶子需要能挨几发;
- 视觉上刻意不用任何轨道特征色:灰白平台 + 红色同心靶心,避免被误认成信号卫星;
- **不需要任何生成逻辑**:直接放置/刷出即可。缺名册的轨道卫星由
  `SatelliteManager.onWorldLoaded()` 的补建路径自动登记(控制器每 60 帧兜底触发),
  登记后即按 LEO 轨迹运动;
- 刻意不设 `hidden`:沙盒单位面板与地图编辑器都用 `!isHidden()` 过滤,
  隐藏了就再也刷不出来。

攻击方式不受 `hittable/targetable = false` 影响:LOIC 与拦截塔都用 scripted 伤害
(`u.damage()`),不经过引擎的命中判定与索敌过滤。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 367/367 对称;贴图已入 jar。
- `ION_DAMAGE` 与 `splashDamage` 同步改为 2000(溅射半径 10 格不变)
- 持续输出由 ≈333 伤害/秒 回到 **≈67 伤害/秒**(1 发 / 30 秒 × 2000)
- 爆发由 5 秒内 50000 伤害 改为 **5 秒内 10000 伤害**
- 靶标卫星 1000 血,仍为一发击落

同步更新:代码注释、双语 LOIC 描述、`docs/blocks/LOIC.md`(武器参数表 / 平衡表 /
常量速查 / 开头概述 / 数值沿革)、`README.md`。

注意与"发射缓冲电力 10000"(`SatelliteLauncher.LAUNCH_POWER`)区分,后者未改动。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 367/367 对称。
四处实测问题,逐个定位到代码:

**1. LOIC 乱开火(轰炸己方建筑)**
`LoicWeapon.findTarget` 的建筑分支使用了沙盒放宽标志 `selfOk`。该放宽的本意是
"沙盒里没有第二个队,允许打己方卫星以便自测",但它作用到建筑分支后,
沙盒模式下 LOIC 会把**自己的建筑**当目标。
现在建筑分支**永远只打敌方**(findTarget 与 checkTarget 对称修改);
卫星分支保留放宽——靶标卫星可以直接刷成敌方队伍,建筑不需要这种便利。

**2. 拦截塔射程只有 10 格**
`range = 80f` 的单位是**世界像素**(Mindustry 1 格 = 8 像素),实际只有 10 格,
卫星从头顶掠过时几乎来不及锁定。文档一直写的是"80 格",代码与文档不符。
改为 `range = 80f * 8f`(640px = 80 格)。

**3. 锁定时间过长**
`lockTimeMin = 60f / lockTimeMax = 360f` 即 1~6 秒,改为 **30f / 120f**(0.5~2 秒)。

**4. 电量不能正常检测**
`canAffordShot()` 只认 `power.graph.getBatteryStored() >= powerPerShot`,
而**纯发电网(没有电池)**的存量恒为 0 —— 这类电网永远开不了火,
面板还会显示"电量 0/40000"误导成永久缺电。现在:
- 有电池:按存量精确判断;
- 无电池:退回按供电状态(`power.status`)放行;
- 扣电时只扣真实存在的存量(`Math.min`);
- 面板在无电池时显示"电网直供(无电池):每发 N 电力"而不是 0/N;
- `powerPerShot` 由 40000 下调到 **10000**(原值接近一个大型电池,门槛过高)。

同步更新:双语 bundle(新增 `ui.powerDirect`)、`docs/blocks/AsatInterceptor.md`
(概述/四道门/参数表/排障表/平衡表)、`docs/blocks/SatelliteLocator.md`、`README.md`。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 368/368 对称。
- 同队普通卫星与同队 LOIC 继续排除;
- 沙盒中仅允许 `SatelliteUnits.targetSatellite` 作为己方例外目标;
- 靶标卫星因 `hittable=false` 不会被普通子弹伤害,LOIC 对卫星目标改走 `target.damage(bullet.damage)`;
- 靶标被摧毁时发送 `block.silicon-loic.kill` 全局击坠提示,并播放爆炸效果;
- 建筑规则保持不变:己方建筑永不作为 LOIC 目标,敌方建筑正常攻击。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 369/369 对称。
- 在轨管理优先按实体机型显示:靶标卫星显示"靶标卫星",SSO 机型显示"SSO 卫星",不再被旧存档 type 字段误显示成信号卫星;
- `UnitType.loadIcon()` 仅在缺少正式贴图时使用程序化图标,正式 PNG 不再被无条件覆盖,右下角信息区会显示实际卫星贴图;
- 靶标卫星攻击改为直接写入血量字段并记录 `satelliteDamage hp=before->after`,绕过隔离单位的 hittable/targetable 路径,确保 LOIC 实际造成伤害;
- 靶标被摧毁时继续发送 LOIC 击坠提示与爆炸效果。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 371/371 对称。
诊断日志给出了决定性证据:

```
[findTarget] ... limit=1083.0 range=1080.0
```

`range=1080` 正是 `speed × lifetime = 12 × 90`,而代码里显式设置的是
`ION_RANGE_TILES * 8 = 320`(40 格)。也就是说**显式射程从未生效**,
LOIC 实际在 135 格内索敌——表现为"异常锁定"(会在半个地图外锁定目标)。

根因在引擎 `BulletType`:

```java
// :854(init 内)与 :402(afterPatch 内)—— 无条件覆盖
range = calculateRange();

// :435-440 —— 唯一的覆盖入口是 rangeOverride
protected float calculateRange(){
    if(rangeOverride > 0) return rangeOverride;
    ...
    return Math.max(... speed * lifetime ..., maxRange);
}
```

修复:`range = ION_RANGE_TILES * 8f` → `rangeOverride = ION_RANGE_TILES * 8f`。

原版单位同样走这个入口(`UnitTypes.java` 里有 `rangeOverride = 385f` / `361.2f` 等),
已核对全仓库:只有 LOIC 的 bullet 受影响;拦截塔的 `range` 是 `Turret` 方块字段,
不经过 `BulletType.init`,无需改动。

同时核对了目标规则的实际效果(取最后一次会话,排除跨版本累积的旧记录):
最后一次会话的目标分布为「敌方核心 84 次 + 己方靶标 4 次」,
**没有同队 LOIC / 同队普通建筑 / 同队 SSO 卫星**,规则生效。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
**命名**(只动两处,其余保持原状)
- 单位名:`离子炮卫星·LOIC` → **近地轨道离子炮**(en: `Low Orbit Ion Cannon`)
- 控制台「在轨管理」类型列:`离子炮卫星` → **LOIC**(en: `LOIC`)
- 中文用全名的位置,英文同步用全名(单位名 en = Low Orbit Ion Cannon)

**射程**
- `ION_RANGE_TILES` 40 → **80 格**,与反卫星拦截塔射程一致,形成
  「地面能打多远、天上就能打多远」的对称。

**悬停预览攻击范围**
- 鼠标指向 LOIC 卫星时,除现有的 ±50 秒轨迹与信号覆盖圆外,
  额外画出**武器索敌射程**圆(红色 `ff5a4a`,双圈以便在亮色地形上辨识)。
- 这是与信号覆盖**不同**的圆:射程大于覆盖,两圈会明显分开,
  便于判断「这颗能不能够到目标」。
- 只对挂武器的机型绘制(判断 `type == SatelliteUnits.ionLeo`),信号卫星不受影响。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 373/373 对称。
现象:LOIC 悬停时会画出信号覆盖圈,而它其实是武器卫星。

核对覆盖路径后确认这不是纯视觉问题——`satelliteRawAt()` 只按轨道半径判定,
**没有按类型过滤**,所以 LOIC 此前确实在提供信号覆盖(能激活中继器、参与绑定判定)。
只把圈去掉会让「看不到圈」与「仍在提供信号」不一致。

按语义收口:

- `SatelliteManager.satelliteRawAt()`:LOIC 直接返回 0。它是**所有覆盖计算的唯一入口**
  (覆盖绘制、强度聚合、中继器激活、绑定判定都经它),一处收口即保证行为一致;
- 判定用**双保险**:名册 `r.type == TYPE_ION` **或** 实体机型 `u.type == ionLeo`。
  只看名册 type 不够——旧存档残留值、补建时序都可能让它不准,而机型是引擎里唯一可靠的线索;
- `SatelliteTrajectory`:LOIC 悬停时不再画覆盖圈,只保留轨迹与**攻击范围**圆
  (射程 80 格,红色双圈)。

影响:LOIC 不再能当廉价信号卫星用(不激活中继器、不提供绑定覆盖)。
这是「武器卫星」语义的自然结果;若希望它同时承担通信职责,回退只需删掉那一行判定。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
现象:多颗 LOIC 的子弹飞向同一方向,与各自的目标无关。

根因在引擎 `Weapon.bulletRotation()`(:456):

```java
return rotate ? unit.rotation + mount.rotation
              : Angles.angle(bulletX, bulletY, mount.aimX, mount.aimY)
                + (unit.rotation - unit.angleTo(mount.aimX, mount.aimY))
                + baseRotation;
```

LOIC 的武器偏移是 `x = 0, y = 0`,于是 `bulletX/bulletY` 就等于单位位置,
`Angles.angle(bulletX,bulletY,aimX,aimY)` 与 `unit.angleTo(aimX,aimY)` 两项相消:

```
shootAngle = unit.rotation + baseRotation
```

即**子弹方向 = 卫星本体朝向(轨迹切线)**。同轨道卫星切线相同,所以全部朝同一方向开火。

修复:覆写 `bulletRotation()` 直接返回「枪口 → 目标」的绝对角度。
比改成 `rotate = true` 更合适——保持武器不转向的视觉,且没有转向延迟
(卫星在轨道上高速移动,转向延迟会让它在目标移动时打偏)。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
原先两者的情报按**队伍**自动共享(`SatelliteIntel` 以 Team 为键),任何一座定位器探测到的
目标,全队拦截塔无需任何配置即可使用。改为与卫星控制台 / 发射中枢同一套机制:**通过信号编码配对**。

**核心改动**
- `SatelliteIntel`:索引由 `Team -> Snapshot` 改为 `Team -> (编码 -> Snapshot)`;
  `publish` / `get` / `clearFrom` 全部带编码参数。未绑定编码时既不发布也不读取。
- **卫星定位器**(发布方):面板新增信号绑定选择器;探测结果发布到绑定编码;
  未绑定 / 断电 / 关闭上报时撤稿(只撤自己那条编码,不影响同队其它编码)。
- **反卫星拦截塔**(消费方):面板新增信号绑定选择器;只读取绑定编码的情报;
  `findTarget` 与 `validateTarget` 同步收紧。

**信号真实性约束**(与卫星控制台同一判据)
- 定位器:绑定的编码没有存活信号源时**自动解除绑定**(不对着空频道广播)。
- 拦截塔:编码没有存活信号源时**视为无情报**,但**不清绑定** —— 信号源恢复后自动续接。

**配对关系**:两侧绑定同一编码即建立连接,**一个定位器可服务多座塔**(传感器共享,
比中枢/控制台的 1:1 更宽松)。若需要 1:1,可在编码匹配之外再加唯一性校验。

**新增共用组件** `SignalBindUI`:搜索框 + 编码按钮网格 + 清除,交互与卫星控制台的信号选择一致。

**存档**:定位器 v1 -> v2(追加编码),拦截塔新增 v1(编码);两者都兼容旧档
(旧档读到的编码为 null,即未绑定,需重新绑定后才工作)。

**本地化**:新增 5 个 `block.silicon-signal-bind.*` 键;拦截塔描述补充绑定要求。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 378/378 对称。
mod 内的方块配置面板统一用 `table.table(Styles.grayPanel, ...)` 包裹
(信号源 / 中继器 / 干扰器 / 探测器 / 发电机泵都是),而卫星系列的三个面板此前是裸表,
在游戏里表现为没有底色、与其它方块观感不一致。

按同一惯例补齐:

- **卫星发射中枢**:种类选择面板
- **卫星定位器**:状态 + 上报开关 + 信号绑定
- **反卫星拦截塔**:情报 / 绑定 / 电量 / 锁定 + 信号绑定

卫星控制台不加:它的 `buildConfiguration` 直接打开对话框(`hideConfig()` + `openDialog()`),
对话框自带背景,再加一层会重复。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
上一版只是 1:N:`SatelliteIntel` 一个编码存一份快照,`publish` 是**覆盖写入** ——
两个定位器绑同一编码时,后发布的会静默盖掉前一个的探测结果,
表现为"其中一座定位器白建了"。

改为按「编码 → 发布者 → 该发布者的快照」三级存储:

- `publish(team, code, publisherId, units, now)`:只写**自己那一格**,同编码的其它定位器不受影响;
- `get(team, code, now)`:聚合该编码下所有**未过期**发布者的结果,按实体去重后返回;
- `clearPublisher(team, code, publisherId)`:断电/被拆只撤自己那份贡献,
  同编码的其它定位器继续工作(旧实现是清整个编码,会误伤别人)。

发布者标识用方块 `id`(`Building.id`)。

**时效**:每个发布者独立计时,超过 `STALE_TICKS`(180 tick = 3 秒)未刷新即从聚合结果中剔除,
并在聚合时顺手清理过期条目(避免表无限增长)。

**聚合缓存**:结果按 `Time.time` 缓存,同一帧内多次 `get` 不重复聚合
(多座塔读同一编码时只算一次)。

配对关系因此是真正的 N:N:
- N 个定位器 → 同一编码 → 结果合并;
- 同一编码 → M 座塔读取;
- 也支持多个编码并存(不同定位器绑不同编码,不同塔各取所需)。

验证:gradlew clean jar 通过;tests/ 三套件全绿。
原先定位器是**不限距离的全图探测**(代码里明确不过滤距离),一座即可覆盖整张图。
现改为半径 **80 格**,与反卫星拦截塔射程一致 —— 探测范围与打击范围对齐,探得到就打得到。

- `SatelliteLocator.DETECT_RANGE_TILES = 80f`,`updateTile` 增加 `u.within(x, y, range)` 过滤;
- 扫描脉冲半径同步改为探测半径(脉冲范围 = 实际探测范围,玩家据此判断这座管多大一片),
  原先按地图长边取,视觉上会扫过整张图。

同步更新的过时表述(这些描述在新行为下已不准确):
- bundle 描述:探测敌方在轨卫星 -> 探测 80 格内的敌方在轨卫星,把情报发布到绑定的信号编码
  (旧描述还写着"共享给本队的反卫星拦截塔",那是编码配对之前的语义);
- README.md:方块表与详细说明里的「全图探测」「按队伍共享」;
- docs/blocks/SatelliteLocator.md:开头、参数表(探测范围 全图 -> 80 格)、
  对比表(需要配对:不需要 -> 需要)、排障条目;
- docs/blocks/AsatInterceptor.md:开头、目标来源、射程说明、成本说明;
- AsatInterceptor 的两处类注释("全队共享,自动连接、无需配对")。

验证:gradlew clean jar 通过;tests/ 三套件全绿;双语键 378/378 对称。
@rt334 rt334 closed this Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Voting is closed Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
1 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant