diff --git a/Fuyutsui/core/block.lua b/Fuyutsui/core/block.lua
index c225a260..7a747473 100644
--- a/Fuyutsui/core/block.lua
+++ b/Fuyutsui/core/block.lua
@@ -608,6 +608,16 @@ local function SetupPermanentAuraPixel(button, index)
bg:SetColorTexture(r, g, 1, 1)
end
+--- 职责覆盖槽:光环存在时保留职责像素的索引通道,并把数值通道覆盖为 0。
+local function SetupZeroAuraPixel(button, index)
+ AnchorAuraPixelButton(button, index)
+ button:SetFrameLevel((button:GetFrameLevel() or 0) + 1)
+ local bg = button:CreateTexture(nil, "ARTWORK")
+ bg:SetAllPoints(button)
+ local r, g = EncodeBlockChannels(index)
+ bg:SetColorTexture(r, g, 0, 1)
+end
+
--- 限时光环槽:底层 b=0,█ 用剩余时间曲线;叠在永久槽之上
local function SetupTimedAuraDuration(button, index)
AnchorAuraPixelButton(button, index)
@@ -646,6 +656,12 @@ local function MakeTimedSlotInitializer(index)
end
end
+local function MakeZeroSlotInitializer(index)
+ return function(button)
+ SetupZeroAuraPixel(button, index)
+ end
+end
+
local function AddDurationAuraSlotPair(container, slotKeyPrefix, filter, includeSpellIDs, index)
-- 先限时后永久:若容器对 auraInstance 互斥分配,避免限时光环被永久槽抢走。
-- 限时槽:maxDuration 排除永久;底层 b=0 + █ 曲线
@@ -1120,7 +1136,9 @@ end
驱散蓝通道:Magic=1 Curse=2 Disease=3 Poison=4 Bleed=11(/255)
============================================================================]]
-local groupAuraContainers = {} -- [memberIndex] = AuraContainer
+local GROUP_ROLE_ZERO_AURA_ID = 27827
+local groupAuraContainers = {} -- [memberIndex] = AuraContainer
+local groupRoleOverlayContainers = {} -- [memberIndex] = AuraContainer
local function CollectGroupAuraDefs(auraTable)
local defs = {}
@@ -1173,6 +1191,39 @@ function Fuyutsui:ReleaseGroupAuraContainers()
ReleaseFrame(container)
groupAuraContainers[memberIndex] = nil
end
+ for memberIndex, container in pairs(groupRoleOverlayContainers) do
+ ReleaseFrame(container)
+ groupRoleOverlayContainers[memberIndex] = nil
+ end
+end
+
+local function CreateGroupMemberRoleOverlayContainer(memberIndex, groups)
+ EnsureAuraContainerLoaded()
+
+ local container = CreateFrame("AuraContainer", "FuyutsuiGroupRoleOverlaySlots_" .. memberIndex, UIParent,
+ "CustomAuraContainerTemplate")
+ container:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 0, 0)
+ container:SetEnabled(true)
+ container:SetFrameStrata(AURA_DURATION_STRATA)
+ container:SetFrameLevel(AURA_DURATION_LEVEL + 1)
+
+ local pixelIndex = GroupAuraPixelIndex(groups, memberIndex, groups.role)
+ local slotKey = "group_" .. memberIndex .. "_role_zero"
+ local includeSpellIDs = { [GROUP_ROLE_ZERO_AURA_ID] = true }
+ container:AddAuraSlot(slotKey, "HELPFUL", {
+ candidateFilters = AuraSlotFilters(includeSpellIDs),
+ sortMethod = AuraContainerSortMethod.Expiration,
+ sortDirection = AuraContainerSortDirection.Normal,
+ initializeFrame = MakeZeroSlotInitializer(pixelIndex),
+ })
+ container.fuyutsuiSpellIdSlots = {
+ {
+ key = slotKey,
+ includeSpellIDs = includeSpellIDs,
+ },
+ }
+
+ return container
end
local function CreateGroupMemberAuraContainer(memberIndex, groups, auraDefs, includeDispelTypes)
@@ -1237,34 +1288,57 @@ function Fuyutsui:RefreshGroupAuraContainers()
local auraDefs = CollectGroupAuraDefs(groups.aura)
local includeDispelTypes = groups.dispel and CopyIncludeDispelTypes() or nil
- if #auraDefs == 0 and not includeDispelTypes then
+ local needsAuraContainer = #auraDefs > 0 or includeDispelTypes ~= nil
+ local needsRoleOverlay = groups.role ~= nil
+ if not needsAuraContainer and not needsRoleOverlay then
self:ReleaseGroupAuraContainers()
return
end
local groupList = Fuyutsui.groupList or {}
local group = Fuyutsui.group or {}
- local used = {}
+ local usedAuraContainers = {}
+ local usedRoleOverlayContainers = {}
for _, unit in ipairs(groupList) do
local obj = group[unit]
if obj and obj.index then
local memberIndex = obj.index
- used[memberIndex] = true
- local container = groupAuraContainers[memberIndex]
- if not container then
- container = CreateGroupMemberAuraContainer(memberIndex, groups, auraDefs, includeDispelTypes)
- groupAuraContainers[memberIndex] = container
+ if needsAuraContainer then
+ usedAuraContainers[memberIndex] = true
+ local container = groupAuraContainers[memberIndex]
+ if not container then
+ container = CreateGroupMemberAuraContainer(memberIndex, groups, auraDefs, includeDispelTypes)
+ groupAuraContainers[memberIndex] = container
+ end
+ container.fuyutsuiUnit = unit
+ container:SetUnit(unit)
+ container:SetEnabled(true)
+ container:Show()
+ end
+ if needsRoleOverlay then
+ usedRoleOverlayContainers[memberIndex] = true
+ local roleContainer = groupRoleOverlayContainers[memberIndex]
+ if not roleContainer then
+ roleContainer = CreateGroupMemberRoleOverlayContainer(memberIndex, groups)
+ groupRoleOverlayContainers[memberIndex] = roleContainer
+ end
+ roleContainer.fuyutsuiUnit = unit
+ RebindContainerSpellFilters(roleContainer, unit)
+ roleContainer:SetEnabled(true)
+ roleContainer:Show()
end
- container.fuyutsuiUnit = unit
- container:SetUnit(unit)
- container:SetEnabled(true)
- container:Show()
end
end
for memberIndex, container in pairs(groupAuraContainers) do
- if not used[memberIndex] then
+ if not usedAuraContainers[memberIndex] then
+ container:SetEnabled(false)
+ container:Hide()
+ end
+ end
+ for memberIndex, container in pairs(groupRoleOverlayContainers) do
+ if not usedRoleOverlayContainers[memberIndex] then
container:SetEnabled(false)
container:Hide()
end
@@ -1317,4 +1391,14 @@ function Fuyutsui:RebindAuraSpellFilters()
RebindContainerSpellFilters(container, container.fuyutsuiUnit)
end
+ local roleOverlayIndices = {}
+ for memberIndex in pairs(groupRoleOverlayContainers) do
+ tinsert(roleOverlayIndices, memberIndex)
+ end
+ table.sort(roleOverlayIndices)
+ for _, memberIndex in ipairs(roleOverlayIndices) do
+ local container = groupRoleOverlayContainers[memberIndex]
+ RebindContainerSpellFilters(container, container.fuyutsuiUnit)
+ end
+
end
diff --git a/Fuyutsui/core/group.lua b/Fuyutsui/core/group.lua
index 9cdcd56b..ce619b33 100644
--- a/Fuyutsui/core/group.lua
+++ b/Fuyutsui/core/group.lua
@@ -6,6 +6,7 @@ local state = Fuyutsui.state
local roleMap = Fuyutsui.roleMap
local ColorValue0 = CreateColor(0, 0, 0, 1)
local updateIndex = 1
+local GROUP_MAX_MEMBERS = 40
-- 施法治疗预估偏移(近似秒数/权重,写入生命曲线)
local helpfulSpells = {
@@ -60,30 +61,39 @@ function Fuyutsui:UpdateGroupInRangeAndHealth()
local groupList = self.groupList
if not blocks or not blocks.groups then return end
local numUnits = #groupList
- if numUnits >= 1 then
- local unit = groupList[updateIndex]
- local obj = group[unit]
- if not obj then return end
- self:UpdateUnitHealthInfo(unit)
- local index = blocks.groups.start + (obj.index - 1) * blocks.groups.num + blocks.groups.role
- obj.isDead = UnitIsDeadOrGhost(unit)
- obj.canAssist = UnitCanAssist("player", unit)
- obj.valid = not obj.isDead and obj.canAssist and obj.inSight
- if obj.valid then
- local inRange = UnitIsUnit(unit, "player") and true or UnitInRange(unit)
- local roleValue = roleMap[obj.role] and roleMap[obj.role] / 255 or 5 / 255
- local trueValue = CreateColor(0, 0, roleValue, 1)
- local booleanValue = EvaluateColorFromBoolean(inRange, trueValue, ColorValue0)
- local _, _, b = booleanValue:GetRGB()
- self:CreateTexture(index, b)
- else
- self:CreateTexture(index, 0)
- end
- updateIndex = updateIndex + 1
- if updateIndex > numUnits then
- updateIndex = 1
- end
+ if numUnits < 1 then
+ updateIndex = 1
+ return
end
+
+ if updateIndex < 1 or updateIndex > numUnits then
+ updateIndex = 1
+ end
+
+ local unit = groupList[updateIndex]
+ local obj = unit and group[unit] or nil
+ if not obj then
+ updateIndex = 1
+ return
+ end
+
+ self:UpdateUnitHealthInfo(unit)
+ local index = blocks.groups.start + (obj.index - 1) * blocks.groups.num + blocks.groups.role
+ obj.isDead = UnitIsDeadOrGhost(unit)
+ obj.canAssist = UnitCanAssist("player", unit)
+ obj.valid = not obj.isDead and obj.canAssist and obj.inSight
+ if obj.valid then
+ local inRange = UnitIsUnit(unit, "player") and true or UnitInRange(unit)
+ local roleValue = roleMap[obj.role] and roleMap[obj.role] / 255 or 5 / 255
+ local trueValue = CreateColor(0, 0, roleValue, 1)
+ local booleanValue = EvaluateColorFromBoolean(inRange, trueValue, ColorValue0)
+ local _, _, b = booleanValue:GetRGB()
+ self:CreateTexture(index, b)
+ else
+ self:CreateTexture(index, 0)
+ end
+
+ updateIndex = updateIndex % numUnits + 1
end
--- source: "guid" | "health" | nil
@@ -140,15 +150,20 @@ end
function Fuyutsui:ClearGroupBlocks()
local blocks = self.blocks
- if blocks.groups and blocks.groups.start then
- local startIndex = blocks.groups.start
- for index = startIndex, 255 do
- self:CreateTexture(index, 0)
- end
+ local groups = blocks and blocks.groups or nil
+ if not groups or not groups.start or not groups.num then
+ return
+ end
+
+ local endIndex = groups.start + GROUP_MAX_MEMBERS * groups.num
+ for index = groups.start, endIndex do
+ self:CreateTexture(index, 0)
end
end
function Fuyutsui:UpdateGroup()
+ self:ClearGroupBlocks()
+ updateIndex = 1
self.group = {}
self.groupList = {}
local group = self.group
diff --git a/Infrastructure/StartupNotice.cs b/Infrastructure/StartupNotice.cs
index 9064216f..a0e1bc27 100644
--- a/Infrastructure/StartupNotice.cs
+++ b/Infrastructure/StartupNotice.cs
@@ -4,17 +4,10 @@ internal static class StartupNotice
{
// 在这里填写首次打开或版本升级时展示的信息文本。
private const string NoticeText = """
- 1.2.1.11
- - 添加了首领战的编号
- - 添加了常用字段的参考
- - 添加了鼠标单位的信息显示
+ 1.2.1.12
+ - 修复了在某些情况下无法正确加载配置文件的问题。
+ - 不再需要检测"救赎之魂"buff, 现在可以在不使用该buff的情况下正常筛选单位
- 1.2.1.10
- - 重要: 修改了施法时间和引导时间的比例, 1秒 = 10, 所有打断都需要修改。
- - 施法修改为:施法(倒计时), 添加了:施法(正计时)。
- - 可以监控boss的一些信息。
- - 优化了UI界面。
-
""";
public static void ShowIfNeeded()
diff --git a/Modules/UnitSelector.cs b/Modules/UnitSelector.cs
index b791425e..bc5ca8f0 100644
--- a/Modules/UnitSelector.cs
+++ b/Modules/UnitSelector.cs
@@ -4,8 +4,8 @@ namespace Shigure;
/// 把 / 定义在当前 group 状态下解析为
/// 单位槽位或数量。逻辑忠实移植自旧 Python 项目 utils.py:
/// - 只考虑职责 != 0 的单位;
-/// - 生命值 0 视为死亡跳过;
-/// - 阈值表示只考虑 0 < 生命值 < 阈值;
+/// - 最低生命值选择器保留并比较 0 和负生命值;
+/// - 生命值数量字段仍只统计 0 < 生命值 < 阈值;
/// - 按 "1".."30" 升序遍历, 保证首/末语义稳定。
///
public static class UnitSelector
@@ -134,7 +134,7 @@ public static class UnitSelector
};
}
- /// 在职责 != 0 的单位里, 取 0 < 生命值 < 阈值 且满足 predicate 的最低血量单位。
+ /// 在职责 != 0 的单位里, 取生命值 < 阈值且满足 predicate 的最低血量单位(含 0 和负数)。
private static string? LowestHealth(
IReadOnlyDictionary> group,
int threshold,
@@ -155,7 +155,7 @@ public static class UnitSelector
continue;
}
- if (pct > 0 && pct < threshold && pct < lowestPct)
+ if (pct < threshold && pct < lowestPct)
{
lowestUnit = key;
lowestPct = pct;
diff --git a/Obsidian/20-Fuyutsui/07-Fuyutsui-队伍与治疗吸收.md b/Obsidian/20-Fuyutsui/07-Fuyutsui-队伍与治疗吸收.md
index 4a41c32e..987d489b 100644
--- a/Obsidian/20-Fuyutsui/07-Fuyutsui-队伍与治疗吸收.md
+++ b/Obsidian/20-Fuyutsui/07-Fuyutsui-队伍与治疗吸收.md
@@ -30,7 +30,7 @@ source_symbols:
- Fuyutsui:UpdateUnitHealthInfo
- Fuyutsui:RefreshGroupHealAbsorbBars
- Fuyutsui:UpdateGroupHealAbsorbBar
-verified_at: 2026-08-09
+verified_at: 2026-08-28
---
# Fuyutsui 队伍与治疗吸收
@@ -100,6 +100,7 @@ GROUP_ROSTER_UPDATE / 专精重建
- 健康使用 `curve100`,并可叠加 `inComingHeals` 的近似偏移;这是预测值,不是纯 `UnitHealthPercent`。
- `valid` 由“未死亡、可协助、在视线内”组成。
- 职责来自 `UnitGroupRolesAssigned()`;玩家用当前专精职责覆盖 API 结果。
+- 每名成员另有一个独立职责覆盖 AuraContainer:存在 `27827`(救赎之魂)时,在职责像素上覆盖 `B=0`;光环消失后覆盖槽隐藏,重新显示底层原职责。
- `inSight` 在收到中文 `UI_ERROR_MESSAGE`“目标不在视野中”时对最近施法目标置为 false,1.5 秒后恢复 true;它是错误消息驱动的启发式,不等价于射线检测。
- 死亡既可按 unit token 更新,也可由战斗日志 GUID 标记。
@@ -107,6 +108,8 @@ GROUP_ROSTER_UPDATE / 专精重建
如果 `group.aura` 存在,每个 offset 建一个 `HELPFUL|PLAYER` 槽;如果 `group.dispel` 存在,则按玩家当前实际会驱散的类型建立一个减益槽。两者都落在成员块地址公式内。
+职责覆盖使用独立容器和 `HELPFUL` 过滤,因此 `27827` 可以来自队伍中的其他牧师,也不会与配置中已有的“救赎之魂”普通光环槽争用同一个 aura 实例。
+
光环持续时间使用顶部像素 `B`,驱散槽使用固定类型色。定义与刷新细节见 [[20-Fuyutsui/08-Fuyutsui-光环容器本地集成]]。
## 治疗吸收网格协议
@@ -130,20 +133,21 @@ GROUP_ROSTER_UPDATE / 专精重建
- 对 30 名外部消费者,最后一个成员地址必须不超过顶部索引 510。
- 顶部队伍块和吸收网格是两套坐标系,不可用同一个 index 解释。
- Shigure 当前只构建 30 名成员,而 WoW 团队可达到 40 人;30 是当前跨项目契约上限,不是游戏上限。
-- roster 变化后应重置轮转游标并清除旧成员像素,避免上一阵容泄漏。
+- roster 变化后会先清除完整 40 人成员区并重置轮转游标,避免上一阵容泄漏。
+- roster 重建后的职责像素先保持为 0,再由每帧轮转逐个恢复;未重新确认有效性的成员不会提前进入 Shigure 的目标选择。
## 失败模式与当前风险
-1. **缩编后分帧轮转可永久卡住。** `updateIndex` 是文件级局部变量,`UpdateGroup()` 不重置它。若旧索引大于新 `#groupList`,`groupList[updateIndex]` 为 `nil`,函数在递增前返回,此后每帧都停在同一无效索引(`group.lua:8,57-85,151-190`)。
-2. **旧成员顶部像素残留。** 重建 roster 没有清除超出新人数的旧槽;`ClearGroupBlocks()` 未见调用,而且只清到 255,无法覆盖两段协议的 256..510。
-3. **40 人被截断或未消费。** Lua 成员表可能包含 40 人,但吸收网格和 Shigure 固定 30;需要明确选择截断策略。
-4. **角色偏移缺失。** 职业 group 表若没有 `role` 或 `healthPercent`,更新器做索引算术时可能报错。
-5. **异步 roster 竞态。** roster 更新使用延迟回调;光环容器和吸收槽在短窗口内可能仍绑定旧 unit。
-6. **视线语义是启发式。** 将 `valid` 当作权威 LoS 会导致错误治疗决策。
+1. **40 人被截断或未消费。** Lua 成员表可能包含 40 人,但吸收网格和 Shigure 固定 30;需要明确选择截断策略。
+2. **角色偏移缺失。** 职业 group 表若没有 `role` 或 `healthPercent`,更新器做索引算术时可能报错。
+3. **异步 roster 竞态。** roster 更新使用延迟回调;光环容器和吸收槽在短窗口内可能仍绑定旧 unit。
+4. **视线语义是启发式。** 将 `valid` 当作权威 LoS 会导致错误治疗决策。
+
+已修复:`UpdateGroup()` 现在会在重建前清空完整 40 人顶部成员区并把 `updateIndex` 重置为 1;每帧更新也会主动归一越界或异常游标,不再因缩编停在无效槽位。
## 修改影响
-- 修复缩编时应在 `UpdateGroup()` 把 `updateIndex` 归一为 1,并清除旧成员实际占用的完整 1..510 范围。
+- 队伍重建必须继续保持“先清成员块、再重置游标、最后重建 roster”的顺序;不要在新 roster 写入后再清理。
- 改成员顺序、最大人数或字段偏移,必须同步 [[30-Shigure/03-Shigure-配置合并与GameState构建]]。
- 改吸收网格的列数、槽宽、锚点 RGB 或终点色,必须同步 [[30-Shigure/02-Shigure-像素扫描与协议解码]]。
- 新增 group aura/dispel 字段时检查 `group.num` 和第 30 人末索引。
@@ -151,11 +155,11 @@ GROUP_ROSTER_UPDATE / 专精重建
## 源码索引
- `Fuyutsui/main.lua:171-180`:成员块起点与字段定义。
-- `Fuyutsui/core/group.lua:21-87`:成员遍历、健康与每帧轮转。
-- `Fuyutsui/core/group.lua:90-139`:死亡、视线与治疗预估。
-- `Fuyutsui/core/group.lua:141-190`:清理和 roster 重建。
+- `Fuyutsui/core/group.lua:22-97`:成员遍历、健康与每帧轮转。
+- `Fuyutsui/core/group.lua:100-149`:死亡、视线与治疗预估。
+- `Fuyutsui/core/group.lua:151-202`:清理和 roster 重建。
- `Fuyutsui/core/block.lua:299-457`:治疗吸收网格、绑定和事件。
-- `Fuyutsui/core/block.lua:946-1090`:队伍光环/驱散成员槽。
+- `Fuyutsui/core/block.lua:1136-1404`:队伍光环、职责覆盖与驱散成员槽。
## 知识图谱
diff --git a/Obsidian/30-Shigure/03-Shigure-配置合并与GameState构建.md b/Obsidian/30-Shigure/03-Shigure-配置合并与GameState构建.md
index 3a248e62..4ddcb3f3 100644
--- a/Obsidian/30-Shigure/03-Shigure-配置合并与GameState构建.md
+++ b/Obsidian/30-Shigure/03-Shigure-配置合并与GameState构建.md
@@ -28,7 +28,7 @@ source_symbols:
- StateBuilder.Build
- StateBuilder.BuildGroup
- GameState.GetValue
-verified_at: 2026-08-09
+verified_at: 2026-08-28
---
# Shigure 配置合并与 GameState 构建
@@ -92,7 +92,7 @@ RowData[2,3]
- 组员配置默认 `start=26`、`num=5`,但以生成的配置为准。
- 固定生成 30 个单位;第 `i` 个单位某字段的绝对步骤为 `start + (i-1)*num + relativeStep`。
- 组员字段若 `step: "bar"`,读取的是全局动作条索引,不是组员步长。
-- `HealAbsorbData[i]` 注入组员;因插件生命值包含吸收,构建器从 `生命值` 中减去吸收,并把结果下限钳为 0。
+- `HealAbsorbData[i]` 注入组员;因插件生命值包含吸收,构建器从 `生命值` 中减去吸收。结果不设下限,可以为 0 或负数,供最低生命值选择器比较。
### GameState 查询
diff --git a/Obsidian/30-Shigure/07-Shigure-动态单位数量与公式.md b/Obsidian/30-Shigure/07-Shigure-动态单位数量与公式.md
index 7636ba5b..5442d179 100644
--- a/Obsidian/30-Shigure/07-Shigure-动态单位数量与公式.md
+++ b/Obsidian/30-Shigure/07-Shigure-动态单位数量与公式.md
@@ -29,7 +29,7 @@ source_symbols:
- UnitSelector.Resolve
- ModuleLogic.ResolveDynamicFields
- FormulaEvaluator.TryEvaluateInt
-verified_at: 2026-08-09
+verified_at: 2026-08-28
---
# Shigure 动态单位、数量统计与公式
@@ -75,8 +75,8 @@ verified_at: 2026-08-09
选择器根据 `ModuleUnit.Kind` 分派,核心类别包括最低生命/低于阈值、按角色选取、按光环/驱散、按治疗吸收等。常见但容易遗漏的细节:
-- 最低生命要求 `0 < health < threshold`;默认阈值是 100,所以满血 100 不参与。若要包含满血,应把阈值设为 101。
-- 低生命/最低生命路径会跳过生命 0,但光环、角色、驱散、吸收路径没有统一的生命 0 过滤。
+- 最低生命要求 `health < threshold`,会保留并比较 0 和负数;默认阈值是 100,所以满血 100 不参与。若要包含满血,应把阈值设为 101。
+- 最低生命路径不会跳过生命 0;扣除治疗吸收后的生命值也不设下限,因此负数同样参与选择。光环、角色、驱散、吸收路径也没有统一的生命 0 过滤。
- 角色选择的 first/last 由 `Reverse` 控制;它本身不排除生命 0。
- 光环选择比较光环数值,取最大值;驱散选择取首个满足项。
- 吸收选择使用严格 `absorb > threshold`,默认阈值 0。
@@ -89,6 +89,8 @@ verified_at: 2026-08-09
`ModuleCountField` 使用与单位选择相近的筛选维度,但输出数量而非单位编号。数量可在条件中直接按名字读取,也会物化为 `$counts.`。若 ValueAdjustment 目标是已有 count 或 unit-health 名称,调整器可覆盖其派生数值;否则可能写到原状态或 `$dynamicvalues`。
+低生命数量字段仍要求 `0 < health < threshold`,所以 0 和负数不会计入数量;这与最低生命动态单位的候选范围不同。
+
## ValueAdjustment
每项调整有 `Enabled`、Condition、Field、Delta 和 Formula:
@@ -122,7 +124,7 @@ verified_at: 2026-08-09
| 症状 | 优先检查 |
|---|---|
| 满血单位从不被最低生命选中 | 默认阈值 100 且比较是严格 `<`;设 101 |
-| 离线/死亡单位仍被光环或角色规则选中 | 这些 Kind 没有统一检查 health > 0 |
+| 离线/死亡单位仍被最低生命、光环或角色规则选中 | 这些 Kind 没有统一检查 health > 0;最低生命明确允许 0 和负数 |
| 角色缺失的单位意外参与 | `RoleNotZero` 对不可解析值返回合格 |
| 数值调整没影响目标选择 | 该字段未被归类为选择阈值的早期调整,或 Condition 未命中 |
| 公式小数结果少 1 | 最终强转 int 向 0 截断 |
diff --git a/Obsidian/40-跨项目/01-Shigure-像素生产消费契约.md b/Obsidian/40-跨项目/01-Shigure-像素生产消费契约.md
index d4e380df..4663b690 100644
--- a/Obsidian/40-跨项目/01-Shigure-像素生产消费契约.md
+++ b/Obsidian/40-跨项目/01-Shigure-像素生产消费契约.md
@@ -32,7 +32,7 @@ source_symbols:
- "Fuyutsui:RefreshGroupHealAbsorbBars"
- "PixelScanner.ScanScreenData"
- "StateBuilder.Build"
-verified_at: "2026-08-09"
+verified_at: "2026-08-28"
---
# Shigure 像素生产消费契约
@@ -117,7 +117,7 @@ Shigure 从客户区左边缘向下寻找红色标记字节 `(1,0,0)`,以该 y
- `G - 1` 是治疗吸收值。
- `B` 是单位编号 `1..30`。
-`StateBuilder` 将该值写入每个 `group` 成员的 `治疗吸收`,并在值非零时从扫描到的 `生命值` 中扣除吸收,最低为零。因此这里的协议变化不仅影响显示字段,也会改变模块实际使用的队伍生命值。
+`StateBuilder` 将该值写入每个 `group` 成员的 `治疗吸收`,并在值非零时从扫描到的 `生命值` 中扣除吸收。扣除结果不设下限,可以为 0 或负数,并会参与最低生命值动态单位的比较。因此这里的协议变化不仅影响显示字段,也会改变模块实际使用的队伍生命值。
## 关键不变量
diff --git a/README.md b/README.md
index 64b16e59..9eb1ed74 100644
--- a/README.md
+++ b/README.md
@@ -241,10 +241,10 @@ Tools\ 辅助脚本
### 动态单位、数量与动态数值
-这些字段按 `group` 状态每帧重新计算。队伍统计会跳过 `职责 == 0` 或 `生命值 == 0` 的单位。
+这些字段按 `group` 状态每帧重新计算。多数动态选择与数量统计会跳过 `职责 == 0` 的单位;具体生命值过滤取决于字段类别。
-- 动态单位:可按最低生命值、最高治疗吸收、职责、光环、光环值或驱散类型选择 `1`~`30` 的队伍槽位。可在规则目标中直接使用,也可用裸名称判断是否成功解析。
-- 数量字段:统计低于生命值阈值、高于治疗吸收阈值或带/不带指定光环的单位数,只能用于条件。
+- 动态单位:可按最低生命值、最高治疗吸收、职责、光环、光环值或驱散类型选择 `1`~`30` 的队伍槽位。最低生命值使用“扫描生命值减去治疗吸收”的结果,允许结果为 0 或负数,并在所有 `生命值 < 阈值` 的候选中取最低值。动态单位可在规则目标中直接使用,也可用裸名称判断是否成功解析。
+- 数量字段:统计低于生命值阈值、高于治疗吸收阈值或带/不带指定光环的单位数,只能用于条件。其中低生命数量仍只统计 `0 < 生命值 < 阈值` 的单位。
- 条件动态数值:条件成立时对已有状态、光环、技能或动态字段加减一个整数。
- 公式动态数值:使用字段和算术表达式生成命名数值,支持 `+ - * /`、括号、一元正负号和 `int`、`round`、`floor`、`ceil`、`min`、`max`。
diff --git a/Runtime/StateBuilder.cs b/Runtime/StateBuilder.cs
index 6b93c01f..056f2fc2 100644
--- a/Runtime/StateBuilder.cs
+++ b/Runtime/StateBuilder.cs
@@ -110,11 +110,12 @@ public sealed class StateBuilder : IRuntimeStateBuilder
// 治疗吸收来自网格扫描:白块右侧像素的 B=单位编号,G-1=吸收值。
// 插件像素里的生命值含吸收盾,这里折算为真实生命:生命值 -= 治疗吸收。
+ // 保留 0 和负数,供最低生命值选择器比较治疗吸收后的有效生命值。
var absorb = healAbsorbData.TryGetValue(i, out var absorbValue) ? absorbValue : 0;
sub["治疗吸收"] = absorb;
if (absorb != 0 && sub.TryGetValue("生命值", out var healthObj) && healthObj is int health)
{
- sub["生命值"] = Math.Max(0, health - absorb);
+ sub["生命值"] = health - absorb;
}
group[i.ToString()] = sub;