1607 lines
80 KiB
PowerShell
1607 lines
80 KiB
PowerShell
#requires -Version 5.1
|
||
<#
|
||
BadWolf 过滤器构建脚本 build.ps1 v1.11
|
||
------------------------------------------------------------------
|
||
用途:把模块目录下的 txt 按文件名排序拼接成 BadWolf-LootFilter.txt,
|
||
并完成一致性校验与行号换算。纯 PowerShell,无需安装任何软件。
|
||
|
||
v1.11 相对 v1.10 的变化(2026-11-01)
|
||
------------------------------------------------------------------
|
||
· 自检新增「版本说明每行宽度」校验 → 「提示」。
|
||
`00` 模块的 `ItemDisplay[box]` 版本说明是整块提示文本,**靠 `%CL%` 逐行
|
||
断行**;写成 `%NL%%NL%%NAME%` 会把「BADWOLF FILTER…/ 当前过滤等级」整段
|
||
重复打印一次(2026-11-01 实测,8 段炸成 8 个块)。游戏里一行放不下不会
|
||
自动折行,而是**溢出到框外被裁掉**(实测 37 宽就开始丢字)。故按
|
||
`%CL%` / `%NL%` 切行、剔除 `%XXX%` 控制码后逐行判宽,上限 **72**
|
||
(=36 汉字宽)。
|
||
· `V2.10` 那条概要原来是一整段 312 宽,已按本规则切成 8 行(每行 ≤50 宽)。
|
||
|
||
v1.10 相对 v1.9 的变化(2026-11-01)
|
||
------------------------------------------------------------------
|
||
· 「菜单按钮文字长度」改口径为**显示宽度**:汉字/全角记 2,字母·数字·
|
||
符号各记 1;按钮 X ≤ 20(=10 汉字宽),分组名 Y ≤ 24(=12 汉字宽)。
|
||
v1.9 按「汉字个数」统计是偏松近似——ASCII 较多的按钮实际宽度越界也
|
||
查不出来。实测 108 个按钮中仅 `00` L136 那条越界(65 宽),已按裁定放行。
|
||
· 两条提示加入**已裁定放行名单**(校验本身保留,只放行具体条目):
|
||
1)「同条件近似重复」02/03 模块的 7 组(UNI/SET/RARE/MAG/CRAFT/RW 与
|
||
NMAG 那组)—— 2026-11-01 现场确认为误报;
|
||
2)「按钮文字超长」`00` 模块 29 汉字的 JUST_TAG4 说明按钮 —— 有意写长。
|
||
放行判据是「条件 + 规则所在模块」,其它模块出现同类问题照常提示。
|
||
· 物品代码白名单 `item_codes.txt` 迁到 `tools\` 子目录。业务目录
|
||
(filters\Local)顶层除 `本地过滤器存放目录.txt` 外的每个 .txt 都会被
|
||
BH 菜单当成**独立过滤器**列出,item_codes.txt 混在顶层属误识别。
|
||
脚本按 tools\ → 旧位置(同目录)顺序探测,兼容未迁移的旧布局。
|
||
· 新增「业务目录顶层杂项 txt」提示:顶层出现除合并版与
|
||
`本地过滤器存放目录.txt` 以外的 .txt 就提示,防止再次被误识别。
|
||
|
||
v1.9 相对 v1.8 的变化
|
||
------------------------------------------------------------------
|
||
· 自检新增「菜单按钮文字长度」校验 → 「提示」。
|
||
`00` 模块里 `//@X@` 的 X 是 BH 界面上的按钮文字,上限 10 个汉字;
|
||
分组写法 `//@X@Y@` 的 Y 上限 12 个汉字。超长会让菜单显示异常,而游戏
|
||
不报错、只在界面上变形,属「肉眼才能发现」的一类。
|
||
口径按**汉字数**统计,不是字符总数:`屏蔽ILVL<68亮金珠宝` 共 13 个字符
|
||
但只有 6 个汉字,合规;`显示无孔鬼魂尖枪(有形/无形均可)` 17 个汉字
|
||
才是真超标(2026-10-31 实际事故,用户手工改成 `@显示无孔鬼魂尖枪@(…)`)。
|
||
结论口径:补充说明一律写在第二个 `@` 之后,不进按钮文字。
|
||
|
||
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> 而 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<n>,<l> 的
|
||
双参逗号是变量自身的一部分,先掩码再切,不算分隔符。
|
||
不满足则「提示」(build 不中断)。
|
||
2) AND/OR 单参 —— 只有一个参数时等价于给表达式加括号,写法冗余;
|
||
「提示」。
|
||
3) 公式体污染 —— 出现非 ASCII / 单等号(应为 ==)/ ~(BETWEEN 只能
|
||
用在 ItemDisplay 条件)→ 直接「错误」,构建不通过。
|
||
· 第 4 节自检摘要新增一行「公式 lint 错误/提示」计数。
|
||
· build 在写盘前先跑同样的错误级检查:有 lint 错误就中止,不会把
|
||
错公式写进合并版(提示级不阻断)。
|
||
|
||
v1.2 相对 v1.1 的变化
|
||
------------------------------------------------------------------
|
||
· 自检新增:孤立 % / 未闭合 % 扫描(半截百分号会吞颜色或漏出明文)
|
||
判定:先整体屏蔽合法占位符 %NAME%,剩下的 % 只放行四种写字面百分号的情形
|
||
(数值后 40%ED / 右括号后 [4-6]%物理减伤 / 夹在两个占位符之间 /
|
||
「物理减伤%」「魔法减伤%」后的百分比示意符)
|
||
|
||
v1.1 相对 v1.0 的变化
|
||
------------------------------------------------------------------
|
||
· 模块白名单:只拼 NN-*.txt,其它 txt 一律跳过并黄字警告(不再静默混入)
|
||
· 备份轮转:build 只保留最近 2 份 .bak,可用 -KeepBackups N 调整
|
||
· 临时文件:写盘失败也会清理 .tmp
|
||
· 自检新增:物品代码白名单(item_codes.txt)/ 死规则 / Formula 引用完整性
|
||
· 版本行联动:版本行日期落后于最晚模块改动时间时提示
|
||
|
||
用法(二选一)
|
||
------------------------------------------------------------------
|
||
A. 双击 build.bat → 直接校验(窗口不关)
|
||
B. 命令行:
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 build
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 map 1939
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 map 16:139
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 export
|
||
|
||
命令
|
||
------------------------------------------------------------------
|
||
check 校验(默认,不写任何文件)
|
||
build 重建合并版(自动备份 .bak,只保留最近 2 份;-NoBackup 关闭备份)
|
||
map 行号换算: 1939(合并→模块) 或 16:139(模块→合并)
|
||
export 导出行号映射表
|
||
|
||
参数
|
||
------------------------------------------------------------------
|
||
-NoBackup build 时不备份原文件
|
||
-KeepBackups N 备份保留份数,默认 2
|
||
-Strict 有提示项也算不通过(退出码 1)
|
||
|
||
退出码:0 一致 / 1 有问题 / 2 环境问题
|
||
#>
|
||
param(
|
||
[Parameter(Position = 0)][string]$Command = 'check',
|
||
[Parameter(Position = 1)][string]$Arg = '',
|
||
[switch]$NoBackup,
|
||
[switch]$Strict,
|
||
[int]$KeepBackups = 2
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
# ---------------------------------------------------------------- 基础路径
|
||
$Root = $PSScriptRoot
|
||
$MergedName = 'BadWolf-LootFilter.txt'
|
||
$MergedPath = Join-Path $Root $MergedName
|
||
$MapName = [char]0x884C + [char]0x53F7 + [char]0x6620 + [char]0x5C04 + [char]0x8868 + '.md'
|
||
|
||
function Write-Line([string]$s) { Write-Host $s }
|
||
function Format-N([long]$n) { return $n.ToString('N0', [System.Globalization.CultureInfo]::InvariantCulture) }
|
||
function Get-Bar { return ('=' * 74) }
|
||
function Write-Head([string]$t) { Write-Host ""; Write-Host "[$t]"; Write-Host ('-' * 74) }
|
||
|
||
function Get-Md5Hex([byte[]]$Bytes) {
|
||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||
try { $h = $md5.ComputeHash($Bytes) } finally { $md5.Dispose() }
|
||
return (-join ($h | ForEach-Object { $_.ToString('x2') }))
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 模块目录
|
||
# 只认「≥10 个 数字-xxx.txt」的目录;多个候选直接报错,绝不猜
|
||
function Get-ModuleDir {
|
||
$cands = @()
|
||
foreach ($d in @(Get-ChildItem -LiteralPath $Root -Directory -ErrorAction SilentlyContinue)) {
|
||
$n = @(Get-ChildItem -LiteralPath $d.FullName -Filter '*.txt' -File -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Name -match '^\d{2}-' }).Count
|
||
if ($n -gt 0) { $cands += [pscustomobject]@{ Path = $d.FullName; Count = $n } }
|
||
}
|
||
$good = @($cands | Where-Object { $_.Count -ge 10 } | Sort-Object -Descending Count)
|
||
|
||
# 兼容「模块与脚本平铺在同一目录」的布局(仓库里可能没有子文件夹)
|
||
$flat = @(Get-ChildItem -LiteralPath $Root -Filter '*.txt' -File -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Name -match '^\d{2}-' }).Count
|
||
if ($flat -ge 10) { return $Root }
|
||
if ($good.Count -eq 0) {
|
||
$tip = ''
|
||
if ($cands.Count -gt 0) {
|
||
$tip = ';候选目录(文件数不足):' + (($cands | ForEach-Object { "$($_.Path)($($_.Count)个)" }) -join ',')
|
||
}
|
||
throw "找不到模块目录:$Root 下没有包含 ≥10 个 `00-xxx.txt` 形式文件的文件夹$tip"
|
||
}
|
||
if ($good.Count -gt 1) {
|
||
$list = ($good | ForEach-Object { "$($_.Path)($($_.Count) 个)" }) -join ';'
|
||
throw "检测到多个可能的模块目录,为避免用错目录构建,已停止:$list。请把多余的移走或改名。"
|
||
}
|
||
return $good[0].Path
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 载入模块
|
||
# 白名单:只认「NN-名称.txt」;名字里带 副本/备份/copy/bak/(1) 的视为复制品,同样跳过
|
||
$script:SkippedModules = @()
|
||
$CopyNameRe = '副本|备份|复制|copy|bak|backup|\(\d+\)'
|
||
|
||
function Get-Modules([string]$Dir) {
|
||
$allTxt = @(Get-ChildItem -LiteralPath $Dir -Filter '*.txt' -File)
|
||
$files = @($allTxt | Where-Object {
|
||
$_.Name -match '^\d{2}-.*\.txt$' -and $_.Name -notmatch $CopyNameRe
|
||
} | Sort-Object Name)
|
||
$script:SkippedModules = @($allTxt | Where-Object {
|
||
-not ($_.Name -match '^\d{2}-.*\.txt$') -or ($_.Name -match $CopyNameRe)
|
||
} | Sort-Object Name | ForEach-Object { $_.Name })
|
||
$list = New-Object System.Collections.ArrayList
|
||
$cursor = 1
|
||
$strict = [System.Text.UTF8Encoding]::new($false, $true)
|
||
|
||
foreach ($f in $files) {
|
||
$bytes = [System.IO.File]::ReadAllBytes($f.FullName)
|
||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||
$errors = New-Object System.Collections.ArrayList
|
||
|
||
try { [void]$strict.GetString($bytes) }
|
||
catch { [void]$errors.Add('不是合法的 UTF-8 编码') }
|
||
|
||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||
[void]$errors.Add('含 UTF-8 BOM')
|
||
}
|
||
$lfOnly = ([regex]::Matches($text, "(?<!\r)\n")).Count
|
||
if ($lfOnly -gt 0) { [void]$errors.Add("存在裸 LF 换行($lfOnly 处)") }
|
||
if ($bytes.Length -eq 0) { [void]$errors.Add('空文件') }
|
||
elseif ($bytes[$bytes.Length - 1] -ne 10) { [void]$errors.Add('文件末尾缺少换行') }
|
||
|
||
$lineCount = ([regex]::Matches($text, "\n")).Count
|
||
|
||
$m = [pscustomobject]@{
|
||
Name = $f.Name
|
||
Code = if ($f.Name.Length -ge 2) { $f.Name.Substring(0, 2) } else { '??' }
|
||
Path = $f.FullName
|
||
Bytes = $bytes
|
||
ByteLen = $bytes.Length
|
||
Text = $text
|
||
LineCount = $lineCount
|
||
Start = $cursor
|
||
MTime = $f.LastWriteTime
|
||
Errors = $errors
|
||
}
|
||
$m | Add-Member -MemberType ScriptProperty -Name End -Value { $this.Start + $this.LineCount - 1 }
|
||
[void]$list.Add($m)
|
||
$cursor += $lineCount
|
||
}
|
||
return , $list.ToArray()
|
||
}
|
||
|
||
function Get-Lines([string]$Text) {
|
||
$a = $Text -split "\r?\n"
|
||
if ($a.Count -gt 0 -and $a[-1] -eq '') {
|
||
if ($a.Count -eq 1) { return @() }
|
||
return $a[0..($a.Count - 2)]
|
||
}
|
||
return $a
|
||
}
|
||
|
||
function Find-ModuleOfLine($Mods, [int]$Line) {
|
||
foreach ($m in $Mods) { if ($Line -ge $m.Start -and $Line -le $m.End) { return $m } }
|
||
return $null
|
||
}
|
||
|
||
function Join-Bytes($Mods) {
|
||
$ms = New-Object System.IO.MemoryStream
|
||
try {
|
||
foreach ($m in $Mods) { $ms.Write($m.Bytes, 0, $m.ByteLen) }
|
||
return , $ms.ToArray()
|
||
} finally { $ms.Dispose() }
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 白名单
|
||
$Colors = @('BLACK','WHITE','GRAY','LIGHT_GRAY','DARK_GRAY','RED','DARK_RED','ORANGE','GOLD',
|
||
'YELLOW','GREEN','DARK_GREEN','SAGE','TEAL','BLUE','DARK_BLUE','PURPLE','CORAL',
|
||
'TAN','LIGHT_BLUE','VIOLET')
|
||
$Special = @('NAME','CL','NL','CONTINUE')
|
||
$InfoCodes = @('ED','EDAM','EDEF','RES','FRES','CRES','LRES','PRES','IAS','FCR','MFIND','LIFE',
|
||
'STR','DEX','AR','RANGE','PRICE','QTY','GEMTYPE','SOCKETS','MAXSOCKETS','BASENAME',
|
||
'BASEMINKICK','BASEMAXKICK','CRAFTALVL','REROLLALVL','UPLVL','UPSTR','UPDEX',
|
||
'WPNSPD','MAXDMG','ITEMCODE')
|
||
$PhSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
||
foreach ($x in @($Colors + $Special + $InfoCodes)) { [void]$PhSet.Add($x) }
|
||
$NumPrefix = @('SK','STAT','CHARSTAT','TABSK','FORMULA')
|
||
$KwPrefix = @('SK','STAT','CHARSTAT','TABSK','MULTI','CLSK','FORMULA')
|
||
$IgnoreUndef = @()
|
||
$MaxLineLen = 1024
|
||
|
||
# ---------------------------------------------------------------- 已裁定放行(v1.10)
|
||
# 只放行**具体条目**,不关闭整条校验:其它模块 / 其它按钮出现同类问题照常提示。
|
||
$SameContAcceptedConds = @(
|
||
'UNI', 'SET', 'RARE', 'MAG', 'CRAFT', 'RW',
|
||
'(ARMOR OR WEAPON OR QUIVER OR MAPTIER>0) NMAG !RW !INF'
|
||
)
|
||
$SameContAcceptedMods = @('02', '03') # 只放行这两个模块里的这 7 组
|
||
$ButtonAccepted = @(
|
||
'说明:使用重置开关重新加载本地配置;点击下拉框[V]以切换至其它页面'
|
||
)
|
||
$DirPlaceholderTxt = '本地过滤器存放目录.txt' # 业务目录顶层唯一允许的占位 txt
|
||
|
||
# 显示宽度:汉字/全角记 2,字母·数字·符号各记 1(BH 菜单按此宽度排版)
|
||
function Get-Display-Width([string]$s) {
|
||
if ([string]::IsNullOrEmpty($s)) { return 0 }
|
||
$w = 0
|
||
foreach ($ch in $s.ToCharArray()) {
|
||
$cp = [int]$ch
|
||
$wide =
|
||
($cp -ge 0x1100 -and $cp -le 0x115F) -or # 韩文字母
|
||
($cp -ge 0x2E80 -and $cp -le 0xA4CF) -or # CJK 部首/汉字/假名/注音
|
||
($cp -ge 0xAC00 -and $cp -le 0xD7A3) -or # 韩文音节
|
||
($cp -ge 0xF900 -and $cp -le 0xFAFF) -or # CJK 兼容汉字
|
||
($cp -ge 0xFE30 -and $cp -le 0xFE6F) -or # CJK 兼容形式
|
||
($cp -ge 0xFF00 -and $cp -le 0xFF60) -or # 全角 ASCII
|
||
($cp -ge 0xFFE0 -and $cp -le 0xFFE6) # 全角符号
|
||
if ($wide) { $w += 2 } else { $w += 1 }
|
||
}
|
||
return $w
|
||
}
|
||
|
||
function Test-Placeholder([string]$name) {
|
||
if ($PhSet.Contains($name)) { return $true }
|
||
# 带连字符的显示宏(图标/边框/音效/层级):只认合法形态,写错即报非法占位符
|
||
if ($name -match '^(MAP|DOT|BG|BORDER|PX|PY)-[0-9A-Fa-f]{2}$') { return $true }
|
||
if ($name -match '^SOUNDID-\d+$') { return $true }
|
||
if ($name -match '^TIER-[1-5]$') { return $true }
|
||
foreach ($p in $NumPrefix) {
|
||
if (-not $name.StartsWith($p, [System.StringComparison]::Ordinal)) { continue }
|
||
$tail = $name.Substring($p.Length)
|
||
if ($tail -eq '') { return $true }
|
||
if ($tail -match '^\d+$') { return $true }
|
||
if ($p -eq 'FORMULA' -and $tail -match '^[A-Z0-9_]+$') { return $true }
|
||
}
|
||
return $false
|
||
}
|
||
|
||
function Test-KeywordToken([string]$t) {
|
||
foreach ($p in $KwPrefix) {
|
||
if (-not $t.StartsWith($p, [System.StringComparison]::Ordinal)) { continue }
|
||
$tail = $t.Substring($p.Length)
|
||
if ($tail -eq '') { return $true }
|
||
if ($tail -match '^\d+$') { return $true }
|
||
if ($p -eq 'FORMULA' -and $tail -match '^[A-Z0-9_]+$') { return $true }
|
||
}
|
||
return $false
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 物品代码白名单
|
||
# item_codes.txt 由 data\global\excel 生成:[codes] 物品代码 / [types] 类型代码。
|
||
# v1.10 起优先读 tools\item_codes.txt:业务目录顶层只能有合并版与
|
||
# 本地过滤器存放目录.txt,其它 .txt 会被 BH 菜单当成独立过滤器。
|
||
# 旧位置(同目录)仍兼容,便于仓库/他人 clone 的扁平布局。
|
||
# 文件不存在则跳过该项检查(不视为错误)。
|
||
function Get-CodeWhitelist {
|
||
$p = $null
|
||
foreach ($cand in @((Join-Path $Root 'tools\item_codes.txt'),
|
||
(Join-Path $Root 'item_codes.txt'),
|
||
(Join-Path $Root 'build\item_codes.txt'))) {
|
||
if (Test-Path -LiteralPath $cand) { $p = $cand; break }
|
||
}
|
||
if (-not $p) { return $null }
|
||
$set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||
foreach ($l in [System.IO.File]::ReadAllLines($p, [System.Text.Encoding]::UTF8)) {
|
||
$t = $l.Trim()
|
||
if ($t -eq '') { continue }
|
||
if ($t.StartsWith('#') -or $t.StartsWith('[')) { continue }
|
||
[void]$set.Add($t)
|
||
}
|
||
if ($set.Count -eq 0) { return $null }
|
||
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% 的区间,再逐个检查剩下的 %:
|
||
# 放行 前一位是数字 40%ED、/35%%TAN%、10%移动速度
|
||
# 放行 前一位是 ] ) / - [4-6]%物理减伤、+[50-80]% 增强伤害
|
||
# 放行 前一位是占位符结尾,且后一位是占位符开头 / } / ] %EDEF%%%GRAY%、%STAT258%%}
|
||
# 放行 前四位是「物理减伤」「魔法减伤」 [4-6]%物理减伤% ← 百分比减伤的专用示意符,
|
||
# 刻意与数值减伤 [2-5]物理减伤 区分(2026-10-28 用户定义,不要删、不要报)
|
||
# 报出 其余全部(%GRAY%%Steel( 缺右侧、抽动挣扎%%CL% 多一个、神蛇之羽CL% 缺左侧)
|
||
function Get-PercentFault([string[]]$Lines) {
|
||
$phMask = [regex]'%[A-Za-z][A-Za-z0-9_\-]*%'
|
||
$nextNameRe = [regex]'^([A-Za-z][A-Za-z0-9_\-]*)'
|
||
$prevNameRe = [regex]'([A-Za-z][A-Za-z0-9_\-]*)$'
|
||
$res = New-Object System.Collections.ArrayList
|
||
|
||
for ($i = 0; $i -lt $Lines.Count; $i++) {
|
||
$s = $Lines[$i]
|
||
if (-not $s.Contains('%')) { continue }
|
||
if ($s.TrimStart().StartsWith('//')) { continue }
|
||
# 行尾 // 注释里的 % 不参与显示,先切掉
|
||
$cut = $s.IndexOf('//')
|
||
$body = if ($cut -ge 0) { $s.Substring(0, $cut) } else { $s }
|
||
if (-not $body.Contains('%')) { continue }
|
||
|
||
$inPh = [System.Collections.Generic.HashSet[int]]::new()
|
||
$phStart = [System.Collections.Generic.HashSet[int]]::new()
|
||
$phEnd = [System.Collections.Generic.HashSet[int]]::new()
|
||
foreach ($mm in $phMask.Matches($body)) {
|
||
[void]$phStart.Add($mm.Index)
|
||
[void]$phEnd.Add($mm.Index + $mm.Length - 1)
|
||
for ($k = $mm.Index; $k -lt ($mm.Index + $mm.Length); $k++) { [void]$inPh.Add($k) }
|
||
}
|
||
|
||
$j = $body.IndexOf('%')
|
||
while ($j -ge 0) {
|
||
if ($inPh.Contains($j)) { $j = $body.IndexOf('%', $j + 1); continue }
|
||
$prev = if ($j -gt 0) { $body[$j - 1] } else { [char]0 }
|
||
$next = if ($j + 1 -lt $body.Length) { $body[$j + 1] } else { [char]0 }
|
||
|
||
$ok = $false
|
||
if ([char]::IsDigit($prev)) { $ok = $true }
|
||
elseif ('])/-'.IndexOf($prev) -ge 0) { $ok = $true }
|
||
elseif ($phEnd.Contains($j - 1) -and ($phStart.Contains($j + 1) -or $next -eq '}' -or $next -eq ']')) { $ok = $true }
|
||
elseif ($j -ge 4 -and ($body.Substring($j - 4, 4) -eq '物理减伤' -or $body.Substring($j - 4, 4) -eq '魔法减伤')) { $ok = $true }
|
||
|
||
if (-not $ok) {
|
||
$kind = '孤立 %'
|
||
$nw = $nextNameRe.Match($body.Substring($j + 1))
|
||
$pw = $prevNameRe.Match($body.Substring(0, $j))
|
||
if ($pw.Success -and (Test-Placeholder $pw.Groups[1].Value)) { $kind = '缺左侧 %' }
|
||
elseif ($nw.Success) { $kind = '缺右侧 %' }
|
||
elseif ($phStart.Contains($j + 1) -or $next -eq '%') { $kind = '多余 %' }
|
||
$a = [Math]::Max(0, $j - 16)
|
||
$len = [Math]::Min(38, $body.Length - $a)
|
||
$snip = $body.Substring($a, $len)
|
||
[void]$res.Add([pscustomobject]@{ Line = $i + 1; Kind = $kind; Snippet = $snip })
|
||
}
|
||
$j = $body.IndexOf('%', $j + 1)
|
||
}
|
||
}
|
||
return $res.ToArray()
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 公式 lint
|
||
# 五条轻量规则,只作用于 Formula[...] 行。入口先切掉行尾 // 注释:
|
||
# 注释里写中文是合法的,代码体里写中文才是错的。
|
||
# 0 公式名 必须匹配 ^[A-Za-z_]+$(错误)
|
||
# 2026-10-30 游戏内实测:名字带数字时 %FORMULA名字% 查不到值,
|
||
# 占位符原样输出文字、不报错、不连坐其它公式——纯静默失效,
|
||
# 所以必须在构建期拦死
|
||
# 1 IF 元数 顶层逗号切参数必须正好 3 个(提示)
|
||
# MULTI<n>,<l> 的双参逗号属于变量自身,先整体掩码再切
|
||
# 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
|
||
$warns = New-Object System.Collections.ArrayList
|
||
|
||
$ruleRe = [regex]'^ItemDisplay\[([^\]]*)\]:(.*)$'
|
||
$aliasRe = [regex]'^Alias\[([^\]]+)\]:(.*)$'
|
||
$formulaRe = [regex]'^Formula\[([^\]]+)\]:(.*)$'
|
||
$phRe = [regex]'%([A-Za-z][A-Za-z0-9_\-]*)%'
|
||
$tokRe = [regex]'(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,})(?![A-Za-z0-9_])'
|
||
|
||
$rules = New-Object System.Collections.ArrayList
|
||
$aliasNames = New-Object System.Collections.ArrayList
|
||
$formulaNames = New-Object System.Collections.ArrayList
|
||
$formulaCount = 0
|
||
$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
|
||
$s = $Lines[$i]
|
||
|
||
$mr = $ruleRe.Match($s)
|
||
if ($mr.Success) {
|
||
$cond = $mr.Groups[1].Value
|
||
$out = $mr.Groups[2].Value
|
||
[void]$rules.Add([pscustomobject]@{ Line = $ln; Cond = $cond; Out = $out })
|
||
if ($cond.Contains('FILTLVL')) { $filtlvl++ }
|
||
if ($cond.Contains('(') -or $cond.Contains(')')) {
|
||
$open = ([regex]::Matches($cond, '\(')).Count
|
||
$close = ([regex]::Matches($cond, '\)')).Count
|
||
if ($open -ne $close) {
|
||
[void]$errors.Add("L$ln 条件括号不平衡(( $open / ) $close):$cond")
|
||
}
|
||
}
|
||
}
|
||
$ma = $aliasRe.Match($s)
|
||
if ($ma.Success) {
|
||
$anm = $ma.Groups[1].Value
|
||
[void]$aliasNames.Add($anm)
|
||
$abody = $ma.Groups[2].Value
|
||
$acut = $abody.IndexOf('//')
|
||
if ($acut -ge 0) { $abody = $abody.Substring(0, $acut) }
|
||
$aopen = ([regex]::Matches($abody, '\(')).Count
|
||
$aclose = ([regex]::Matches($abody, '\)')).Count
|
||
if ($aopen -ne $aclose) {
|
||
[void]$errors.Add("L$ln 别名 $anm 条件括号不平衡(( $aopen / ) $aclose):$abody")
|
||
}
|
||
if ($abody -match '\)(?=[A-Za-z0-9!])') {
|
||
[void]$errors.Add("L$ln 别名 $anm 右括号后粘连(缺空格):$abody")
|
||
}
|
||
if ($abody -match '\dOR\b') {
|
||
[void]$errors.Add("L$ln 别名 $anm 数字与 OR 粘连:$abody")
|
||
}
|
||
if ($abody -match '=~(?![0-9])') {
|
||
[void]$errors.Add("L$ln 别名 $anm 非法比较符 =~:$abody")
|
||
}
|
||
}
|
||
$mf = $formulaRe.Match($s)
|
||
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
|
||
|
||
foreach ($pm in $phRe.Matches($s)) {
|
||
$nm = $pm.Groups[1].Value
|
||
[void]$phSeen.Add($nm)
|
||
if (-not (Test-Placeholder $nm)) {
|
||
if (-not $badPh.ContainsKey($nm)) { $badPh[$nm] = New-Object System.Collections.ArrayList }
|
||
[void]$badPh[$nm].Add($ln)
|
||
}
|
||
}
|
||
|
||
if ($s -ne $s.TrimEnd()) { [void]$trail.Add($ln) }
|
||
if ($s.Length -gt $MaxLineLen) { [void]$warns.Add("L$ln 超长($($s.Length) 字符)") }
|
||
}
|
||
|
||
# 孤立 % / 未闭合 %
|
||
$pctFaults = @(Get-PercentFault $Lines)
|
||
foreach ($f in $pctFaults) {
|
||
[void]$warns.Add("$($f.Kind) L$($f.Line):$($f.Snippet)")
|
||
}
|
||
|
||
# 别名重复
|
||
$seenAlias = @{}
|
||
foreach ($n in $aliasNames) { if ($seenAlias.ContainsKey($n)) { $seenAlias[$n]++ } else { $seenAlias[$n] = 1 } }
|
||
foreach ($k in ($seenAlias.Keys | Sort-Object)) {
|
||
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()) }
|
||
$refs = @{}
|
||
foreach ($r in $rules) {
|
||
foreach ($tm in $tokRe.Matches($r.Cond)) {
|
||
$t = $tm.Groups[1].Value
|
||
if (-not $refs.ContainsKey($t)) { $refs[$t] = New-Object System.Collections.ArrayList }
|
||
[void]$refs[$t].Add($r.Line)
|
||
}
|
||
}
|
||
foreach ($t in ($refs.Keys | Sort-Object)) {
|
||
if ($defined.Contains($t)) { continue }
|
||
if ($IgnoreUndef -contains $t) { continue }
|
||
if (Test-KeywordToken $t) { continue }
|
||
if ($t -match '[_\d]') {
|
||
$lns = ($refs[$t] | Select-Object -First 4) -join ','
|
||
[void]$warns.Add("未定义标识 $t(L$lns)")
|
||
}
|
||
}
|
||
|
||
# 完全重复规则
|
||
$dupMap = @{}
|
||
foreach ($r in $rules) {
|
||
$k = (($r.Cond -replace '\s+', ' ').Trim().ToLowerInvariant()) + '|' + (($r.Out -replace '\s+', ' ').Trim())
|
||
if ($dupMap.ContainsKey($k)) { $dupMap[$k] += ",$($r.Line)" } else { $dupMap[$k] = "$($r.Line)" }
|
||
}
|
||
foreach ($k in $dupMap.Keys) {
|
||
if ($dupMap[$k] -match ',') {
|
||
$c = $k.Split('|')[0]
|
||
if ($c.Length -gt 70) { $c = $c.Substring(0, 70) }
|
||
[void]$warns.Add("完全重复规则 L$($dupMap[$k]):$c")
|
||
}
|
||
}
|
||
|
||
# 死规则:同一条件重复出现,且更早的一条没有 %CONTINUE% → 后一条永不命中
|
||
$stopAt = @{}
|
||
$deadCount = 0
|
||
foreach ($r in $rules) {
|
||
$k = ($r.Cond -replace '\s+', ' ').Trim().ToUpperInvariant()
|
||
if ($k -eq '') { continue }
|
||
if ($stopAt.ContainsKey($k)) {
|
||
$deadCount++
|
||
if ($deadCount -le 12) {
|
||
[void]$warns.Add("死规则 L$($r.Line) 永不命中(同条件 L$($stopAt[$k]) 未带 CONTINUE,已终结判定):$($r.Cond)")
|
||
}
|
||
} elseif (-not $r.Out.ToUpperInvariant().Contains('%CONTINUE%')) {
|
||
$stopAt[$k] = $r.Line
|
||
}
|
||
}
|
||
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 }
|
||
# 已裁定放行(v1.10):条件命中名单,且组内每条规则都落在 02/03 模块
|
||
# → 这 7 组是现场确认的误报,不再提示;其它模块的同名条件照常提示。
|
||
if ($SameContAcceptedConds -contains $k) {
|
||
$allInAcceptedMods = $true
|
||
foreach ($g in $grp) {
|
||
$mm = Find-ModuleOfLine $Mods $g.Line
|
||
if ($null -eq $mm -or ($SameContAcceptedMods -notcontains $mm.Code)) {
|
||
$allInAcceptedMods = $false
|
||
break
|
||
}
|
||
}
|
||
if ($allInAcceptedMods) { 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
|
||
if ($null -eq $wl) {
|
||
[void]$warns.Add('未找到 item_codes.txt,跳过物品代码校验')
|
||
} else {
|
||
$wlCount = $wl.Count
|
||
$codeRe = [regex]'^[a-z][a-z0-9]{1,5}$'
|
||
$badCode = @{}
|
||
foreach ($r in $rules) {
|
||
$c = [regex]::Replace($r.Cond, '%[^%]*%', ' ')
|
||
$c = $c.Replace('(', ' ').Replace(')', ' ').Replace('!', ' ')
|
||
foreach ($t in ($c -split '\s+')) {
|
||
if ($t -eq '') { continue }
|
||
if (-not $codeRe.IsMatch($t)) { continue }
|
||
if ($wl.Contains($t)) { continue }
|
||
if ($defined.Contains($t.ToUpperInvariant())) { continue }
|
||
if (-not $badCode.ContainsKey($t)) { $badCode[$t] = New-Object System.Collections.ArrayList }
|
||
[void]$badCode[$t].Add($r.Line)
|
||
}
|
||
}
|
||
foreach ($k in ($badCode.Keys | Sort-Object)) {
|
||
$lns = ($badCode[$k] | Select-Object -First 4) -join ','
|
||
[void]$errors.Add("疑似无效物品代码 $k($($badCode[$k].Count) 处,行 $lns)")
|
||
}
|
||
}
|
||
|
||
# Formula 引用完整性
|
||
$fDef = @{}
|
||
foreach ($n in $formulaNames) { $fDef[$n] = $true }
|
||
$fRef = @{}
|
||
for ($i = 0; $i -lt $Lines.Count; $i++) {
|
||
if ($formulaRe.IsMatch($Lines[$i])) { continue }
|
||
foreach ($fm in [regex]::Matches($Lines[$i], 'FORMULA([A-Za-z0-9_]+)')) {
|
||
$n = $fm.Groups[1].Value
|
||
if (-not $fRef.ContainsKey($n)) { $fRef[$n] = New-Object System.Collections.ArrayList }
|
||
[void]$fRef[$n].Add($i + 1)
|
||
}
|
||
}
|
||
foreach ($n in ($fRef.Keys | Sort-Object)) {
|
||
if ($fDef.ContainsKey($n)) { continue }
|
||
$lns = ($fRef[$n] | Select-Object -First 4) -join ','
|
||
[void]$errors.Add("Formula 引用无定义:FORMULA$n($($fRef[$n].Count) 处,行 $lns)")
|
||
}
|
||
$fUnused = @($formulaNames | Where-Object { -not $fRef.ContainsKey($_) } | Sort-Object)
|
||
if ($fUnused.Count -gt 0) {
|
||
[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 = '(?<![A-Za-z0-9_])' + [regex]::Escape($on) + '(?![A-Za-z0-9_])'
|
||
if ([regex]::IsMatch($b3, $pat3)) { $aliasRef[$on] = $true }
|
||
}
|
||
}
|
||
}
|
||
|
||
$aUnused = @()
|
||
foreach ($an in ($aliasBody.Keys | Sort-Object)) {
|
||
if ($aliasRef.ContainsKey($an)) { continue }
|
||
$av = $aliasBody[$an].ToUpperInvariant()
|
||
if ($av -eq 'TRUE' -or $av -eq 'FALSE') { continue }
|
||
$aUnused += $an
|
||
}
|
||
if ($aUnused.Count -gt 0) {
|
||
$aShow = $aUnused
|
||
if ($aShow.Count -gt 10) { $aShow = $aShow[0..9] }
|
||
$aMore = if ($aUnused.Count -gt 10) { "…(共 $($aUnused.Count) 个)" } else { '' }
|
||
[void]$warns.Add("别名定义后 0 引用:$($aShow -join '、')$aMore")
|
||
}
|
||
|
||
# 公式之间不可互相引用(2026-10-30 游戏内实测四条路全不通:裸名 / %FORMULA…% /
|
||
# $f() / Alias 宏)。失败是静默的——该条公式作废、占位符原样输出、不报错。
|
||
# 这里抓「公式体里出现另一个公式名」这种最容易犯的写法;公式名(SMA/XPA/C_HELM…)
|
||
# 与 STAT/MULTI 等变量名不重叠,误报风险极低。
|
||
for ($i = 0; $i -lt $Lines.Count; $i++) {
|
||
$cf = $formulaRe.Match($Lines[$i])
|
||
if (-not $cf.Success) { continue }
|
||
$own = $cf.Groups[1].Value
|
||
$cbody = $cf.Groups[2].Value
|
||
$ccut = $cbody.IndexOf('//')
|
||
if ($ccut -ge 0) { $cbody = $cbody.Substring(0, $ccut) }
|
||
foreach ($fn2 in $formulaNames) {
|
||
if ($fn2 -eq $own) { continue }
|
||
$pat = '(?<![A-Za-z0-9_])' + [regex]::Escape($fn2) + '(?![A-Za-z0-9_])'
|
||
if ([regex]::IsMatch($cbody, $pat)) {
|
||
[void]$errors.Add("L$($i + 1) 公式 $own 的表达式里引用了公式 $fn2(公式之间不能互相引用,游戏内会静默失效)")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
# 版本行联动:版本行日期 vs 最晚模块改动
|
||
$verRe = [regex]'更新于\s*(\d{2,4})[/-](\d{1,2})[/-](\d{1,2})'
|
||
foreach ($l in $Lines) {
|
||
$mv = $verRe.Match($l)
|
||
if (-not $mv.Success) { continue }
|
||
$y = [int]$mv.Groups[1].Value
|
||
if ($y -lt 100) { $y += 2000 }
|
||
$verOk = $true
|
||
$vd = [datetime]::MinValue
|
||
try { $vd = [datetime]::new($y, [int]$mv.Groups[2].Value, [int]$mv.Groups[3].Value) }
|
||
catch { $verOk = $false }
|
||
if ($verOk -and $null -ne $Mods -and $Mods.Count -gt 0) {
|
||
$newest = $Mods | Sort-Object MTime -Descending | Select-Object -First 1
|
||
if ($vd.Date -lt $newest.MTime.Date) {
|
||
[void]$warns.Add("版本行停在 $($vd.ToString('yyyy-MM-dd')),但最晚模块改动是 $($newest.MTime.ToString('yyyy-MM-dd'))($($newest.Name))—— 记得更新版本号")
|
||
}
|
||
}
|
||
break
|
||
}
|
||
|
||
if ($trail.Count -gt 0) {
|
||
$head = ($trail | Select-Object -First 6) -join ','
|
||
[void]$warns.Add("行尾空白 $($trail.Count) 处(L$head…)")
|
||
}
|
||
|
||
# 菜单按钮文字长度(v1.9 新增,v1.10 改口径)—— `00` 的 //@X@ 里 X 是 BH
|
||
# 界面按钮文字,正则的分组名部分要求**以 @ 收尾**((?:([^@]*)@)?),否则会把
|
||
# 关闭 @ 之后的普通行尾注释当成分组名(实测踩过:`//@屏蔽ILVL<68亮金珠宝@ 说明:…`)。
|
||
# 口径 = **显示宽度**(不是汉字个数):汉字/全角记 2,字母·数字·符号各记 1;
|
||
# 按钮 X ≤ 20(=10 汉字宽),分组写法 //@X@Y@ 的 Y ≤ 24(=12 汉字宽)。
|
||
# v1.9 按汉字个数统计偏松,`显示无孔鬼魂尖枪ETH5` 这类 ASCII 多的按钮会漏报。
|
||
# 超长→菜单显示异常,游戏不报错,只能靠这里拦。
|
||
$btnRe = [regex]'//@([^@\r\n]*)@(?:([^@\r\n]*)@)?'
|
||
for ($bi = 0; $bi -lt $Lines.Count; $bi++) {
|
||
$mb = $btnRe.Match($Lines[$bi])
|
||
if (-not $mb.Success) { continue }
|
||
$btn = $mb.Groups[1].Value
|
||
$grp = $mb.Groups[2].Value
|
||
$bl = $bi + 1
|
||
# 已裁定放行(v1.10):JUST_TAG4 那条说明按钮有意写长,只放行这一个
|
||
if ($ButtonAccepted -notcontains $btn) {
|
||
$bc = Get-Display-Width $btn
|
||
if ($bc -gt 20) { [void]$warns.Add("按钮文字超长 L$bl「$btn」$bc 宽(上限 20 = 10 汉字宽,超长会让菜单显示异常)") }
|
||
}
|
||
$gc = Get-Display-Width $grp
|
||
if ($gc -gt 24) { [void]$warns.Add("分组名超长 L$bl「$grp」$gc 宽(上限 24 = 12 汉字宽)") }
|
||
}
|
||
|
||
# 版本说明每行宽度(v1.11 新增)—— `00` 模块的 `ItemDisplay[box]` 版本说明
|
||
# 断行单元的**标准写法是整条 `%NL%%NL%%NAME%`**:第一个 `%NL%` 换行,第二个
|
||
# `%NL%` 再多一个空行隔开,视觉更整齐(2026-11-01 用户裁定)。
|
||
# 单个 `%CL%` / 单个 `%NL%` 都没问题;**有 BUG 的是 `%CL%%NL%` 相邻混用**。
|
||
# 语义区别(2026-11-01 用户说明):`%CL%` = 惰性换行,后面有内容才换行;
|
||
# `%NL%` = 强制换行,不管有没有内容。所以本检查把两者都当切点(%CL% 后若
|
||
# 无内容会产生空段,已在下面跳过,不影响判宽)。
|
||
# 但 `ItemDisplay[box]` 里用 `%NL%%NL%%NAME%` 断行会**重开一个新块**,把
|
||
# 「BADWOLF FILTER…/ 当前过滤等级」整段再打一遍(实测一屏炸成 8 个块),
|
||
# 所以版本说明正文段间改用 `%CL%%CORAL%`(已进游戏验证显示正常)。
|
||
# 两种切法这里都认:先把整条 `%NL%%NL%%NAME%` 换成哨兵,再按 `%CL%`/`%NL%` 切。
|
||
# 游戏里不自动折行:一行超宽会溢出框外被裁掉(实测 37 宽即丢字)。
|
||
# 故逐行剔掉 `%XXX%` 控制码后按显示宽度判,每行 ≤ 72(=36 汉字宽)。
|
||
$ctlRe = [regex]'%[A-Za-z0-9_]+%'
|
||
$brkTok = '%NL%%NL%%NAME%'
|
||
$brkRe = [regex]'%CL%|%NL%'
|
||
for ($vi = 0; $vi -lt $Lines.Count; $vi++) {
|
||
$vl = $Lines[$vi]
|
||
if (-not $vl.StartsWith('ItemDisplay[box]')) { continue }
|
||
if ($brkRe.IsMatch($vl) -eq $false) { continue }
|
||
$norm = $vl.Replace($brkTok, [string][char]1)
|
||
$segs = [regex]::Split($norm, ('[' + [char]1 + ']|%CL%|%NL%'))
|
||
$vline = $vi + 1
|
||
for ($si = 0; $si -lt $segs.Count; $si++) {
|
||
$txt = $ctlRe.Replace($segs[$si], '').Trim()
|
||
# 首段带着 `ItemDisplay[条件]:` 前缀,前缀不显示、不计入宽度
|
||
if ($si -eq 0) { $txt = [regex]::Replace($txt, '^ItemDisplay\[[^\]]*\]:\s*', '').Trim() }
|
||
if ($txt.Length -eq 0) { continue }
|
||
$vw = Get-Display-Width $txt
|
||
if ($vw -gt 72) { [void]$warns.Add("版本说明/提示行超宽 L$vline「$txt」$vw 宽(上限 72 = 36 汉字宽,超出会被框裁掉,需断行)") }
|
||
}
|
||
}
|
||
|
||
# 业务目录顶层杂项 txt(v1.10 新增)—— BH 菜单把 filters\Local 顶层的每个
|
||
# .txt 当成一个**独立过滤器**列出,只有 `本地过滤器存放目录.txt` 被忽略。
|
||
# 所以顶层除合并版与该占位文件外不应再有 .txt;工具文件一律放 tools\ 子目录。
|
||
# 在仓库里跑时顶层只有合并版,本检查自然为空。
|
||
$strayTxt = @(Get-ChildItem -LiteralPath $Root -Filter '*.txt' -File -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Name -ne $MergedName -and $_.Name -ne $DirPlaceholderTxt })
|
||
if ($strayTxt.Count -gt 0) {
|
||
[void]$warns.Add("业务目录顶层有 $($strayTxt.Count) 个多余 .txt(会被 BH 菜单当成独立过滤器):$(($strayTxt | ForEach-Object Name) -join ', ') —— 建议移到 tools\ 子目录")
|
||
}
|
||
|
||
return [pscustomobject]@{
|
||
Errors = $errors
|
||
Warns = $warns
|
||
Rules = $rules.Count
|
||
Aliases = $aliasNames.Count
|
||
Formulas = $formulaCount
|
||
Continue = $continueCount
|
||
Lines = $Lines.Count
|
||
PhKinds = $phSeen.Count
|
||
FiltLvl = $filtlvl
|
||
WlCount = $wlCount
|
||
Dead = $deadCount
|
||
FRef = $fRef.Count
|
||
FUnused = $fUnused.Count
|
||
ARef = $aliasRef.Count
|
||
AUnused = $aUnused.Count
|
||
Pct = $pctFaults.Count
|
||
LintE = $lintErr
|
||
LintW = $lintWarn
|
||
Excel = $excelDir
|
||
StatN = $statN
|
||
PfMax = $pfMax
|
||
SfMax = $sfMax
|
||
SameCont = $sameCont
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 差异定位
|
||
function Get-DiffBlocks([string[]]$A, [string[]]$B) {
|
||
$blocks = New-Object System.Collections.ArrayList
|
||
if ($A.Count -eq 0 -or $B.Count -eq 0) { return , $blocks.ToArray() }
|
||
|
||
if ($A.Count -eq $B.Count) {
|
||
# 行数相同:逐行比对,合并连续差异
|
||
$cur = $null
|
||
for ($i = 0; $i -lt $A.Count; $i++) {
|
||
if ($A[$i] -cne $B[$i]) {
|
||
if ($null -eq $cur) {
|
||
$cur = [pscustomobject]@{
|
||
AFrom = $i + 1; ATo = $i + 1
|
||
BFrom = $i + 1; BTo = $i + 1
|
||
A = @(); B = @()
|
||
}
|
||
}
|
||
$cur.ATo = $i + 1
|
||
$cur.BTo = $i + 1
|
||
$cur.A += $A[$i]
|
||
$cur.B += $B[$i]
|
||
} elseif ($null -ne $cur) {
|
||
[void]$blocks.Add($cur); $cur = $null
|
||
}
|
||
}
|
||
if ($null -ne $cur) { [void]$blocks.Add($cur) }
|
||
return , $blocks.ToArray()
|
||
}
|
||
|
||
# 行数不同:掐掉公共前后缀,报中间
|
||
$min = [Math]::Min($A.Count, $B.Count)
|
||
$p = 0
|
||
while ($p -lt $min -and $A[$p] -ceq $B[$p]) { $p++ }
|
||
$s = 0
|
||
while ($s -lt ($min - $p) -and $A[$A.Count - 1 - $s] -ceq $B[$B.Count - 1 - $s]) { $s++ }
|
||
|
||
$blk = [pscustomobject]@{
|
||
AFrom = $p + 1
|
||
ATo = $A.Count - $s
|
||
BFrom = $p + 1
|
||
BTo = $B.Count - $s
|
||
A = @($A[$p..([Math]::Max($p, $A.Count - $s - 1))])
|
||
B = @($B[$p..([Math]::Max($p, $B.Count - $s - 1))])
|
||
}
|
||
[void]$blocks.Add($blk)
|
||
return , $blocks.ToArray()
|
||
}
|
||
|
||
# ---------------------------------------------------------------- check
|
||
function Invoke-Check($Mods) {
|
||
Write-Line (Get-Bar)
|
||
Write-Line " BadWolf 过滤器 构建校验"
|
||
Write-Line (Get-Bar)
|
||
|
||
$mergedRaw = @()
|
||
if (Test-Path -LiteralPath $MergedPath) {
|
||
$mergedRaw = [System.IO.File]::ReadAllBytes($MergedPath)
|
||
}
|
||
$mergedText = [System.Text.Encoding]::UTF8.GetString($mergedRaw)
|
||
$mergedLines = Get-Lines $mergedText
|
||
$cat = Join-Bytes $Mods
|
||
$catText = [System.Text.Encoding]::UTF8.GetString($cat)
|
||
$catLines = Get-Lines $catText
|
||
|
||
Write-Line " 模块数 $($Mods.Count)"
|
||
Write-Line " 拼接 $(Format-N $cat.Length) B $(Format-N $catLines.Count) 行 MD5 $(Get-Md5Hex $cat)"
|
||
Write-Line " 合并 $(Format-N $mergedRaw.Length) B $(Format-N $mergedLines.Count) 行 MD5 $(Get-Md5Hex $mergedRaw)"
|
||
|
||
# ---- 1 完整性
|
||
Write-Head '1/5 模块完整性'
|
||
$badMods = 0
|
||
foreach ($m in $Mods) {
|
||
if ($m.Errors.Count -eq 0) { $flag = 'OK ' } else { $flag = 'ERR'; $badMods++ }
|
||
$line = " $flag [{0}] {1,5} 行 {2,10} B {3}" -f $m.Code, $m.LineCount, (Format-N $m.ByteLen), $m.Name
|
||
Write-Line $line
|
||
foreach ($e in $m.Errors) { Write-Line " ! $e" }
|
||
}
|
||
|
||
# ---- 2 比对
|
||
Write-Head '2/5 拼接与比对'
|
||
$same = $false
|
||
if ($mergedRaw.Length -eq 0) {
|
||
Write-Line " 状态 合并版不存在(首次构建?),无基准可比 → 运行 build 生成"
|
||
$same = $false
|
||
} elseif ($cat.Length -eq $mergedRaw.Length) {
|
||
$same = $true
|
||
for ($i = 0; $i -lt $cat.Length; $i++) {
|
||
if ($cat[$i] -ne $mergedRaw[$i]) { $same = $false; break }
|
||
}
|
||
} else { $same = $false }
|
||
|
||
if ($mergedRaw.Length -eq 0) {
|
||
# 没有基准文件,跳过差异定位
|
||
} elseif ($same) {
|
||
Write-Line " 状态 一致(逐字节相同)"
|
||
} else {
|
||
$delta = $cat.Length - $mergedRaw.Length
|
||
$sign = ''
|
||
if ($delta -ge 0) { $sign = '+' }
|
||
$blocks = Get-DiffBlocks $mergedLines $catLines
|
||
Write-Line " 状态 不一致(拼接比合并版 $sign$delta B,$($blocks.Count) 个差异块)"
|
||
foreach ($b in $blocks) {
|
||
$mod = Find-ModuleOfLine $Mods $b.BFrom
|
||
$head = " [差异] 合并版 L$($b.AFrom)-$($b.ATo)"
|
||
if ($null -ne $mod) {
|
||
$ml = $b.BFrom - $mod.Start + 1
|
||
$mh = $b.BTo - $mod.Start + 1
|
||
if ($ml -eq $mh) { $head += " ← $($mod.Name) 第 $ml 行" }
|
||
else { $head += " ← $($mod.Name) 第 $ml-$mh 行" }
|
||
}
|
||
Write-Line $head
|
||
$n = [Math]::Min($b.A.Count, 3)
|
||
for ($i = 0; $i -lt $n; $i++) {
|
||
Write-Line " 合并: $($b.A[$i])"
|
||
Write-Line " 模块: $($b.B[$i])"
|
||
}
|
||
if ($b.A.Count -ne $b.B.Count) {
|
||
Write-Line " (行数 $($b.A.Count) → $($b.B.Count))"
|
||
}
|
||
}
|
||
}
|
||
|
||
# ---- 3 行号表
|
||
Write-Head '3/5 行号映射表(模块 → 合并版)'
|
||
Write-Line " 模块 合并版区间 行数 字节 文件名"
|
||
foreach ($m in $Mods) {
|
||
$line = " [{0}] {1,5}-{2,-5} {3,8} {4,12} {5}" -f $m.Code, $m.Start, $m.End, $m.LineCount, (Format-N $m.ByteLen), $m.Name
|
||
Write-Line $line
|
||
}
|
||
$last = $Mods[$Mods.Count - 1]
|
||
Write-Line (" 合计 1-{0,-9} {1,8} {2,12} ({3} 个模块)" -f $last.End, $last.End, (Format-N $cat.Length), $Mods.Count)
|
||
|
||
# ---- 4 自检
|
||
Write-Head '4/5 静态自检'
|
||
$chk = Invoke-SelfCheck $catLines $Mods
|
||
Write-Line " 规则 $($chk.Rules) 条 | 别名 $($chk.Aliases) 个 | 公式 $($chk.Formulas) 个 | CONTINUE $($chk.Continue) 处 | FILTLVL $($chk.FiltLvl) 条"
|
||
Write-Line " 行 $(Format-N $chk.Lines) | 字节 $(Format-N $cat.Length) | 占位符 $($chk.PhKinds) 种"
|
||
$wlTxt = if ($chk.WlCount -gt 0) { "白名单 $($chk.WlCount) 码" } else { '白名单 未加载' }
|
||
Write-Line " 语义 $wlTxt | 死规则 $($chk.Dead) | 公式引用 $($chk.FRef)/$($chk.Formulas)(未引用 $($chk.FUnused))"
|
||
Write-Line " 引用 别名 $($chk.ARef)/$($chk.Aliases)(未引用 $($chk.AUnused),不含 TRUE/FALSE 开关项)"
|
||
Write-Line " 符号 孤立 % / 未闭合 % $($chk.Pct) 处"
|
||
if ($null -ne $chk.Excel) {
|
||
Write-Line " 编号 STAT 白名单 $($chk.StatN) 个 | PREFIX <= $($chk.PfMax) | SUFFIX <= $($chk.SfMax) | 同条件近似重复 $($chk.SameCont) 组"
|
||
} else {
|
||
Write-Line " 编号 数据表未加载,STAT / 词缀编号校验已跳过"
|
||
}
|
||
$lintFlag = 'OK'
|
||
if ($chk.LintE -gt 0) { $lintFlag = 'x' } elseif ($chk.LintW -gt 0) { $lintFlag = '!' }
|
||
Write-Line " 公式 lint $lintFlag 错误 $($chk.LintE) / 提示 $($chk.LintW)(公式名 / 公式体 % / IF 元数 / AND·OR 单参 / 非 ASCII·单等号·~)"
|
||
if ($chk.Errors.Count -gt 0) {
|
||
Write-Line ""
|
||
Write-Line " 错误 $($chk.Errors.Count) 项:"
|
||
foreach ($e in ($chk.Errors | Select-Object -First 30)) { Write-Line " x $e" }
|
||
} else {
|
||
Write-Line ""
|
||
Write-Line " 无错误(括号 / 占位符 / 别名重复 / 物品代码 / 公式引用 / 别名引用 / 公式 lint)"
|
||
}
|
||
if ($chk.Warns.Count -gt 0) {
|
||
Write-Line ""
|
||
Write-Line " 提示 $($chk.Warns.Count) 项(不阻断):"
|
||
foreach ($w in ($chk.Warns | Select-Object -First 20)) { Write-Line " - $w" }
|
||
if ($chk.Warns.Count -gt 20) { Write-Line " ... 另有 $($chk.Warns.Count - 20) 项" }
|
||
} else {
|
||
Write-Line " 无提示项"
|
||
}
|
||
|
||
# ---- 5 结论
|
||
Write-Head '5/5 结论'
|
||
$ok = $same -and ($chk.Errors.Count -eq 0) -and ($badMods -eq 0)
|
||
if ($ok) {
|
||
Write-Line " 通过:合并版与模块一致,自检无错误。"
|
||
if ($chk.Warns.Count -gt 0) { Write-Line " (有 $($chk.Warns.Count) 项提示,非阻断)" }
|
||
} else {
|
||
if (-not $same) { Write-Line " x 模块已改动但合并版未更新 → 运行 build(或双击 build.bat 选 build)" }
|
||
if ($badMods -gt 0) { Write-Line " x $badMods 个模块有编码/换行问题,先修复再构建" }
|
||
if ($chk.Errors.Count -gt 0) { Write-Line " x 自检发现 $($chk.Errors.Count) 项错误" }
|
||
}
|
||
Write-Line ""
|
||
|
||
if ($Strict) { if ($ok -and $chk.Warns.Count -eq 0) { return 0 } else { return 1 } }
|
||
if ($ok) { return 0 } else { return 1 }
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 备份轮转
|
||
# 只保留最近 $Keep 份 BadWolf-LootFilter.txt.<时间戳>.bak(文件名时间戳可直接按名倒序)
|
||
function Remove-OldBackup([int]$Keep) {
|
||
$dir = Split-Path -Parent $MergedPath
|
||
$pat = (Split-Path -Leaf $MergedPath) + '.*.bak'
|
||
$all = @(Get-ChildItem -LiteralPath $dir -Filter $pat -File -ErrorAction SilentlyContinue |
|
||
Sort-Object Name -Descending)
|
||
if ($all.Count -le $Keep) { return @() }
|
||
$gone = @($all | Select-Object -Skip $Keep)
|
||
foreach ($g in $gone) { Remove-Item -LiteralPath $g.FullName -Force -ErrorAction SilentlyContinue }
|
||
return $gone
|
||
}
|
||
|
||
# ---------------------------------------------------------------- build
|
||
function Invoke-Build($Mods) {
|
||
Write-Line (Get-Bar)
|
||
Write-Line " BadWolf 过滤器 构建"
|
||
Write-Line (Get-Bar)
|
||
|
||
$bad = @($Mods | Where-Object { $_.Errors.Count -gt 0 })
|
||
if ($bad.Count -gt 0) {
|
||
Write-Line " [中止] 以下模块存在编码/换行问题:"
|
||
foreach ($m in $bad) {
|
||
Write-Line " $($m.Name)"
|
||
foreach ($e in $m.Errors) { Write-Line " ! $e" }
|
||
}
|
||
return 1
|
||
}
|
||
|
||
$cat = Join-Bytes $Mods
|
||
|
||
# 写盘前先拦公式 lint 错误(非 ASCII / 单等号 / ~):过了才生成合并版,
|
||
# 避免把明知有错的公式写进 BadWolf-LootFilter.txt
|
||
$preLines = Get-Lines ([System.Text.Encoding]::UTF8.GetString($cat))
|
||
$preFormulaRe = [regex]'^Formula\[([^\]]+)\]:(.*)$'
|
||
$blockers = New-Object System.Collections.ArrayList
|
||
for ($i = 0; $i -lt $preLines.Count; $i++) {
|
||
$pf = $preFormulaRe.Match($preLines[$i])
|
||
if (-not $pf.Success) { continue }
|
||
foreach ($q in @(Get-FormulaLint $pf.Groups[2].Value $pf.Groups[1].Value)) {
|
||
if ($q.Level -eq 'E') { [void]$blockers.Add("L$($i + 1) 公式 $($pf.Groups[1].Value) $($q.Msg)") }
|
||
}
|
||
}
|
||
if ($blockers.Count -gt 0) {
|
||
Write-Line " [中止] 公式 lint 发现 $($blockers.Count) 项错误,未写入合并版:"
|
||
foreach ($b in ($blockers | Select-Object -First 20)) { Write-Line " x $b" }
|
||
return 1
|
||
}
|
||
|
||
$old = @()
|
||
if (Test-Path -LiteralPath $MergedPath) { $old = [System.IO.File]::ReadAllBytes($MergedPath) }
|
||
|
||
$same = $false
|
||
if ($old.Length -eq $cat.Length) {
|
||
$same = $true
|
||
for ($i = 0; $i -lt $cat.Length; $i++) { if ($cat[$i] -ne $old[$i]) { $same = $false; break } }
|
||
}
|
||
if ($same) {
|
||
Write-Line " 合并版已是最新,无需重建。"
|
||
Write-Line ""
|
||
return Invoke-Check $Mods
|
||
}
|
||
|
||
if (-not $NoBackup -and (Test-Path -LiteralPath $MergedPath)) {
|
||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||
$bak = "$MergedPath.$stamp.bak"
|
||
Copy-Item -LiteralPath $MergedPath -Destination $bak -Force
|
||
Write-Line " 已备份 → $(Split-Path -Leaf $bak)"
|
||
if ($KeepBackups -gt 0) {
|
||
$gone = @(Remove-OldBackup $KeepBackups)
|
||
if ($gone.Count -gt 0) {
|
||
Write-Line " 轮转 删除旧备份 $($gone.Count) 份(保留最近 $KeepBackups 份):$(($gone | ForEach-Object { $_.Name }) -join ', ')"
|
||
} else {
|
||
Write-Line " 轮转 现有备份未超过 $KeepBackups 份,无需删除"
|
||
}
|
||
}
|
||
}
|
||
|
||
$tmp = "$MergedPath.tmp"
|
||
try {
|
||
[System.IO.File]::WriteAllBytes($tmp, $cat)
|
||
Move-Item -LiteralPath $tmp -Destination $MergedPath -Force
|
||
} finally {
|
||
if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
|
||
}
|
||
|
||
$new = [System.IO.File]::ReadAllBytes($MergedPath)
|
||
$lineN = ([regex]::Matches([System.Text.Encoding]::UTF8.GetString($new), "\n")).Count
|
||
Write-Line " 已写入 $(Format-N $new.Length) B / $(Format-N $lineN) 行 MD5 $(Get-Md5Hex $new)"
|
||
|
||
$okWrite = $false
|
||
if ($new.Length -eq $cat.Length) {
|
||
$okWrite = $true
|
||
for ($i = 0; $i -lt $cat.Length; $i++) { if ($new[$i] -ne $cat[$i]) { $okWrite = $false; break } }
|
||
}
|
||
if ($okWrite) { Write-Line " 写入校验通过(逐字节一致)" } else { Write-Line " [错误] 写入校验失败"; return 1 }
|
||
|
||
Write-Line ""
|
||
return Invoke-Check $Mods
|
||
}
|
||
|
||
# ---------------------------------------------------------------- map
|
||
function Invoke-Map($Mods, [string]$Q) {
|
||
if ($Q -match ':') {
|
||
$parts = $Q.Split(':')
|
||
$code = $parts[0]; $ln = 0
|
||
if (-not [int]::TryParse($parts[1], [ref]$ln)) { Write-Line "无法解析行号:$Q"; return 1 }
|
||
$m = $Mods | Where-Object { $_.Code -eq $code -or $_.Name.StartsWith($code) } | Select-Object -First 1
|
||
if ($null -eq $m) { Write-Line "找不到模块:$code"; return 1 }
|
||
if ($ln -lt 1 -or $ln -gt $m.LineCount) { Write-Line "$($m.Name) 只有 $($m.LineCount) 行"; return 1 }
|
||
$merged = $m.Start + $ln - 1
|
||
Write-Line "$($m.Name) 第 $ln 行 → 合并版 L$merged"
|
||
$lines = Get-Lines $m.Text
|
||
if ($ln -le $lines.Count) { Write-Line " $($lines[$ln - 1])" }
|
||
return 0
|
||
}
|
||
|
||
$ln = 0
|
||
if (-not [int]::TryParse($Q, [ref]$ln)) { Write-Line "无法解析行号:$Q"; return 1 }
|
||
$m = Find-ModuleOfLine $Mods $ln
|
||
if ($null -eq $m) { Write-Line "L$ln 超出范围(1-$($Mods[$Mods.Count-1].End))"; return 1 }
|
||
Write-Line "合并版 L$ln → $($m.Name) 第 $($ln - $m.Start + 1) 行"
|
||
return 0
|
||
}
|
||
|
||
# ---------------------------------------------------------------- export
|
||
function Invoke-Export($Mods) {
|
||
$cat = Join-Bytes $Mods
|
||
$end = $Mods[$Mods.Count - 1].End
|
||
$sb = New-Object System.Text.StringBuilder
|
||
[void]$sb.AppendLine('# BadWolf 过滤器行号映射表')
|
||
[void]$sb.AppendLine('')
|
||
[void]$sb.AppendLine("- 生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||
[void]$sb.AppendLine("- 模块数:$($Mods.Count)")
|
||
[void]$sb.AppendLine("- 模块拼接:$(Format-N $cat.Length) 字节 / $(Format-N $end) 行 / MD5 ``$(Get-Md5Hex $cat)``")
|
||
[void]$sb.AppendLine('- 换算:`合并行 = 模块起始行 + 模块行 - 1`')
|
||
[void]$sb.AppendLine('')
|
||
[void]$sb.AppendLine('| 模块 | 合并版起 | 合并版止 | 行数 | 字节 |')
|
||
[void]$sb.AppendLine('|---|---:|---:|---:|---:|')
|
||
foreach ($m in $Mods) {
|
||
[void]$sb.AppendLine("| $($m.Name) | $($m.Start) | $($m.End) | $($m.LineCount) | $(Format-N $m.ByteLen) |")
|
||
}
|
||
[void]$sb.AppendLine("| **合计** | 1 | $end | $end | $(Format-N $cat.Length) |")
|
||
[void]$sb.AppendLine('')
|
||
[void]$sb.AppendLine('## 常用锚点')
|
||
[void]$sb.AppendLine('')
|
||
foreach ($m in $Mods) {
|
||
if (@('00','09','16','22','28','37','39') -contains $m.Code) {
|
||
[void]$sb.AppendLine("- ``$($m.Code)`` $($m.Name) → L$($m.Start)–L$($m.End)")
|
||
}
|
||
}
|
||
$path = Join-Path $Root $MapName
|
||
[System.IO.File]::WriteAllText($path, $sb.ToString(), (New-Object System.Text.UTF8Encoding($false)))
|
||
Write-Line "已导出 $path"
|
||
return 0
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 主流程
|
||
try {
|
||
$moduleDir = Get-ModuleDir
|
||
} catch {
|
||
Write-Line "[错误] $($_.Exception.Message)"
|
||
exit 2
|
||
}
|
||
|
||
Write-Line (Get-Bar)
|
||
Write-Line " BadWolf 过滤器构建工具(PowerShell 版)"
|
||
Write-Line (Get-Bar)
|
||
Write-Line " 脚本位置 $PSCommandPath"
|
||
Write-Line " 模块目录 $moduleDir"
|
||
Write-Line " 合并版 $MergedPath"
|
||
|
||
$mods = Get-Modules $moduleDir
|
||
if ($mods.Count -eq 0) { Write-Line "[错误] 模块目录里没有 NN-*.txt 模块文件"; exit 2 }
|
||
|
||
if ($script:SkippedModules.Count -gt 0) {
|
||
Write-Host ""
|
||
Write-Host " [警告] 以下 txt 不符合「NN-名称.txt」,已跳过(不会进入合并版):" -ForegroundColor Yellow
|
||
foreach ($n in $script:SkippedModules) { Write-Host " - $n" -ForegroundColor Yellow }
|
||
Write-Host " 若是正式模块,请改名成 40-xxx.txt 这种形式。" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 编号必须唯一:两个 16-xxx.txt 会让合并版多出一整段,且顺序取决于文件名,必须人工处理
|
||
$dupCodes = @($mods | Group-Object Code | Where-Object { $_.Count -gt 1 })
|
||
if ($dupCodes.Count -gt 0) {
|
||
Write-Line ""
|
||
Write-Line "[错误] 模块编号重复,已停止(避免把两份内容都拼进合并版):"
|
||
foreach ($g in $dupCodes) {
|
||
$names = ($g.Group | ForEach-Object { $_.Name }) -join ' / '
|
||
Write-Line (" [$($g.Name)] $names")
|
||
}
|
||
Write-Line " 处理办法:把多余的那份移出模块目录,或改成未占用的编号。"
|
||
exit 2
|
||
}
|
||
|
||
switch ($Command.ToLowerInvariant()) {
|
||
'build' { exit (Invoke-Build $mods) }
|
||
'map' { if ([string]::IsNullOrWhiteSpace($Arg)) { Write-Line "用法:build.bat map 1939 或 build.bat map 16:139"; exit 2 }; exit (Invoke-Map $mods $Arg) }
|
||
'export' { exit (Invoke-Export $mods) }
|
||
'check' { exit (Invoke-Check $mods) }
|
||
default { Write-Line "[错误] 未知命令:$Command(可用 check / build / map / export)"; exit 2 }
|
||
}
|