Skip to content

Smart FDC 战术层增强:任务优先级、移动目标提前量、炮击顺序规划、装填恢复、唯一任务序号 - #31

Open
StevenLi-phoenix wants to merge 4 commits into
HisenWeb:masterfrom
StevenLi-phoenix:cleanhouse
Open

Smart FDC 战术层增强:任务优先级、移动目标提前量、炮击顺序规划、装填恢复、唯一任务序号#31
StevenLi-phoenix wants to merge 4 commits into
HisenWeb:masterfrom
StevenLi-phoenix:cleanhouse

Conversation

@StevenLi-phoenix

Copy link
Copy Markdown
Contributor

为 Smart FDC 补齐一组"战术层"增强,全部在 IronNestFCS.Logic 内、不改既有公开 API。这套功能已随一个 LLM 火控指挥官项目实战通关完整战役(演示:YouTube / Bilibili),随后按规格做了一次 clean-room 重写,即本 PR。

功能

  • 任务优先级体系:ArtilleryTask.priority(0–100,默认 50)。高优先级任务赢下配位、同批开火顺序、跨批次执行顺序;≥90 视为紧急——跳过凑对窗口,且可抢占一门"装载已匹配"的忙炮(被抢占任务无损退回队列)。
  • 炮击顺序规划:每个规划轮对等待队列做序列优化——优先级带为硬外层顺序,带内以 max(Δ方位/4°s, Δ俯仰/2°s) 的换位耗时做 Held-Karp 精确求解(≤10 任务,超出退化贪心),队列本体重排,HUD 直接显示计划炮击顺序。
  • 移动目标:任务可携带线性运动模型(实体跟踪自动拟合,或外部转录);规划期与执行期(装填后 pre-aim、扳机前 pre-fire、人工击发等待期每 3s)按"装药感知飞行时间"(实测 TTI 表,C1–C6 1.43–4.76 s/km)重解提前量。修掉了平均弹速假设造成的"炮弹落在移动目标身后"系统误差。
  • 晚绑定射击诸元:瞄点在入队时固化为地图局部坐标,每轮从玩家炮塔棋子("Player Turret Piece")的实时位置重推方位/距离——排队期间重校炮位自动纠正所有待办任务。
  • 解析仰角解:实测证明游戏弹道是线性的(仰角 = 距离km × 12 / 装药,60° 封顶,52 组台解验证残差 ±0.01°);重解仰角走解析式,零耗时不占弹道台,物理台只做兜底。
  • 装填失败恢复:装药 commit 不符时不再直接判败——射程仍够就按实际装药打;不够则自动排一发同方位缩程清膛弹再重装(膛内弹只能打出去是游戏机制);供药机瞬断做有界重试。
  • 任务唯一序号 #N:取代可回收的地图标记号,取消/改瞄/日志/HUD 全部以 serial 寻址;左右炮位在 HUD 以固定槽位 T9/T10 标示。
  • 排队时效:validForSeconds>0 的任务超窗自动撤销(仅限仍在等待队列的)。
  • 外部接入点(供其它 mod 反射调用,不依赖则零影响):AdjustTaskAim 非阻塞改瞄、CancelPendingTaskRequestConsoleCard 打孔卡代购队列(方位/距离/起始网格拨盘自动化)。
  • 优先级协程锁:三台共享操作台的等待队列按优先级放行(同级 FIFO),人工等待期的实时重调永远让路给新任务规划。
  • 若干内务:购买流程去重、事件驱动买卡排空取代 1s 轮询、规划轮左右侧规则表驱动。

质量与兼容

  • 与 baseline 行为兼容:未改任何既有公开成员;新增字段/方法均为增量。UI 文本走既有 FcsLocalization 双语通道。
  • 开发方法:先把全部行为提炼成规格(仓内 REQUIREMENTS.md,约 1800 行,经两轮共 135 条对抗性审计),再由未接触旧代码的实现者按规格重写,最后逐子系统对照实战版本做行为回归验证。构建 0 error / 0 warning(nullable 全开)。
  • 全部源文件 UTF-8 with BOM(规避中文 locale 下 csc 按 ANSI 误读字面量的坑)。

如希望拆分成更小的 PR(比如只要优先级/移动目标/恢复中的某几项),我可以按子系统拆——各功能在提交上是可分的。

🤖 Generated with Claude Code

StevenLi-phoenix and others added 3 commits August 26, 2026 02:28
Seven adversarial auditors compared v1 against the legacy diff; all
findings folded in. Three declared deviations from legacy behavior:
UTF-8 BOM discipline (no mojibake), cancellations now recorded in
RecentTasks, and the reflection-contract note on CoroutineLock.Acquire
overload ambiguity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sequencing/log-order/threshold precision from the second adversarial
pass, the ResolveElevation liveness exception, the FcsModule TaskContext
carve-out, and the DisplayName()/ProgressText HUD contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven Opus implementers rebuilt the enhancements from REQUIREMENTS.md
alone (no access to the legacy diff); UTF-8 BOM discipline applied
repo-wide; build clean at 0 errors / 0 warnings in Logic.

Adversarial verification (7 comparators + reflection-contract check
against the legacy behavior) found 4 low regressions - all fixed - and
12 accepted deviations, archived in REQUIREMENTS.md Appendix D.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 469c11a997

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +274 to +277
// Take the gun slot first and leave the queue only after that succeeded: an external reader must
// never observe a frame in which the task is neither pending nor on a gun.
if (!RemovePendingTask(task))
MelonLogger.Warning($"[FCS Dispatch] admitted T{task.targetId} was no longer present in pending queue");
MelonLogger.Warning($"[FCS Dispatch] admitted #{task.serial} was no longer present in pending queue");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate tasks before admitting materialized plans

When CancelPendingBySerial or SweepExpiredTasks removes a task while this planning coroutine is suspended in MaterializeCandidate, the stale assignment still reaches AddPlan; this failed RemovePendingTask is only logged after the gun slot has already been assigned. Consequently, a task that was reported as cancelled/expired and recorded in RecentTasks can become active and eventually fire. Confirm the task is still pending and not failed before creating/admitting its plan, and discard or restart stale materialization otherwise.

Useful? React with 👍 / 👎.

Comment on lines +722 to +725
if (!ReferenceEquals(_current, plan) || !IsActive(plan) || plan.Failed)
yield break;
if (_fcs.Turret.LastRotationSucceeded)
appliedAzimuth = plan.Task.angel;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile the armed follower after an azimuth re-lay

When a same-bearing follower has already been armed during a manual fire wait and the current moving target then triggers this re-lay, the shared turret rotates but the follower remains armed. Pulling the shared trigger can therefore fire the follower at the current task's new bearing rather than its own target. After a successful re-lay, re-evaluate the follower against the effective bearing and clear its ready/armed state if it is now outside SameAzimuthToleranceDegrees.

Useful? React with 👍 / 👎.

Comment on lines +113 to +115
result => {
LastCardRequestResult = $"{request.CardId}: {result} @{FcsRuntimeClock.Now:F0}";
MelonLogger.Msg($"[FCS] console card request {request.CardId} -> {result}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make every card result token unique

If two identical card requests fail on an immediate path such as card not found or card has no DraggableItem, both callbacks can run within the same rounded second and produce exactly the same LastCardRequestResult. The external poller detects completions only by string inequality, so the later request then has no observable result and its caller can wait indefinitely. Include a monotonically increasing request/completion identifier rather than relying only on F0 time.

Useful? React with 👍 / 👎.

Comment on lines +139 to +151
var requiredCharge = BallisticCalculator.MinimumCharge(urgent.distance);
FirePlan? victim = null;

// Left before right, replacing only on a strictly lower priority, so equal priorities pick the left gun.
foreach (var plan in new[] { _leftPlan, _rightPlan })
{
if (plan == null
|| plan.Task.priority >= urgent.priority
|| ReferenceEquals(_current, plan)
|| ReferenceEquals(_fireWaitOwner, plan)
|| plan.ShotObserved
|| plan.Shell != urgent.bulletType
|| plan.Charge < requiredCharge)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh an urgent target before choosing a transferable charge

EnqueueTask attempts urgent preemption before the planning round calls RefreshPendingSolutions, so urgent.distance can still describe the unled target or an aim computed from an old firing origin. If motion lead or origin re-survey moves the target across a 5 km charge boundary, this check can preempt a plan whose committed charge becomes insufficient as soon as planning refreshes the urgent task; the victim is disrupted while the urgent task remains pending instead of taking the freed gun. Evaluate the urgent task's late-bound solution before calculating requiredCharge, or otherwise use a charge requirement that accounts for that pending refresh.

Useful? React with 👍 / 👎.

…ult uniqueness, stale preemption solution

- Discard materialized plans whose task was cancelled/expired mid-round
  (and skip them in the pending-reset sweep - a second resurrection hole
  found while fixing the first).
- Disarm a same-bearing armed follower when a manual-wait re-lay moves
  the shared turret outside SameAzimuthToleranceDegrees; new
  TriggerConsole.Disarm(side) primitive, follower task unaffected.
- LastCardRequestResult gains a monotonic completion counter so
  identical same-second results stay distinguishable to pollers.
- TryPreemptForUrgent refreshes the urgent task's late-bound solution
  before computing the transferable-charge requirement.

Spec sections (16, 8.5, 15.2/17.13, 5.5, appendix C) updated in step.
Verified via an equivalent out-of-repo compile (0 errors) - the local
game install was removed after archival, taking the Il2Cpp reference
assemblies with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@StevenLi-phoenix

Copy link
Copy Markdown
Contributor Author

Codex 的四条意见全部采纳,已在 e0f1fd2 修复:

  • P1 物化竞态:任务在规划协程物化期间被取消/过期(progress=Failed)时,该 assignment 直接丢弃、不再占炮位;顺带发现并修掉了第二个洞——规划轮末尾的"未选中任务复位 Pending"扫描会把刚取消的任务复活成无队列归属的幽灵任务,同样加了 Failed 跳过。
  • P1 已武装 follower:人工等待重调成功转炮后,若已武装的同方位 follower 与新方位差超出 SameAzimuthToleranceDegrees,自动解除其武装(新增 TriggerConsole.Disarm(side) 单侧原语,pose-verified);follower 任务不判失败、不释放炮位,轮到它时按正常流程重新武装。
  • P2 结果串唯一性:LastCardRequestResult 追加单调完成序号({CardId}: {result} @{t:F0} #{n}),计数器跨重绑不复位,同秒同结果对轮询方保持可分辨。
  • P2 抢占陈旧解算:TryPreemptForUrgent 在计算可转移装药需求前,先对紧急任务做一次与规划轮相同的晚绑定刷新(运动模型 + 诸元重推),消除跨 5km 装药档的误抢占。

REQUIREMENTS.md 相应章节已同步。

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0f1fd2f03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -540,6 +888,79 @@ private bool CanFollowerArm(FirePlan current, FirePlan follower, out float azimu
return azimuthDelta <= SameAzimuthToleranceDegrees;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare followers against the effective bearing

When either task's bearing changes during the new execution-stage tracking refresh, these immutable FirePlan.Azimuth values remain at their planning-time bearings. In particular, the current plan can complete its pre-fire azimuth correction before opening the wait, after which this check still approves a follower whose original bearing matched even though the turret now points elsewhere; pulling the shared trigger then fires that follower off-target. Use the current turret/applied bearing and the follower's refreshed task bearing for this authorization.

Useful? React with 👍 / 👎.

task.motionVelLocalPerSec = task.hasMotion
? Vector3.Lerp(task.motionVelLocalPerSec, sampled, VelocityLowPassFactor)
: sampled;
_entitySamples[task.trackEntityId] = (local, now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share the sampled velocity between tasks

When multiple pending tasks track the same entity ID, the first task processed updates this shared sample timestamp, so every following task in the same refresh sees dt < 0.5s and leaves its own motionVelLocalPerSec unchanged. With the stable queue iteration order, a later task can remain at zero velocity through every refresh and be planned or armed without lead while the first task tracks correctly. Cache the fitted velocity with the entity sample or sample each entity once and propagate the model to all matching tasks.

Useful? React with 👍 / 👎.


if (preFireSolve.Ok)
{
appliedDistance = plan.Task.distance;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance the range baseline only after elevation succeeds

If the pre-fire elevation command times out or otherwise leaves LastElevationSucceeded false, this assignment still records the refreshed range as applied. For an unchanged tracked range or a static agent re-aim, the manual-wait condition subsequently sees no range delta and never retries, while the trigger path proceeds with the old elevation (including immediate AutoFire). Move the baseline update after successful SetElevation; the manual-relay path has the same premature update pattern.

Useful? React with 👍 / 👎.

if (plan == null
|| plan.Task.priority >= urgent.priority
|| ReferenceEquals(_current, plan)
|| ReferenceEquals(_fireWaitOwner, plan)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude the armed follower from urgent preemption

When a same-bearing follower has armed itself during the current manual fire wait, it is neither _current nor _fireWaitOwner, so an arriving higher-priority task can select it as the victim here. ReleaseGunSlot clears only the logical ready state and never disarms the physical safety; the commander's subsequent shared-trigger pull can therefore fire the preempted round after its FirePlan has been discarded, leaving an uncontrolled shot with no task to settle. Treat _armedFollower as non-preemptable, or synchronously disarm it before releasing the slot.

Useful? React with 👍 / 👎.

_fcs.MapTable.ApplyMotionModel(urgent);
_fcs.MapTable.RefreshSolution(urgent);

var requiredCharge = BallisticCalculator.MinimumCharge(urgent.distance);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unreachable urgent tasks before preempting

For a refreshed urgent range above the maximum C6 reach of 30 km, MinimumCharge saturates at 6 rather than indicating failure, so this method can evict a lower-priority C6 plan. The next planning round then rejects the urgent task at the explicit task.distance > charge * 5 eligibility check, meaning the victim was disrupted without creating any usable firing opportunity. Check the maximum supported range before selecting or tearing down a victim.

Useful? React with 👍 / 👎.

continue;
}

var plan = _fcs.Planner.CreatePlan(assignment.Planning, item.Candidate, commitAt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate re-aimed tasks before admitting their plans

A pending task remains adjustable while MaterializeCandidate yields on the physical calculator, but this admission path verifies only cancellation/expiry. If the commander moves the aim across a charge boundary during that window, the candidate still carries the old charge and elevation while CreatePlan reads the task's new bearing and range; the now-insufficient charge can be physically committed, and both execution-stage elevation solves then fail without preventing the trigger path from firing the stale solution. Detect solution changes after materialization and rematch or rematerialize before admission.

Useful? React with 👍 / 👎.

Comment on lines +84 to +85
if (task.serial == 0)
task.serial = ++_serialCounter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve externally supplied serials in the counter

When a supported external caller pre-sets a positive serial, this branch preserves it but does not advance or otherwise reserve _serialCounter. For example, enqueueing external serial = 1 followed by an ordinary zero-serial task assigns #1 to both, after which cancellation and re-aim operations select whichever duplicate they encounter first and recent outcomes cannot distinguish the missions. Preserve the supplied value while also advancing the counter or rejecting an already-used serial.

Useful? React with 👍 / 👎.

HisenWeb commented Aug 30, 2026

Copy link
Copy Markdown
Owner

感谢你为这个 PR 投入这么多时间,也感谢你认真处理了前面的 review。

我重新对照了 PR、AgentBridge 的实际使用方式,以及 Smart 现在的架构。先说结论:

这个 PR 我目前不会按现在的整体形态合并。

原因不只是当前实现里还有一些 race / lifecycle 问题,更主要的是几项核心能力放置的层级和 Smart 现在的职责边界不一致。也就是说,即使剩下的 review issue 全部修完,也不代表整个 PR 就会进入主线。

Smart 目前仍然坚持:

“自动化操作,不自动化战术。”

在默认的人类操作模型里,玩家负责目标选择、任务提交顺序、弹种以及是否使用 Auto Fire / Max Charge;Smart 负责后续的机械执行、双炮协调、装填、瞄准和物理状态收敛。

1. Agent 动态调度的需求成立,但边界应该停在 Pending

进一步看过 AgentBridge 后,我理解 priority、engagement-order optimization、moving prediction 等设计想解决的核心问题:Agent 和玩家的 workload 不一样。Agent 一次决策里可以生成一批任务,这一批任务更像一个待调度任务包;允许动态重排尚未执行的任务,确实可以提高双炮利用率,也能减少 Agent 在执行期间反复等待和重新提交任务的次数。

这个需求本身我认为是成立的。

但真正的分歧在于:这种动态性应该停在哪一层。

我更倾向于把边界明确放在 Pending 和 Execution 之间:

Agent 可以自由修改“未来”,Smart 独占“现在”。

也就是:

  • Pending 是可变的未来计划,外部 Commander / Agent 可以通过受控接口读取、插入、取消、替换或重排;
  • Admission 是 ownership transfer;
  • 一旦任务进入 Execution / committed,就由 Smart 独占,不再允许外部调度器为了新的优先级去抢占、回退或重写 FirePlan。

这样 Agent 仍然可以一次规划大量任务,也可以在新情报出现时把紧急任务插到队首、删除失效任务、重排剩余任务;如果整个计划失效,也仍然可以用 F9 做粗粒度的全局重规划。

相应地,像 priority、任务包排序 / Held-Karp、移动目标运动模型和提前量预测这类主要服务于 Commander / Agent 的算法,本身没有必要进入 Smart 的执行核心。它们完全可以保留在 Bridge 一侧,通过 Smart 暴露的状态快照和 Pending Queue 接口完成计算,再把最终任务顺序或最终瞄点写回 Pending。

Smart 更适合提供的是:可靠的物理事实、稳定的 Task ID、受控的 Pending Queue API,以及必要的状态/事件,而不是承载某一种 Commander 的具体调度算法。

我更希望最终形成这样的边界:

External Commander / Agent -> observe / edit Pending plan -> Smart admits task -> Smart exclusively executes

如果 Agent 修改某个 Pending 任务时,它刚好已经被 admission,接口可以明确返回 already admitted;Agent 重新读取状态后自己决定下一步,而不是让 Smart 为了完成这次修改去反向拆解正在执行的计划。

移动目标也可以先沿用这个原则:由上层维护运动模型和预测,在任务仍处于 Pending 时持续更新,并在 Admission 前物化成最终射击任务。只有以后实测证明执行期持续跟踪确实不可替代,才值得单独讨论,而不是默认让执行中的 FirePlan 持续可变。

因此 priority / Smart 内部自动任务排序 / tactical preemption / 执行期持续改写 FirePlan 这一整套当前实现,我目前仍然不准备按现在的方式放进主线。

2. 射击原点模型

这一点是比较明确的架构冲突。

Smart 现在把实际的 TurretLocation.position 作为权威射击原点,然后转换到地图局部坐标。

地图上的 Player Turret Piece 只属于地图/UI 表示,不再被当作物理炮位真值。如果实际 TurretLocation 不可用,当前设计更倾向于认为绑定失败,而不是退回地图棋子的位置。

这个 PR 的实现重新优先使用 Player Turret Piece 来计算射击原点,这和 Smart 当前已经确定下来的坐标模型相反,所以这一部分即使没有实现 bug,我也不会按现在的方式合入。

3. 自动清膛属于新的真实射击行为

装填失败恢复里“重新读取实际物理装药,并以物理状态为准”这个原则,我是认可的,它和 Smart 当前 Host / Logic 的关系一致。

但我不倾向于让 Executor 为了恢复一个任务,自动产生一发额外的缩程清膛射击。

如果当前物理装填状态已经无法完成原任务,我更希望 Smart 明确报告状态、让任务失败或者等待新的明确指令,而不是为了继续完成原任务,自行决定再打一发。

如果以后确实要支持清膛,我更倾向于把它定义成一个明确操作,由玩家或上层 Commander 主动决定,而不是作为 FirePlanExecutor 的隐式恢复路径。

4. 可以吸收的方向

这个 PR 不是没有可取的东西。相反,它把未来外部集成真正需要的基础设施暴露得比较清楚。

我目前认为比较值得单独吸收或重新实现的方向包括:

  • 稳定、真正唯一的 Task ID / serial
  • 受控、原子的 Pending Queue 操作接口,例如读取、插入、取消、替换、重排;
  • **任务在 coroutine / materialization 中被取消或失败后不能重新“复活”**这类通用 lifecycle 保护;
  • task accepted / admitted / shot observed / completed / failed 等稳定状态或事件;
  • Logic 和实际装填状态不一致时重新以物理状态为准的恢复原则;
  • 进一步审计 shared trigger / armed follower 等物理状态 invariant。

这些如果以后要做,我更希望每个问题单独做成很小的 reliability / infrastructure / integration 改动,不同时引入新的战术语义。

至于 priority、Smart 内部自动炮击排序、urgent preemption、以 Player Turret Piece 作为炮位真值、执行期持续修改 FirePlan、隐式自动清膛,以及把外部 Commander 的具体调度算法直接纳入 Smart 内部生命周期,目前我都不会按当前方案合入主线。

所以你不用继续为了这个大 PR 把剩下的 review 问题全部修完。

还是感谢你做了这一整套实现。这个 PR 对我来说最大的价值之一,是把 Smart 的架构边界以及未来外部集成真正应该开放的位置暴露得很清楚:把未来做强,把现在做稳。

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.

2 participants