diff --git a/build/build.ps1 b/build/build.ps1 index 3e6c468..f425163 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -1,10 +1,96 @@ #requires -Version 5.1 <# - BadWolf 过滤器构建脚本 build.ps1 v1.2 + BadWolf 过滤器构建脚本 build.ps1 v1.8 ------------------------------------------------------------------ 用途:把模块目录下的 txt 按文件名排序拼接成 BadWolf-LootFilter.txt, 并完成一致性校验与行号换算。纯 PowerShell,无需安装任何软件。 + v1.8 相对 v1.7 的变化 + ------------------------------------------------------------------ + · 自检新增「别名互为子串」校验 → 「错误」。 + 硬规则:别名名不得是另一别名名的子串(必须严格不同)。引擎按子串 + 扫描别名表,短名会抢先命中长名,把长名切成「前缀残渣 + 短名」, + 残渣是无效 token → 整条条件恒假,静默失效,原 check 完全查不出。 + 实例(2026-10-30 修复):裸名 CAST_4_1 / DMG_4_1 是 SHIELD_DMG_4_1、 + AMU_DMG_4_1、MELEE_DMG_4_1、GLOVE_DMG_4_1 等 24 个别名的子串 —— + 盔甲档位用裸名所以正常,其余部位的长名全部被吃掉。 + 修法:给裸名加类型前缀(CAST_4_1 → CHEST_CAST_4_1)。 + + v1.7 相对 v1.6 的变化 + ------------------------------------------------------------------ + · 自检新增 3 条校验,全部来自 2026-10-30 的全库手工审计,都是 + 「游戏内静默失效、check 原本查不出」的类型: + A) STAT 编号白名单 —— 读 ItemStatCost.txt 的 Id 列,条件里出现 + STAT 而 n 不在表内 → 「错误」。写错的编号不会报错,只会让 + 该条件恒为 0、规则永不命中,肉眼几乎查不出来。 + B) PREFIX / SUFFIX 编号越界 —— 与 MagicPrefix / MagicSuffix 的 + 数据行数比对(当前上界 990 / 1043)→ 「错误」。支持 = 与 ~ 两种写法。 + C) 同条件 + 模板只差配色 + 都带 CONTINUE —— 「提示」。这类规则会让 + 同一件物品把同一段内容并排显示两遍,而既有的「完全重复规则」 + (要求模板一字不差)与「死规则」(要求前一条不带 CONTINUE) + 都抓不到它。★ 判据必须收在「只差配色」:模板内容不同的同条件组 + 绝大多数是**分段拼接**(箭袋「升级后需求:(」+ 数值 + ")", + 护身符/地图的多行说明等),放开判据会立刻膨胀成 66 组噪音。 + 数据表位置按 PD2_EXCEL_DIR → build.ps1 同目录 excel\ → 游戏 + Data\global\excel → 桌面 data\global\excel 依次探测;找不到就跳过 + 这两条校验并给提示,不阻断构建。 + ★ 不要指向 ZYLPD2\Data\global\magicprefix.txt(911 条残表)。 + + v1.6 相对 v1.5 的变化 + ------------------------------------------------------------------ + · 「非法占位符」放行已定义别名:`%别名%` 不再判错误,改判「提示」。 + 原因:别名是**条件宏**、不是文本占位符——三个过滤器(BadWolf 479 个、 + Kryszard 160 个)中 `%别名%` 形态的实际用法为 0,别名定义体也全部是 + 条件表达式,没有文本型别名。所以模板里写 `%别名%` 在游戏内只会原样 + 显示成 `%别名%`。 + 处理:错误降为提示,并明确写出「别名是条件宏,模板里不会被替换成文本」, + 既不再误报,也不会让真正的显示错误被静默吞掉。 + 实现:扫描完成后统一判定($badPh 里名字若在 $aliasNames 内则移入 $aliasPh)—— + 别名在文件中的定义位置有先有后,必须等全量收集完再判,不能边扫边判。 + + v1.5 相对 v1.4 的变化 + ------------------------------------------------------------------ + · 自检新增「别名 0 引用」检查,补上 v1.4 留下的盲区。 + ★ 别名与公式的引用语法完全不同:别名在条件里是**裸名** + (`!KNOWN_CODES`、`(SHIELD_SKILL_5_1 OR ...)`),只有公式才是 + `%FORMULA名%`。用 `%别名名%` 去统计会全部漏检——2026-10-30 就因此 + 把 6 个盾牌星级别名误判为全死(实际都在 34 模块 L38/L39 使用)。 + 实现:复用 $tokRe 抓全大写 token 再查字典,480 个别名 × 4,470 行 + 只需一次线性扫描,不逐名做正则。 + 排除:值为裸 TRUE / FALSE 的条目(现 105 个)是设置菜单的开关与 + 分组标题(JUST_TAG1~4 之类),本就不需要被任何规则引用。 + 级别:「提示」,不阻断构建。 + + v1.4 相对 v1.3 的变化 + ------------------------------------------------------------------ + · 「公式 lint」由 3 条扩到 5 条,依据 2026-10-30 游戏内四轮实测: + 0) 公式名合法性 —— 名字必须匹配 ^[A-Za-z_]+$。实测 Formula[TESTA] 的 + %FORMULATESTA% 正常出 7,Formula[TEST1] 的 + %FORMULATEST1% 原样输出文字 → 带数字会让占位符 + 静默查不到值。「错误」 + 4) 公式体含 % —— 实测 %FORMULATESTA%*3 编译不过、该公式作废 + (% 只对 ItemDisplay 模板生效)。「错误」 + · 自检新增「公式交叉引用」:某条公式的表达式里出现另一个公式名 + (如 Formula[ZZ]: XPA+1)直接「错误」。实测裸名引用 / %FORMULA…% 包裹 / + $f() / Alias 宏四条路全不通,且失败不报错、不连坐其它公式——只能靠构建期拦。 + · 结论性事实:Formula 之间完全隔离,69 个公式必须保持自包含; + 「抽公共子公式省体积」这条路不存在,不要再尝试。 + + v1.3 相对 v1.2 的变化 + ------------------------------------------------------------------ + · 新增三条「公式 lint」,只作用于 Formula[...] 行,把 2026-10-30 审计 + 发现的问题变成自动拦截(先剥掉行尾 // 注释,注释里的中文不参与): + 1) IF 元数 —— 按顶层逗号切参数,必须正好 3 个;MULTI, 的 + 双参逗号是变量自身的一部分,先掩码再切,不算分隔符。 + 不满足则「提示」(build 不中断)。 + 2) AND/OR 单参 —— 只有一个参数时等价于给表达式加括号,写法冗余; + 「提示」。 + 3) 公式体污染 —— 出现非 ASCII / 单等号(应为 ==)/ ~(BETWEEN 只能 + 用在 ItemDisplay 条件)→ 直接「错误」,构建不通过。 + · 第 4 节自检摘要新增一行「公式 lint 错误/提示」计数。 + · build 在写盘前先跑同样的错误级检查:有 lint 错误就中止,不会把 + 错公式写进合并版(提示级不阻断)。 + v1.2 相对 v1.1 的变化 ------------------------------------------------------------------ · 自检新增:孤立 % / 未闭合 % 扫描(半截百分号会吞颜色或漏出明文) @@ -237,6 +323,67 @@ function Get-CodeWhitelist { return , $set } +# ---------------------------------------------------------------- 数据表读取(v1.7 新增) +# 用途:给「STAT 编号白名单」「词缀编号越界」两条校验提供权威数据。 +# 为什么必须查表:编号写错时游戏内是**静默失效**——该条件恒为 0,规则永不命中, +# 不报错、不连坐,肉眼极难发现。2026-10-30 手工审计才把 139 种 STAT 全核了一遍。 +# 表目录按优先级探测,都找不到就跳过这两条校验(只给提示,不阻断构建): +# 1. 环境变量 PD2_EXCEL_DIR +# 2. build.ps1 同目录下的 excel\ +# 3. 游戏目录 ..\..\Data\global\excel +# 4. 桌面 data\global\excel +# ★ 不要读 ZYLPD2\Data\global\magicprefix.txt:那是 911 条残表,比正式表少 79 条。 +function Get-ExcelDir { + $cands = New-Object System.Collections.ArrayList + if ($env:PD2_EXCEL_DIR) { [void]$cands.Add($env:PD2_EXCEL_DIR) } + [void]$cands.Add((Join-Path $Root 'excel')) + [void]$cands.Add((Join-Path $Root '..\..\Data\global\excel')) + $desk = [Environment]::GetFolderPath('Desktop') + if ($desk) { [void]$cands.Add((Join-Path $desk 'data\global\excel')) } + foreach ($d in $cands) { + if (-not $d) { continue } + if (Test-Path -LiteralPath (Join-Path $d 'ItemStatCost.txt')) { return $d } + } + return $null +} + +function Read-ExcelRows([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $txt = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8) + $rows = New-Object System.Collections.ArrayList + foreach ($l in ($txt -split "`r?`n")) { + if ($l.Trim() -eq '') { continue } + [void]$rows.Add(($l -split "`t")) + } + if ($rows.Count -lt 2) { return $null } + return , $rows.ToArray() +} + +# 返回 @{ Id = <编号哈希>; Max = <最大合法编号> } +# Id 仅在给了 -IdCol 时填(ItemStatCost 用 Id 列) +# Max = 数据行数 - 1,即编号上界(MagicPrefix 992 行含表头 → 编号 0..990) +function Get-ExcelMeta([string]$Dir, [string]$File, [string]$IdCol) { + $rows = Read-ExcelRows (Join-Path $Dir $File) + if ($null -eq $rows) { return $null } + $res = @{ Id = $null; Max = ($rows.Count - 2) } + if ($IdCol) { + $hdr = $rows[0] + $ci = -1 + for ($k = 0; $k -lt $hdr.Count; $k++) { if ($hdr[$k].Trim() -eq $IdCol) { $ci = $k; break } } + if ($ci -ge 0) { + $h = @{} + for ($r = 1; $r -lt $rows.Count; $r++) { + $row = $rows[$r] + if ($row.Count -le $ci) { continue } + $v = 0 + if ([int]::TryParse($row[$ci].Trim(), [ref]$v)) { $h[$v] = $true } + } + $res.Id = $h + } + } + return $res +} + # ---------------------------------------------------------------- 孤立 % / 未闭合 % # 半截百分号不会让过滤器报错,只会吞掉颜色或把 %XXX 漏成明文,所以只作「提示」。 # 做法:先找出所有合法占位符 %NAME% 的区间,再逐个检查剩下的 %: @@ -300,6 +447,115 @@ function Get-PercentFault([string[]]$Lines) { return $res.ToArray() } +# ---------------------------------------------------------------- 公式 lint +# 五条轻量规则,只作用于 Formula[...] 行。入口先切掉行尾 // 注释: +# 注释里写中文是合法的,代码体里写中文才是错的。 +# 0 公式名 必须匹配 ^[A-Za-z_]+$(错误) +# 2026-10-30 游戏内实测:名字带数字时 %FORMULA名字% 查不到值, +# 占位符原样输出文字、不报错、不连坐其它公式——纯静默失效, +# 所以必须在构建期拦死 +# 1 IF 元数 顶层逗号切参数必须正好 3 个(提示) +# MULTI, 的双参逗号属于变量自身,先整体掩码再切 +# 2 AND/OR 单参 只有 1 个参数(提示)——等价于给表达式加括号,写法冗余 +# 3 公式体污染 非 ASCII / 单等号(应为 ==)/ ~(BETWEEN 只能用在 +# ItemDisplay 条件)→ 错误,构建不通过 +# 4 公式体含 % 错误。同一轮实测:公式之间完全隔离,裸名引用、%FORMULA…% +# 包裹、Alias 宏四条路全不通。% 只对 ItemDisplay 模板生效, +# 写进公式体必然让该条公式编译失败 +# 数「顶层逗号」得出参数个数。注意不要返回数组:PS 里 `return ,$arr` 再被 @() 包裹 +# 会变成嵌套数组,Count 恒为 1(本轮踩过)。直接返回整数最省心。 +function Get-TopLevelArgCount([string]$s) { + if ($s.Trim() -eq '') { return 0 } + $n = 1 + $depth = 0 + foreach ($ch in $s.ToCharArray()) { + if ($ch -eq '(') { $depth++ } + elseif ($ch -eq ')') { $depth-- } + elseif ($ch -eq ',' -and $depth -eq 0) { $n++ } + } + return $n +} + +function Get-FormulaLint([string]$Body, [string]$Name = '') { + $res = New-Object System.Collections.ArrayList + + $code = $Body + $cut = $code.IndexOf('//') + if ($cut -ge 0) { $code = $code.Substring(0, $cut) } + + # ---- 规则 0:公式名只允许字母和下划线(错误) + # 实测:Formula[TESTA] 的 %FORMULATESTA% 正常,Formula[TEST1] 的 %FORMULATEST1% + # 原样输出。名字里带数字会让占位符查不到值,且完全无声。 + if ($Name -ne '' -and $Name -notmatch '^[A-Za-z_]+$') { + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式名 '$Name' 含非法字符(只允许字母和下划线)。带数字会让 %FORMULA$Name% 静默失效" }) + } + + # ---- 规则 4:公式体内不能出现 % (错误) + # 实测:%FORMULATESTA%*3 这类写法编译不过,该公式作废。% 仅对 ItemDisplay 模板生效。 + if ($code.Contains('%')) { + $qp = $code.IndexOf('%') + $qa = [Math]::Max(0, $qp - 16) + $ql = [Math]::Min(36, $code.Length - $qa) + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现 %(公式之间不能互相引用,%FORMULA…% 只在 ItemDisplay 模板里生效):$($code.Substring($qa, $ql).Trim())" }) + } + + # ---- 规则 3:非 ASCII / 单等号 / ~ (错误) + $nonAscii = $null + foreach ($ch in $code.ToCharArray()) { if ([int]$ch -gt 127) { $nonAscii = $ch; break } } + if ($null -ne $nonAscii) { + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现非 ASCII 字符 '$nonAscii'(中文请写在 // 注释里)" }) + } + $eqRe = [regex]'(?=])=(?!=)' + $em = $eqRe.Match($code) + if ($em.Success) { + $a = [Math]::Max(0, $em.Index - 16) + $l = [Math]::Min(36, $code.Length - $a) + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现单等号(比较应写 ==):$($code.Substring($a, $l).Trim())" }) + } + if ($code.Contains('~')) { + $pp = $code.IndexOf('~') + $a = [Math]::Max(0, $pp - 16) + $l = [Math]::Min(36, $code.Length - $a) + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现 ~(BETWEEN 只用于 ItemDisplay 条件,公式请写 >= / <=):$($code.Substring($a, $l).Trim())" }) + } + + # ---- 规则 1 / 2:IF 元数、AND·OR 单参数(提示) + $multiRe = [regex]'(?i)MULTI\d+\s*,\s*\d+' + $callRe = [regex]'(?i)\b(IF|AND|OR)\s*\(' + foreach ($m in $callRe.Matches($code)) { + $fn = $m.Groups[1].Value.ToUpperInvariant() + $op = $m.Index + $m.Length - 1 + $d = 0 + $cl = -1 + for ($k = $op; $k -lt $code.Length; $k++) { + $c = $code[$k] + if ($c -eq '(') { $d++ } + elseif ($c -eq ')') { $d--; if ($d -eq 0) { $cl = $k; break } } + } + if ($cl -lt 0) { + [void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "$fn( 括号未闭合" }) + continue + } + $inner = $code.Substring($op + 1, $cl - $op - 1) + $masked = $multiRe.Replace($inner, 'MULTI_V') + $n = Get-TopLevelArgCount $masked + $a = [Math]::Max(0, $op - 4) + $l = [Math]::Min(52, $code.Length - $a) + $snip = $code.Substring($a, $l).Trim() + if ($fn -eq 'IF') { + if ($n -ne 3) { + [void]$res.Add([pscustomobject]@{ Level = 'W'; Msg = "IF 参数数 $n(应为 3):$snip" }) + } + } elseif ($n -le 1) { + [void]$res.Add([pscustomobject]@{ Level = 'W'; Msg = "$fn 只有 $n 个参数(等价于加括号,建议直接写 (...)):$snip" }) + } + } + + # 不要写 return , $res.ToArray():外层 @() 会得到「嵌套数组」, + # foreach 拿到的元素是空数组,反而每个公式都吐一条空提示(本轮踩过) + return $res.ToArray() +} + # ---------------------------------------------------------------- 自检 function Invoke-SelfCheck([string[]]$Lines, $Mods) { $errors = New-Object System.Collections.ArrayList @@ -318,8 +574,14 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) { $continueCount = 0 $phSeen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) $badPh = @{} + # 以 %别名% 形式出现的名字:别名是条件宏、不是文本占位符,游戏内不会替换成文字。 + # 因此不判「非法占位符」(那是错误),只作提示,避免写错别名导致界面出现裸 %名%。 + $aliasPh = @{} $filtlvl = 0 $trail = New-Object System.Collections.ArrayList + $lintErr = 0 + $lintWarn = 0 + $lintShown = 0 for ($i = 0; $i -lt $Lines.Count; $i++) { $ln = $i + 1 @@ -362,7 +624,23 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) { } } $mf = $formulaRe.Match($s) - if ($mf.Success) { $formulaCount++; [void]$formulaNames.Add($mf.Groups[1].Value) } + if ($mf.Success) { + $formulaCount++ + $fkey = $mf.Groups[1].Value + [void]$formulaNames.Add($fkey) + foreach ($q in @(Get-FormulaLint $mf.Groups[2].Value $fkey)) { + if ($q.Level -eq 'E') { + $lintErr++ + [void]$errors.Add("L$ln 公式 $fkey $($q.Msg)") + } else { + $lintWarn++ + if ($lintShown -lt 12) { + $lintShown++ + [void]$warns.Add("L$ln 公式 $fkey $($q.Msg)") + } + } + } + } $continueCount += ([regex]::Matches($s, '%CONTINUE%')).Count @@ -392,12 +670,40 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) { if ($seenAlias[$k] -gt 1) { [void]$errors.Add("别名重复定义:$k") } } - # 非法占位符 + # 非法占位符(前缀是已定义别名的不算非法:记入 $aliasPh 作提示,见下) + $aliasSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($a in $aliasNames) { [void]$aliasSet.Add($a) } + + # 别名互为子串(v1.8 新增)—— 命名红线:别名名不得是另一别名名的子串 + # 引擎按子串扫描别名表,短名会抢先命中长名,把长名切成「前缀残渣 + 短名」, + # 残渣是无效 token → 整条条件恒假,静默失效、原 check 完全查不出。 + # 实例:裸名 DMG_4_1 是 SHIELD_DMG_4_1 / AMU_DMG_4_1 / MELEE_DMG_4_1 … 的子串。 + $aliasByLen = @($aliasSet | Sort-Object -Property @{Expression={$_.Length}}, @{Expression={$_}}) + for ($i = 0; $i -lt $aliasByLen.Count; $i++) { + $shortName = $aliasByLen[$i] + for ($j = $i + 1; $j -lt $aliasByLen.Count; $j++) { + $longName = $aliasByLen[$j] + if ($longName.Length -le $shortName.Length) { continue } + if ($longName.IndexOf($shortName, [System.StringComparison]::Ordinal) -ge 0) { + [void]$errors.Add("别名互为子串:$shortName 是 $longName 的一部分(短名会抢先命中,长名失效)") + } + } + } foreach ($k in ($badPh.Keys | Sort-Object)) { + if ($aliasSet.Contains($k)) { + $aliasPh[$k] = $badPh[$k] + continue + } $lns = ($badPh[$k] | Select-Object -First 8) -join ',' [void]$errors.Add("非法占位符 %$k%($($badPh[$k].Count) 处,行 $lns)") } + # 别名写成 %别名% 形态:不算错误,但游戏内不会被替换成文字,只会原样显示 %别名% + foreach ($k in ($aliasPh.Keys | Sort-Object)) { + $lns = ($aliasPh[$k] | Select-Object -First 6) -join ',' + [void]$warns.Add("别名以占位符形态出现 %$k%($($aliasPh[$k].Count) 处,行 $lns):别名是条件宏,模板里不会被替换成文本") + } + # 未定义标识(只判别名风格:含下划线或数字) $defined = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($n in $aliasNames) { [void]$defined.Add($n.ToUpperInvariant()) } @@ -450,6 +756,124 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) { } if ($deadCount -gt 12) { [void]$warns.Add("... 另有 $($deadCount - 12) 条死规则") } + # ---------------------------------------------------------------- 编号体系校验(v1.7 新增) + # 三条都是「游戏内静默失效、check 原本查不出」的类型: + # A STAT 编号不在 ItemStatCost → 条件恒为 0,规则永不命中 + # B PREFIX / SUFFIX 编号越界 → 同上 + # C 同条件 + 都带 CONTINUE → 一件物品并排输出多条标题 + # 扫描对象是全部非注释行(不只是 ItemDisplay),这样别名体内的编号也能覆盖到。 + $excelDir = Get-ExcelDir + $statIds = $null + $pfMax = -1 + $sfMax = -1 + if ($null -eq $excelDir) { + [void]$warns.Add('未找到 ItemStatCost.txt / MagicPrefix.txt,STAT 与词缀编号校验已跳过(可用环境变量 PD2_EXCEL_DIR 指定 excel 目录)') + } else { + $mc = Get-ExcelMeta $excelDir 'ItemStatCost.txt' 'Id' + if ($null -ne $mc -and $null -ne $mc.Id) { $statIds = $mc.Id } + $mp = Get-ExcelMeta $excelDir 'MagicPrefix.txt' '' + if ($null -ne $mp) { $pfMax = $mp.Max } + $ms = Get-ExcelMeta $excelDir 'MagicSuffix.txt' '' + if ($null -ne $ms) { $sfMax = $ms.Max } + } + + $statBad = @{} + $pfBad = @{} + $sfBad = @{} + $statN = 0 + if ($null -ne $statIds) { $statN = $statIds.Count } + if ($null -ne $statIds -or $pfMax -ge 0 -or $sfMax -ge 0) { + $pfRe = [regex]'\bPREFIX(?:=(\d+)|~(\d+)-(\d+))' + $sfRe = [regex]'\bSUFFIX(?:=(\d+)|~(\d+)-(\d+))' + for ($i = 0; $i -lt $Lines.Count; $i++) { + $sl = $Lines[$i] + if ($sl.TrimStart().StartsWith('//')) { continue } + if ($null -ne $statIds) { + foreach ($m in [regex]::Matches($sl, '\bSTAT(\d+)\b')) { + $v = [int]$m.Groups[1].Value + if ($statIds.ContainsKey($v)) { continue } + if (-not $statBad.ContainsKey($v)) { $statBad[$v] = New-Object System.Collections.ArrayList } + [void]$statBad[$v].Add($i + 1) + } + } + if ($pfMax -ge 0) { + foreach ($m in $pfRe.Matches($sl)) { + $vs = @() + if ($m.Groups[1].Success) { $vs = @([int]$m.Groups[1].Value) } + else { $vs = @([int]$m.Groups[2].Value, [int]$m.Groups[3].Value) } + foreach ($v in $vs) { + if ($v -le $pfMax) { continue } + if (-not $pfBad.ContainsKey($v)) { $pfBad[$v] = New-Object System.Collections.ArrayList } + [void]$pfBad[$v].Add($i + 1) + } + } + } + if ($sfMax -ge 0) { + foreach ($m in $sfRe.Matches($sl)) { + $vs = @() + if ($m.Groups[1].Success) { $vs = @([int]$m.Groups[1].Value) } + else { $vs = @([int]$m.Groups[2].Value, [int]$m.Groups[3].Value) } + foreach ($v in $vs) { + if ($v -le $sfMax) { continue } + if (-not $sfBad.ContainsKey($v)) { $sfBad[$v] = New-Object System.Collections.ArrayList } + [void]$sfBad[$v].Add($i + 1) + } + } + } + } + foreach ($v in ($statBad.Keys | Sort-Object)) { + $lns = ($statBad[$v] | Select-Object -First 4) -join ',' + [void]$errors.Add("STAT$v 不在 ItemStatCost 中($($statBad[$v].Count) 处,行 $lns)") + } + foreach ($v in ($pfBad.Keys | Sort-Object)) { + $lns = ($pfBad[$v] | Select-Object -First 4) -join ',' + [void]$errors.Add("PREFIX=$v 越界(MagicPrefix 最大编号 $pfMax,$($pfBad[$v].Count) 处,行 $lns)") + } + foreach ($v in ($sfBad.Keys | Sort-Object)) { + $lns = ($sfBad[$v] | Select-Object -First 4) -join ',' + [void]$errors.Add("SUFFIX=$v 越界(MagicSuffix 最大编号 $sfMax,$($sfBad[$v].Count) 处,行 $lns)") + } + } + + # C 同条件 + 都带 CONTINUE → 并排输出多条标题。 + # 与既有两个检查正好互补、零重叠: + # 「完全重复规则」抓 条件+模板全同;「死规则」抓 同条件但前面那条不带 CONTINUE; + # 这里抓 同条件、都带 CONTINUE、模板又不完全相同的那一类(2026-10-30 审计的 02/03 段 + # 7 条就是这种:模板只差一个 %GRAY%,两边都带 CONTINUE,前两个检查都漏)。 + $condMap = @{} + foreach ($r in $rules) { + $k = ($r.Cond -replace '\s+', ' ').Trim().ToUpperInvariant() + if ($k -eq '') { continue } + if (-not $condMap.ContainsKey($k)) { $condMap[$k] = New-Object System.Collections.ArrayList } + [void]$condMap[$k].Add($r) + } + $sameCont = 0 + $colorRe = [regex]('%(' + ($Colors -join '|') + ')%') + foreach ($k in ($condMap.Keys | Sort-Object)) { + $grp = $condMap[$k] + if ($grp.Count -lt 2) { continue } + $allCont = $true + $outs = @{} + $lns = New-Object System.Collections.ArrayList + foreach ($g in $grp) { + if (-not $g.Out.ToUpperInvariant().Contains('%CONTINUE%')) { $allCont = $false; break } + [void]$lns.Add($g.Line) + # 归一化:先剥掉配色占位符再比。判据必须是「只差颜色」—— + # 模板内容不同的绝大多数是**分段拼接**(前一段以「升级后需求:(」收尾、 + # 后一段接数值、末段以 ")}" 闭口;护身符三段说明、地图两行信息同理), + # 那是正常设计,一旦误报就变成 66 组噪音(首版实测)。 + $no = $colorRe.Replace($g.Out, '') + $outs[($no -replace '\s+', ' ').Trim()] = $true + } + if (-not $allCont) { continue } + if ($outs.Count -ge 2) { continue } + $sameCont++ + $cs = $k + if ($cs.Length -gt 70) { $cs = $cs.Substring(0, 70) } + [void]$warns.Add("同条件近似重复 L$($lns -join ',')(模板只差配色、都带 CONTINUE,同一件物品会并排显示两遍):$cs") + } + + # 物品代码白名单 $wl = Get-CodeWhitelist $wlCount = 0 @@ -499,6 +923,92 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) { [void]$warns.Add("Formula 定义后 0 引用:$($fUnused -join '、')") } + # 别名 0 引用(v1.5 新增) + # ★ 别名与公式的引用语法完全不同:别名在条件里是**裸名**(如 `!KNOWN_CODES`、 + # `(SHIELD_SKILL_5_1 OR ...)`),只有公式才是 `%FORMULA名%`。曾经用 `%别名名%` + # 去查别名引用,导致 6 个盾牌星级别名被误判为全死(实际都在 34 模块 L38/L39 使用)。 + # 实现:借用已有的 $tokRe 抓全大写 token 再查字典,避免 480×4470 次正则的开销。 + # 排除:值为裸 TRUE / FALSE 的条目是设置菜单的开关与分组标题(JUST_TAG1~4 之类), + # 本就不需要被任何规则引用。 + $aliasBody = @{} + for ($i = 0; $i -lt $Lines.Count; $i++) { + $ca = $aliasRe.Match($Lines[$i]) + if (-not $ca.Success) { continue } + $ab = $ca.Groups[2].Value + $ac = $ab.IndexOf('//') + if ($ac -ge 0) { $ab = $ab.Substring(0, $ac) } + $aliasBody[$ca.Groups[1].Value] = $ab.Trim() + } + $aliasRef = @{} + for ($i = 0; $i -lt $Lines.Count; $i++) { + $s2 = $Lines[$i] + if ($s2.TrimStart().StartsWith('//')) { continue } + $ca = $aliasRe.Match($s2) + $body2 = $s2 + $selfName = '' + if ($ca.Success) { $body2 = $ca.Groups[2].Value; $selfName = $ca.Groups[1].Value } + $cut2 = $body2.IndexOf('//') + if ($cut2 -ge 0) { $body2 = $body2.Substring(0, $cut2) } + foreach ($tm2 in $tokRe.Matches($body2)) { + $t2 = $tm2.Groups[1].Value + if ($t2 -eq $selfName) { continue } + if ($aliasBody.ContainsKey($t2)) { $aliasRef[$t2] = $true } + } + } + # $tokRe 只认 [A-Z] 开头的 token。名字以数字或下划线开头的别名(现存 `7CR_INFUSION`) + # 永远抓不到,会被误判成 0 引用——这里对这少数几个名字单独补扫一遍。 + $oddNames = @($aliasBody.Keys | Where-Object { -not ($_ -cmatch '^[A-Z][A-Z0-9_]+$') }) + if ($oddNames.Count -gt 0) { + for ($i = 0; $i -lt $Lines.Count; $i++) { + $s3 = $Lines[$i] + if ($s3.TrimStart().StartsWith('//')) { continue } + $ca3 = $aliasRe.Match($s3) + $b3 = if ($ca3.Success) { $ca3.Groups[2].Value } else { $s3 } + $c3 = $b3.IndexOf('//') + if ($c3 -ge 0) { $b3 = $b3.Substring(0, $c3) } + foreach ($on in $oddNames) { + if ($ca3.Success -and $ca3.Groups[1].Value -eq $on) { continue } + $pat3 = '(?