diff --git a/!src-dist/ci.py b/!src-dist/ci.py index e35535d17..388fa9b3a 100644 --- a/!src-dist/ci.py +++ b/!src-dist/ci.py @@ -9,10 +9,13 @@ # 引入自定义的打包发布模块和环境设置模块 +import subprocess + from plib.publish import run def main() -> None: + subprocess.run(["lua", "tests/team_mon_compute.lua"], check=True) run("ci") diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9712944..4be85e288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ## 未发布 +* [团队监控] 支持数据定义的团队 Buff 统计与计算变量,并随状态变化更新倒计时文案 * [基础库] 修复 29.0.12 中团队面板气血刷新报错的问题 ## 茗伊插件集 v29.0.12 diff --git a/MY_TeamMon/docs/computed-countdowns.md b/MY_TeamMon/docs/computed-countdowns.md new file mode 100644 index 000000000..6a2a8c9a7 --- /dev/null +++ b/MY_TeamMon/docs/computed-countdowns.md @@ -0,0 +1,129 @@ +# 倒计时计算变量(提案 v1) + +在现有 `tCountdown` 条目中增加可选 `tVars`,按团队 Buff 统计结果执行数据作者提供的公式,再使用原有 `{$变量名}` 模板显示结果。没有 `tVars` 的条目沿用原有行为。 + +插件只提供通用计算与刷新能力。Buff ID、层数条件、公式、文案、触发事件、倒计时时长及取消条件由数据作者定义。 + +## 示例 + +下面是现有数据条目中的 `tCountdown` 字段示意。1001、1002、1003 是测试 ID;差值公式仅演示语法,不能用作实战配平公式。外层地图和监控事件数据仍按现有格式填写。 + +```lua +tCountdown = { + { + nClass = 8, -- SKILL_BEGIN;由作者选择实际触发类型 + key = 'example-computed-countdown', + nTime = 20, + nRefresh = 0, + szName = '红2×{$calc_red2};黑×{$calc_black};差值 {$calc_difference}', + tVars = { + nVersion = 1, + szUnavailable = '团队状态未完整读取', + bUpdate = true, + nDebounce = 100, + aQueries = { + { + szName = 'calc_red2', + dwBuffID = 1001, + nLevel = 0, + nStackNum = 2, + szStackOp = '==', + bAliveOnly = true, + dwExcludeBuffID = 1003, + szAggregate = 'count', + }, + { + szName = 'calc_black', + dwBuffID = 1002, + bAliveOnly = true, + }, + }, + aValues = { + { + szName = 'calc_difference', + tExpression = { 'sub', 'calc_black', 'calc_red2' }, + }, + }, + }, + }, +} +``` + +实际的“接圈人数”“找背人数”可放在 `aValues`,再由 `szName` 引用。原有 `{$sender}`、`{$receiver}`、`{$1}` 等变量继续可用;计算结果仅加入本次渲染上下文,不写入原始事件或 Buff 对象。 + +## 字段 + +| 位置 / 字段 | 含义 | +|---|---| +| `tVars.nVersion` | 必须为 `1` | +| `tVars.szUnavailable` | 必填非空备用文案;状态不可读或计算失败时显示 | +| `tVars.bUpdate` | 默认不持续刷新;仅 `true` 时监听后续变化 | +| `tVars.nDebounce` | 首次相关事件后的合并等待毫秒数,默认 100,范围 50–1000;后续事件不继续延长等待 | +| `tVars.aQueries` | 查询数组,最多 32 条,可省略 | +| `tVars.aValues` | 公式数组,按顺序执行,最多 32 条,可省略 | +| `szName` | 查询或公式的变量名,必须以 `calc_` 开头,后接英文字母、数字或下划线,最长 64 字节,不得重复 | +| 查询 `dwBuffID` | 必填正整数 Buff ID | +| 查询 `nLevel` | 非负整数,默认 0,沿用 `X.GetBuff` 的任意等级匹配;不汇总同 ID 的多个实例 | +| 查询 `nStackNum` / `szStackOp` | 默认 `1` / `>=`;比较符支持 `== ~= < <= > >=` | +| 查询 `bAliveOnly` | 默认包含死亡成员;设为 `true` 时仅统计存活成员 | +| 查询 `dwExcludeBuffID` | 可选;拥有此 Buff 的成员排除,等级任意 | +| 查询 `aExcludeBuffIDs` | 可选 ID 数组,最多 8 项;拥有其中任何一个 Buff 即排除。与 `dwExcludeBuffID` 二选一 | +| 查询 `szAggregate` | `count` 为满足条件的人数(默认);`sum` 为满足条件成员的 Buff 层数之和 | +| 公式 `tExpression` | 数字、已有计算变量名,或下述表达式表 | + +离线成员跳过;在线且需要参与查询的成员对象不可读、必要状态缺失时,整组计算使用备用文案。已成功读取的对象没有指定 Buff,才按 0 层处理。查询遵守现有团队监控、地图和可见 Buff 限制,不扩展游戏允许的读取范围。 + +## 表达式 + +| 类型 | 写法 | +|---|---| +| 数值 / 引用 | `3`、`'calc_red2'` | +| 二元运算 | `{ 'add', a, b }`、`sub`、`mul`、`div`、`min`、`max` | +| 一元运算 | `{ 'floor', a }`、`ceil`、`abs` | +| 比较 | `{ 'lt', a, b }`、`le`、`eq`、`ge`、`gt`,返回布尔值 | +| 条件 | `{ 'if', 布尔表达式, 真分支, 假分支 }`,只执行选中的分支 | + +`aValues` 只能引用查询结果或前面已经定义的公式变量,不能前向引用。最终变量必须是有限数字;布尔值仅用于条件。除零、无效类型或运算、非有限结果进入备用文案路径,并写入调试日志。表达式深度最多 16,单次计算最多访问 256 个表达式节点。不执行表达式字符串或配置中的 Lua 函数。 + +## 更新和生命周期 + +首次创建立即计算。开启 `bUpdate` 后,相关团队 Buff 的增加、移除、层数变化,以及成员增减、在线、生死、进出场景事件会标记待刷新;计时窗口在合并等待到期后重新读取状态。只监听查询中出现的 Buff ID 和排除 Buff ID,普通无关 Buff 不触发查询。 + +刷新调用原有计时条的 `SetInfo({ szContent = ... })`,仅在文案改变时写入,不改变开始时间、剩余时间、进度或重播语音。分段倒计时保留各段原始模板,刷新当前和后续段文案。 + +原有 key 在计算前生成。相同 key 的删除、`nTime=0/-1/-2`、`nRefresh`、DEL、CLEAR、读图清理等继续使用原逻辑。数据重载时移除绑定旧计算配置的条目并释放对应刷新冷却;普通条目不受这一新增分支影响。 + +原有数据编辑器没有增加 `tVars` 编辑界面;当前由作者直接维护嵌套配置。游戏原生文件导入、保存导出、在线构建器和订阅链路的字段保留仍需端到端验证。 + +## 六种互斥状态的人数展示 + +`examples/six-state-countdown.lua` 提供六条现有倒计时条目的配置示例。它供作者生成配置及测试使用,需把 `tCountdown` 接到实际事件条目中;示例的技能开始触发和 20 秒时长仅用于演示,不是完整可导入副本包。 + +本例使用此前数据包中的赤煞 34095、幽煞 34096、煞印隔绝 34097,显示标签沿用“红”和“黑”: + +| 分类 | 条件 | +|---|---| +| 红1 | 恰好 1 层红,无黑、无隔绝 | +| 红2 | 恰好 2 层红,无黑、无隔绝 | +| 0 | 无红、无黑、无隔绝 | +| 黑1 | 恰好 1 层黑,无红、无隔绝 | +| 黑2 | 恰好 2 层黑,无红、无隔绝 | +| 其他 | 其余可读取状态,包含红/黑 3 层及以上、任意带隔绝的成员 | + +隔绝优先归“其他”:红2+隔绝只计一次其他。数据若出现红黑同时存在,也保守归入其他。前五类使用多 Buff 排除条件,其他等于参与统计总人数减去前五类人数;这一减法仅用于互斥分类,不是副本配平公式。 + +示例默认统计在线且可读成员,包含死亡成员;离线跳过。如果只统计存活成员,须统一给所有查询(包括总人数)配置 `bAliveOnly=true`。六类合计等于相同过滤口径下的参与人数。 + +必要状态读取不完整时六条都使用备用文案,不把缺失成员计为 0 或其他。相关红、黑或隔绝 Buff 变化会更新人数,保留倒计时原始时间。 + +## 验证 + +在仓库根目录运行: + +```sh +lua tests/team_mon_compute.lua +``` + +测试使用 Lua 5.1,运行实际计算模块、模板函数、倒计时分发与计时模块,游戏 API 和 UI 为模拟对象。测试 ID 和公式不是实战数据。 + +尚需游戏内验证 Buff 缓存相对事件的更新时间、可读取范围,以及实际副本触发和阶段清理。首次计算没有额外的“等 Buff 同步”开关;`nDebounce` 只控制后续更新的合并等待,不证明状态已全部同步。 diff --git a/MY_TeamMon/docs/examples/six-state-countdown.lua b/MY_TeamMon/docs/examples/six-state-countdown.lua new file mode 100644 index 000000000..f3798760a --- /dev/null +++ b/MY_TeamMon/docs/examples/six-state-countdown.lua @@ -0,0 +1,35 @@ +-- Configuration authoring example, not a standalone importable jx3dat package. +-- Bind tCountdown to a real event and adjust duration before use. +-- Offline members are skipped; all readable online members are included. +local tVars = { + nVersion = 1, + szUnavailable = 'ÍŶÓ״̬²»ÍêÕû', + bUpdate = true, + nDebounce = 100, + aQueries = { + { szName = 'calc_total', dwBuffID = 34095, nStackNum = 0 }, + { szName = 'calc_red1', dwBuffID = 34095, nStackNum = 1, szStackOp = '==', aExcludeBuffIDs = { 34096, 34097 } }, + { szName = 'calc_red2', dwBuffID = 34095, nStackNum = 2, szStackOp = '==', aExcludeBuffIDs = { 34096, 34097 } }, + { szName = 'calc_zero', dwBuffID = 34095, nStackNum = 0, szStackOp = '==', aExcludeBuffIDs = { 34096, 34097 } }, + { szName = 'calc_black1', dwBuffID = 34096, nStackNum = 1, szStackOp = '==', aExcludeBuffIDs = { 34095, 34097 } }, + { szName = 'calc_black2', dwBuffID = 34096, nStackNum = 2, szStackOp = '==', aExcludeBuffIDs = { 34095, 34097 } }, + }, + aValues = { + { + szName = 'calc_other', + tExpression = { 'sub', { 'sub', { 'sub', { 'sub', { 'sub', + 'calc_total', 'calc_red1' }, 'calc_red2' }, 'calc_zero' }, 'calc_black1' }, 'calc_black2' }, + }, + }, +} + +return { + tCountdown = { + { nClass = 8, key = 'example-six-red1', nTime = 20, nRefresh = 0, szName = 'ºì1£º{$calc_red1}ÈË', tVars = tVars }, + { nClass = 8, key = 'example-six-red2', nTime = 20, nRefresh = 0, szName = 'ºì2£º{$calc_red2}ÈË', tVars = tVars }, + { nClass = 8, key = 'example-six-zero', nTime = 20, nRefresh = 0, szName = '0£º{$calc_zero}ÈË', tVars = tVars }, + { nClass = 8, key = 'example-six-black1', nTime = 20, nRefresh = 0, szName = 'ºÚ1£º{$calc_black1}ÈË', tVars = tVars }, + { nClass = 8, key = 'example-six-black2', nTime = 20, nRefresh = 0, szName = 'ºÚ2£º{$calc_black2}ÈË', tVars = tVars }, + { nClass = 8, key = 'example-six-other', nTime = 20, nRefresh = 0, szName = 'ÆäËû£º{$calc_other}ÈË', tVars = tVars }, + }, +} diff --git a/MY_TeamMon/info.ini b/MY_TeamMon/info.ini index 0d6ae9079..3f6288946 100644 --- a/MY_TeamMon/info.ini +++ b/MY_TeamMon/info.ini @@ -23,3 +23,4 @@ lua_14=src/MY_TeamMon_Subscribe_VoicePacket_Custom.lua lua_15=src/MY_TeamMon_Subscribe_MergeDataSlice.lua lua_16=src/MY_TeamMon_VoiceAlarm_Previewer.lua lua_17=src/MY_TeamMon.PS.lua +lua_18=src/MY_TeamMon_Compute.lua diff --git a/MY_TeamMon/info.ini.zh_TW b/MY_TeamMon/info.ini.zh_TW index bd34b579a..c802b9c16 100644 --- a/MY_TeamMon/info.ini.zh_TW +++ b/MY_TeamMon/info.ini.zh_TW @@ -23,3 +23,4 @@ lua_14=src/MY_TeamMon_Subscribe_VoicePacket_Custom.lua lua_15=src/MY_TeamMon_Subscribe_MergeDataSlice.lua lua_16=src/MY_TeamMon_VoiceAlarm_Previewer.lua lua_17=src/MY_TeamMon.PS.lua +lua_18=src/MY_TeamMon_Compute.lua diff --git a/MY_TeamMon/src/MY_TeamMon.lua b/MY_TeamMon/src/MY_TeamMon.lua index 526f41f02..f07494b45 100644 --- a/MY_TeamMon/src/MY_TeamMon.lua +++ b/MY_TeamMon/src/MY_TeamMon.lua @@ -1028,6 +1028,8 @@ function D.CountdownEvent(data, nClass, szSender, szReceiver, aBackreferences) nIcon = v.nIcon or data.nIcon or 340, nFrame = v.nFrame, szContent = FilterCustomText(v.szName or data.szName, szSender, szReceiver, aBackreferences), + tVars = v.tVars, + szTemplate = v.szName or data.szName, nTime = v.nTime, nRefresh = v.nRefresh, bTalk = v.bTeamChannel, diff --git a/MY_TeamMon/src/MY_TeamMon_Compute.lua b/MY_TeamMon/src/MY_TeamMon_Compute.lua new file mode 100644 index 000000000..2871f3591 --- /dev/null +++ b/MY_TeamMon/src/MY_TeamMon_Compute.lua @@ -0,0 +1,202 @@ +-------------------------------------------------------------------------------- +-- This file is part of the JX3 Mingyi Plugin. +-- @desc : Data-defined variables for spell timers +-- @copyright: Emil Zhai +-------------------------------------------------------------------------------- +local X = MY +local D = {} +local MAX_VARIABLES, MAX_DEPTH, MAX_NODES = 32, 16, 256 +local MAX_EXCLUSIONS = 8 +local COMPARISONS = { ['=='] = true, ['~='] = true, ['<'] = true, ['<='] = true, ['>'] = true, ['>='] = true } +local BINARY = { + add = function(nLeft, nRight) return nLeft + nRight end, + sub = function(nLeft, nRight) return nLeft - nRight end, + mul = function(nLeft, nRight) return nLeft * nRight end, + div = function(nLeft, nRight) assert(nRight ~= 0, 'division by zero'); return nLeft / nRight end, + min = math.min, + max = math.max, + lt = function(nLeft, nRight) return nLeft < nRight end, + le = function(nLeft, nRight) return nLeft <= nRight end, + eq = function(nLeft, nRight) return nLeft == nRight end, + ge = function(nLeft, nRight) return nLeft >= nRight end, + gt = function(nLeft, nRight) return nLeft > nRight end, +} +local UNARY = { floor = math.floor, ceil = math.ceil, abs = math.abs } + +local function IsFinite(nValue) + return type(nValue) == 'number' and nValue == nValue and math.abs(nValue) < math.huge +end + +local function ArrayLength(aValues, nMax) + assert(type(aValues) == 'table', 'expected an array') + local nLength, nCount = #aValues, 0 + assert(nLength <= nMax, 'too many entries') + for nIndex in pairs(aValues) do + assert(type(nIndex) == 'number' and nIndex % 1 == 0 and nIndex >= 1 and nIndex <= nLength, 'invalid array') + nCount = nCount + 1 + end + assert(nCount == nLength, 'sparse array') + return nLength +end + +local function CheckName(szName, tValues) + assert(type(szName) == 'string' and #szName <= 64 and szName:match('^calc_[%w_]+$'), 'invalid variable name') + assert(tValues[szName] == nil, 'duplicate variable: ' .. szName) +end + +local function Evaluate(tConfig, aBackreferences) + assert(type(tConfig) == 'table' and tConfig.nVersion == 1, 'unsupported variable schema') + assert(type(tConfig.szUnavailable) == 'string' and #tConfig.szUnavailable > 0, 'missing unavailable text') + assert(tConfig.nDebounce == nil or (IsFinite(tConfig.nDebounce) and tConfig.nDebounce >= 50 + and tConfig.nDebounce <= 1000), 'invalid debounce interval') + assert(tConfig.bUpdate == nil or type(tConfig.bUpdate) == 'boolean', 'invalid update option') + local aQueries, aValues = tConfig.aQueries or {}, tConfig.aValues or {} + ArrayLength(aQueries, MAX_VARIABLES) + ArrayLength(aValues, MAX_VARIABLES) + assert(X.GetClientPlayer(), 'player unavailable') + assert(MY_TeamMon.bEnable and not X.IsRestricted('MY_TeamMon'), 'team monitor unavailable') + assert(not X.IsRestricted('MY_TeamMon.MapRestriction') or X.IsInDungeonMap(), 'team queries restricted in this map') + local tValues, tMembers, tSeen = {}, {}, {} + if #aQueries > 0 then + for _, dwID in ipairs(X.GetTeamMemberList()) do + if not tSeen[dwID] then + local tInfo = X.GetTeamMemberInfo(dwID) + assert(tInfo and type(tInfo.bOnline) == 'boolean', 'team member information unavailable') + tMembers[#tMembers + 1] = { info = tInfo, player = X.GetPlayer(dwID), buffs = {} } + tSeen[dwID] = true + end + end + assert(#tMembers > 0, 'team unavailable') + end + local function ReadStack(tMember, dwID, nLevel) + assert(tMember.player, 'online team member is not readable') + local szKey = dwID .. ':' .. nLevel + if tMember.buffs[szKey] == nil then + assert(not X.IsRestricted('MY_TeamMon.HiddenBuff') or X.IsVisibleBuff(dwID, nLevel > 0 and nLevel or 1), 'buff restricted') + local tBuff = X.GetBuff(tMember.player, dwID, nLevel) + local nStack = 0 + if tBuff and tBuff.bValid ~= false then + nStack = tBuff.nStackNum + end + assert(IsFinite(nStack) and nStack >= 0, 'invalid buff stack') + tMember.buffs[szKey] = nStack + end + return tMember.buffs[szKey] + end + for _, tQuery in ipairs(aQueries) do + assert(type(tQuery) == 'table', 'invalid query') + CheckName(tQuery.szName, tValues) + assert(IsFinite(tQuery.dwBuffID) and tQuery.dwBuffID > 0 and tQuery.dwBuffID % 1 == 0, 'invalid buff ID') + local nLevel, nStack = tQuery.nLevel or 0, tQuery.nStackNum or 1 + local szOp, szAggregate = tQuery.szStackOp or '>=', tQuery.szAggregate or 'count' + assert(IsFinite(nLevel) and nLevel >= 0 and nLevel % 1 == 0, 'invalid buff level') + assert(IsFinite(nStack) and COMPARISONS[szOp], 'invalid stack condition') + assert(szAggregate == 'count' or szAggregate == 'sum', 'invalid aggregate') + assert(tQuery.bAliveOnly == nil or type(tQuery.bAliveOnly) == 'boolean', 'invalid alive condition') + assert(not (tQuery.dwExcludeBuffID and tQuery.aExcludeBuffIDs), 'conflicting exclusion fields') + local aExclusions = tQuery.aExcludeBuffIDs or (tQuery.dwExcludeBuffID and { tQuery.dwExcludeBuffID }) or {} + ArrayLength(aExclusions, MAX_EXCLUSIONS) + for _, dwBuffID in ipairs(aExclusions) do + assert(IsFinite(dwBuffID) and dwBuffID > 0 and dwBuffID % 1 == 0, 'invalid excluded buff ID') + end + local nValue = 0 + for _, tMember in ipairs(tMembers) do + local tInfo = tMember.info + assert(not tInfo.bOnline or not tQuery.bAliveOnly or type(tInfo.bDeathFlag) == 'boolean', 'life state unavailable') + if tInfo.bOnline and (not tQuery.bAliveOnly or not tInfo.bDeathFlag) then + local nCount = ReadStack(tMember, tQuery.dwBuffID, nLevel) + local bExcluded = false + for _, dwBuffID in ipairs(aExclusions) do + if ReadStack(tMember, dwBuffID, 0) > 0 then + bExcluded = true + break + end + end + if not bExcluded and X.JudgeOperator(szOp, nCount, nStack) then + nValue = nValue + (szAggregate == 'sum' and nCount or 1) + end + end + end + tValues[tQuery.szName] = nValue + end + local nNodes = 0 + local function Eval(oExpression, nDepth) + nNodes = nNodes + 1 + assert(nDepth <= MAX_DEPTH and nNodes <= MAX_NODES, 'expression limit exceeded') + if type(oExpression) == 'number' then + assert(IsFinite(oExpression), 'non-finite literal') + return oExpression + end + if type(oExpression) == 'string' then + assert(tValues[oExpression] ~= nil, 'unknown variable: ' .. oExpression) + return tValues[oExpression] + end + local nLength = ArrayLength(oExpression, 4) + local szOp = oExpression[1] + if szOp == 'if' then + assert(nLength == 4, 'invalid if expression') + local bCondition = Eval(oExpression[2], nDepth + 1) + assert(type(bCondition) == 'boolean', 'expected a boolean condition') + return Eval(oExpression[bCondition and 3 or 4], nDepth + 1) + end + local fnUnary = UNARY[szOp] + if fnUnary then + assert(nLength == 2, 'invalid unary expression') + local nValue = Eval(oExpression[2], nDepth + 1) + assert(IsFinite(nValue), 'expected a numeric operand') + return fnUnary(nValue) + end + local fnBinary = BINARY[szOp] + assert(fnBinary and nLength == 3, 'invalid binary expression') + local nLeft, nRight = Eval(oExpression[2], nDepth + 1), Eval(oExpression[3], nDepth + 1) + assert(IsFinite(nLeft) and IsFinite(nRight), 'expected numeric operands') + return fnBinary(nLeft, nRight) + end + for _, tValue in ipairs(aValues) do + assert(type(tValue) == 'table', 'invalid calculated value') + CheckName(tValue.szName, tValues) + local nValue = Eval(tValue.tExpression, 1) + assert(IsFinite(nValue), 'expected a finite numeric result') + tValues[tValue.szName] = nValue + end + local tResult = X.Clone(aBackreferences) or {} + for szName, nValue in pairs(tValues) do + tResult[szName] = nValue + end + return tResult +end + +function D.Evaluate(tConfig, aBackreferences) + local bSuccess, tResult = pcall(Evaluate, tConfig, aBackreferences) + if bSuccess then + return tResult + end + return nil, tostring(tResult) +end + +function D.WatchesBuff(tConfig, dwBuffID) + if type(tConfig) ~= 'table' or tConfig.bUpdate ~= true or type(tConfig.aQueries) ~= 'table' then + return false + end + for nIndex = 1, math.min(#tConfig.aQueries, MAX_VARIABLES) do + local tQuery = tConfig.aQueries[nIndex] + if type(tQuery) == 'table' then + if tQuery.dwBuffID == dwBuffID or tQuery.dwExcludeBuffID == dwBuffID then + return true + end + if type(tQuery.aExcludeBuffIDs) == 'table' then + for nExcludeIndex = 1, math.min(#tQuery.aExcludeBuffIDs, MAX_EXCLUSIONS) do + if tQuery.aExcludeBuffIDs[nExcludeIndex] == dwBuffID then + return true + end + end + end + end + end + return false +end + +MY_TeamMon_Compute = X.CreateModule({ + name = 'MY_TeamMon_Compute', + exports = {{ root = D, fields = { 'Evaluate', 'WatchesBuff' } }}, +}) diff --git a/MY_TeamMon/src/MY_TeamMon_SpellTimer.lua b/MY_TeamMon/src/MY_TeamMon_SpellTimer.lua index ef30d76fe..cc2b2fe15 100644 --- a/MY_TeamMon/src/MY_TeamMon_SpellTimer.lua +++ b/MY_TeamMon/src/MY_TeamMon_SpellTimer.lua @@ -86,6 +86,7 @@ local function ParseCountdown(szCountdown, szSender, szReceiver, aBackreferences local aCountdown = MY_TeamMon.ParseCountdown(szCountdown) if X.IsTable(aCountdown) then for _, v in ipairs(aCountdown) do + v.szTemplate = v.szContent v.szContent = FilterCustomText(v.szContent, szSender, szReceiver, aBackreferences) end end @@ -108,13 +109,36 @@ local function CreateCountdown(nType, szKey, tParam, szSender, szReceiver, aBack assert(type(tParam) == 'table', 'CreateCountdown failed!') local tTime = {} local nTime = GetTime() + local tVariables, szVariablesError, tConfig = aBackreferences, nil, tParam.tVars + if not (X.IsString(tParam.nTime) or (X.IsNumber(tParam.nTime) and tParam.nTime > 0)) then + tConfig = nil + end + if tConfig then + if not X.IsTable(tConfig) or type(tConfig.szUnavailable) ~= 'string' or tConfig.szUnavailable == '' then + X.OutputDebugMessage('[MY_TeamMon_Compute] Missing unavailable text.', X.DEBUG_LEVEL.ERROR) + return + end + tVariables, szVariablesError = MY_TeamMon_Compute.Evaluate(tConfig, aBackreferences) + if szVariablesError then + X.OutputDebugMessage('[MY_TeamMon_Compute] ' .. szVariablesError, X.DEBUG_LEVEL.ERROR) + end + if X.IsNumber(tParam.nTime) then + tParam.szContent = tVariables and FilterCustomText(tParam.szTemplate, szSender, szReceiver, tVariables) + or tConfig.szUnavailable + end + end if X.IsNumber(tParam.nTime) then tTime = { nTime = tParam.nTime, szContent = tParam.szContent, } elseif X.IsString(tParam.nTime) then - local aCountdown = ParseCountdown(tParam.nTime, szSender, szReceiver, aBackreferences) + local aCountdown = ParseCountdown(tParam.nTime, szSender, szReceiver, tVariables) + if aCountdown and tConfig and not tVariables then + for _, tPart in ipairs(aCountdown) do + tPart.szContent = tConfig.szUnavailable + end + end if aCountdown then tTime = aCountdown[1] tParam.nTime = aCountdown @@ -158,11 +182,64 @@ local function CreateCountdown(nType, szKey, tParam, szSender, szReceiver, aBack return end ST_TIME_EXPIRE[nType][szKey] = nTime + (tParam.nRefresh or 0) * 1000 - ST:ctor(nType, szKey, tParam):SetInfo(tTime, tParam.nIcon or 13):Switch(false) + local obj = ST:ctor(nType, szKey, tParam):SetInfo(tTime, tParam.nIcon or 13):Switch(false) + obj.ui.tVars = tConfig + obj.ui.tVarsContext = tConfig and { szSender, szReceiver, X.Clone(aBackreferences) } + obj.ui.szVarsTemplate = tParam.szTemplate + obj.ui.nVarsDirty = nil + obj.ui.szVarsError = szVariablesError + end +end + +local function MarkVariablesDirty(dwBuffID) + for _, tTimers in pairs(ST_CACHE) do + for _, ui in pairs(tTimers) do + if ui:IsValid() and ui.tVars and ui.tVars.bUpdate == true + and (not dwBuffID or MY_TeamMon_Compute.WatchesBuff(ui.tVars, dwBuffID)) then + local nDebounce = ui.tVars.nDebounce + if type(nDebounce) ~= 'number' or not (nDebounce >= 50 and nDebounce <= 1000) then + nDebounce = 100 + end + ui.nVarsDirty = ui.nVarsDirty or GetTime() + nDebounce + end + end + end +end + +local function UpdateVariables(ui, nNow) + if not ui.nVarsDirty or ui.nVarsDirty > nNow then + return + end + ui.nVarsDirty = nil + local aContext = ui.tVarsContext + local tVariables, szError = MY_TeamMon_Compute.Evaluate(ui.tVars, aContext[3]) + if szError and ui.szVarsError ~= szError then + X.OutputDebugMessage('[MY_TeamMon_Compute] ' .. szError, X.DEBUG_LEVEL.ERROR) + end + ui.szVarsError = szError + local function Render(szTemplate) + return tVariables and FilterCustomText(szTemplate, aContext[1], aContext[2], tVariables) or ui.tVars.szUnavailable + end + local szContent + if type(ui.countdown) == 'number' then + szContent = Render(ui.szVarsTemplate) + else + for _, tPart in ipairs(ui.countdown) do + tPart.szContent = Render(tPart.szTemplate) + end + szContent = ui.countdown[1].szContent + end + if ui.txt:GetText() ~= szContent then + ui.obj:SetInfo({ szContent = szContent }) end end function D.OnFrameCreate() + for _, szEvent in ipairs({ 'BUFF_UPDATE', 'PARTY_ADD_MEMBER', 'PARTY_DELETE_MEMBER', 'PARTY_DISBAND', + 'PARTY_SET_MEMBER_ONLINE_FLAG', 'PLAYER_STATE_UPDATE', 'PLAYER_ENTER_SCENE', 'PLAYER_LEAVE_SCENE', + 'MY_TEAM_MON_DATA_RELOAD' }) do + this:RegisterEvent(szEvent) + end this:RegisterEvent('LOADING_END') this:RegisterEvent('UI_SCALED') this:RegisterEvent('ON_ENTER_CUSTOM_UI_MODE') @@ -176,7 +253,26 @@ function D.OnFrameCreate() end function D.OnEvent(szEvent) - if szEvent == 'MY_TEAM_MON__SPELL_TIMER__CREATE' then + if szEvent == 'BUFF_UPDATE' then + if X.IsTeammate(arg0) or arg0 == X.GetClientPlayerID() then + MarkVariablesDirty(arg4) + end + elseif szEvent == 'PARTY_ADD_MEMBER' or szEvent == 'PARTY_DELETE_MEMBER' or szEvent == 'PARTY_DISBAND' + or szEvent == 'PARTY_SET_MEMBER_ONLINE_FLAG' or szEvent == 'PLAYER_STATE_UPDATE' + or szEvent == 'PLAYER_ENTER_SCENE' or szEvent == 'PLAYER_LEAVE_SCENE' then + MarkVariablesDirty() + elseif szEvent == 'MY_TEAM_MON_DATA_RELOAD' then + for nType, tTimers in pairs(ST_CACHE) do + for szKey, ui in pairs(tTimers) do + if ui.tVars then + if ui:IsValid() then + ui.obj:RemoveItem() + end + ST_TIME_EXPIRE[nType][szKey] = nil + end + end + end + elseif szEvent == 'MY_TEAM_MON__SPELL_TIMER__CREATE' then CreateCountdown(arg0, arg1, arg2, arg3, arg4, arg5) elseif szEvent == 'MY_TEAM_MON__SPELL_TIMER__DEL' then local ui = ST_CACHE[arg0][arg1] @@ -243,6 +339,7 @@ function D.OnFrameBreathe() for k, v in pairs(ST_CACHE) do for kk, vv in pairs(v) do if vv:IsValid() then + UpdateVariables(vv, nNow) if type(vv.countdown) == 'number' then local nLeft = vv.countdown - ((nNow - vv.nLeft) / 1000) if nLeft >= 0 then diff --git a/tests/team_mon_compute.lua b/tests/team_mon_compute.lua new file mode 100644 index 000000000..c7b58ef5d --- /dev/null +++ b/tests/team_mon_compute.lua @@ -0,0 +1,545 @@ +-- Run from the repository root with Lua 5.1: lua tests/team_mon_compute.lua +-- Uses real calculator, template, countdown dispatch and timer code; game/UI are fixtures. +local nPassed, nFailed = 0, 0 +local function Equal(a, b) + assert(a == b, tostring(a) .. ' ~= ' .. tostring(b)) +end +local function Near(a, b) + assert(math.abs(a - b) < 0.00001, tostring(a) .. ' ~= ' .. tostring(b)) +end +local function Test(szName, fnTest) + local bOK, szError = pcall(fnTest) + if bOK then + nPassed = nPassed + 1 + print('PASS ' .. szName) + return + end + nFailed = nFailed + 1 + print('FAIL ' .. szName .. ': ' .. tostring(szError)) +end +local function Clone(oValue) + if type(oValue) ~= 'table' then + return oValue + end + local tResult = {} + for k, v in pairs(oValue) do + tResult[k] = Clone(v) + end + return tResult +end +local function Serialize(oValue) + if type(oValue) ~= 'table' then + return type(oValue) .. ':' .. tostring(oValue) + end + local aKeys, aResult = {}, {} + for k in pairs(oValue) do + aKeys[#aKeys + 1] = k + end + table.sort(aKeys, function(a, b) return tostring(a) < tostring(b) end) + for _, k in ipairs(aKeys) do + aResult[#aResult + 1] = Serialize(k) .. '=' .. Serialize(oValue[k]) + end + return '{' .. table.concat(aResult, ',') .. '}' +end +local function Read(szPath) + local hFile = assert(io.open(szPath, 'rb')) + local szText = hFile:read('*a') + hFile:close() + return szText +end +local function Extract(szText, szStart, szEnd) + local nStart = assert(szText:find(szStart, 1, true)) + local nEnd = assert(szText:find(szEnd, nStart + #szStart, true)) + return szText:sub(nStart, nEnd - 1) +end + +local nNow, nReads, nVoices, nWrites = 0, 0, 0, 0 +local tPlayers, tInfo, aMembers, tRestrictions = {}, {}, {}, {} +local bInDungeon = true +local aItems, aDebug = {}, {} +local function Node() + local h = { text = '' } + function h:SetText(szText) self.text = szText; nWrites = nWrites + 1 end + function h:GetText() return self.text end + function h:SetPercentage(nValue) self.percentage = nValue end + for _, szName in ipairs({ 'SetFontColor', 'SetColorRGB', 'SetObject', 'SetObjectIcon', 'Set2DRotation', 'SetRelX', 'SetW', + 'SetAlpha', 'SetFrame' }) do + h[szName] = function() end + end + return h +end +local hList = {} +function hList:AppendItemFromData() + local h = { valid = true, nodes = {} } + function h:IsValid() return self.valid end + function h:Lookup(szName) + self.nodes[szName] = self.nodes[szName] or Node() + return self.nodes[szName] + end + function h:Show() end + function h:FormatAllItemPos() end + function h:SetUserData(nValue) self.userData = nValue end + aItems[#aItems + 1] = h + return h +end +function hList:RemoveItem(h) h.valid = false end +function hList:Clear() + for _, h in ipairs(aItems) do h.valid = false end +end +function hList:Sort() end +function hList:FormatAllItemPos() end +this = {} +function this:RegisterEvent() end +function this:CreateItemData() return {} end +function this:Lookup() return hList end +function this:SetPoint() end +function this:CorrectPos() end + +MY = { + Clone = Clone, EncodeLUAData = Serialize, + PACKET_INFO = { ROOT = '', FRAMEWORK_ROOT = '' }, PATH_TYPE = { ROLE = 1 }, DEBUG_LEVEL = { ERROR = 1 }, + Schema = {}, CONSTANT = { MSG_THEME = {} }, + IsTable = function(v) return type(v) == 'table' end, + IsNil = function(v) return v == nil end, + IsEmpty = function(v) return v == nil or v == '' or (type(v) == 'table' and next(v) == nil) end, + IsNumber = function(v) return type(v) == 'number' end, + IsString = function(v) return type(v) == 'string' end, + StringFindW = function(s, p, i) return s:find(p, i, true) end, + StringLenW = string.len, StringSubW = string.sub, + ReplaceSensitiveWord = function(v) return v end, + GetClientPlayerName = function() return 'self' end, + GetClientPlayerID = function() return 1 end, + GetClientPlayer = function() return tPlayers[1] end, + GetTeamMemberList = function() return aMembers end, + GetTeamMemberInfo = function(dwID) return tInfo[dwID] end, + GetPlayer = function(dwID) return tPlayers[dwID] end, + GetBuff = function(kPlayer, dwID, nLevel) + nReads = nReads + 1 + local tBuff = kPlayer.buffs[dwID] + if tBuff and (nLevel == 0 or nLevel == tBuff.nLevel) then + return tBuff + end + end, + IsTeammate = function(dwID) return tInfo[dwID] ~= nil end, + IsRestricted = function(szKey) return tRestrictions[szKey] or false end, + IsInDungeonMap = function() return bInDungeon end, + IsVisibleBuff = function() return true end, + NSFormatString = function(s) return s end, + AssertVersion = function() return true end, + ReportModuleLoading = function() end, + RegisterRestriction = function() end, + RegisterEvent = function() end, + RegisterUserSettingsInit = function() end, + RegisterUserSettingsRelease = function() end, + MakeCaption = function() return '' end, + FormatDuration = function(n) return tostring(n) end, + OutputDebugMessage = function(s) aDebug[#aDebug + 1] = s end, + OutputSystemMessage = function() error('unexpected timer format error') end, +} +function MY.SplitString(szText, szSeparator) + local aParts, nPosition = {}, 1 + while true do + local nStart, nEnd = szText:find(szSeparator, nPosition, true) + local szPart = szText:sub(nPosition, nStart and nStart - 1 or #szText) + if szPart ~= '' then + aParts[#aParts + 1] = szPart + end + if not nStart then + return aParts + end + nPosition = nEnd + 1 + end +end +function MY.LoadLangPack() + return setmetatable({}, { __index = function(_, k) return k end, + __call = function(_, s, ...) return string.format(s, ...) end }) +end +function MY.CreateUserSettingsModule(_, _, tFields) + local tResult = {} + for k, v in pairs(tFields) do tResult[k] = Clone(v.xDefaultValue) end + return tResult +end +function MY.CreateModule(tSettings) + local tResult = {} + for _, tExport in ipairs(tSettings.exports) do + if tExport.preset == 'UIEvent' then + for k, v in pairs(tExport.root) do tResult[k] = v end + end + for _, szName in ipairs(tExport.fields or {}) do tResult[szName] = tExport.root[szName] end + end + return tResult +end +GetTime = function() return nNow end +UI_OBJECT_NOT_NEED_KNOWN = 0 +PLAYER_TALK_CHANNEL = { RAID = 1 } +local function Emit(szEvent, ...) + local aOld, aArgs = {}, { ... } + for i = 0, 11 do aOld[i] = _G['arg' .. i]; _G['arg' .. i] = aArgs[i + 1] end + MY_TeamMon_SpellTimer.OnEvent(szEvent) + for i = 0, 11 do _G['arg' .. i] = aOld[i] end +end +function FireUIEvent(szEvent, ...) + if szEvent == 'MY_TEAM_MON__VOICE_ALARM' then + nVoices = nVoices + 1 + return + end + Emit(szEvent, ...) +end + +assert(loadfile('MY_!Base/src/lib/Game.String.lua'))() +assert(loadfile('MY_!Base/src/lib/System.Operator.lua'))() +local szCore = Read('MY_TeamMon/src/MY_TeamMon.lua') +local szHelpers = Extract(szCore, 'local function RenderCustomText(', 'local function ConstructSpeech(') +local szParser = szCore:sub(assert(szCore:find('local function ParseCountdown(', 1, true)), + assert(szCore:find('local function ParseHPCountdown(', 1, true)) - 1) +X = MY +CACHE = { CD_STR = {} } +MY_SplitString = MY.SplitString +D, O = {}, { bPushTeamChannel = false } +MY_TEAM_MON_TYPE = { COMMON = 15, SKILL_BEGIN = 8, NPC_LEAVE = 4, BUFF_GET = 1 } +assert(loadstring(szHelpers .. szParser + .. Extract(szCore, 'function D.GetCountdownTypeKey(', 'function D.GetSrcName(') + .. '\nD.FilterCustomText=FilterCustomText;D.ParseCountdown=ParseCountdown'))() +MY_TeamMon = D +MY_TeamMon.bEnable, MY_TeamMon.bPushVoiceAlarm = true, true +MY_TeamMon.MY_TEAM_MON_TYPE = MY_TEAM_MON_TYPE +assert(loadfile('MY_TeamMon/src/MY_TeamMon_Compute.lua'))() +assert(loadfile('MY_TeamMon/src/MY_TeamMon_SpellTimer.lua'))() +MY_TeamMon_SpellTimer.OnFrameCreate() + +local function World() + hList:Clear() + nNow, nReads, nVoices, nWrites = 0, 0, 0, 0 + tPlayers, tInfo, aMembers, tRestrictions = {}, {}, {}, {} + bInDungeon, MY_TeamMon.bEnable = true, true + for i = 1, 25 do + aMembers[i] = i + tInfo[i] = { bOnline = true, bDeathFlag = false } + tPlayers[i] = { dwID = i, buffs = {}, IsInParty = function() return true end } + if i <= 10 then + tPlayers[i].buffs[1001] = { nLevel = 1, nStackNum = 2 } + end + if i > 10 and i <= 15 then + tPlayers[i].buffs[1002] = { nLevel = 1, nStackNum = 1 } + end + end + Emit('MY_TEAM_MON__SPELL_TIMER__CLEAR') +end +local function Config() + return { nVersion = 1, szUnavailable = 'waiting', bUpdate = true, aQueries = { + { szName = 'calc_red2', dwBuffID = 1001, nStackNum = 2, szStackOp = '==', bAliveOnly = true }, + { szName = 'calc_black', dwBuffID = 1002, bAliveOnly = true }, + }, aValues = {{ szName = 'calc_diff', tExpression = { 'sub', 'calc_black', 'calc_red2' } }} } +end +local function Item(tConfig, oTime, szKey) + return { dwID = 9001, nLevel = 1, tCountdown = {{ nClass = 8, key = szKey, nTime = oTime or 20, + nRefresh = 0, szName = 'R2x{$calc_red2} Bx{$calc_black} {$sender} {$1}', tVars = tConfig }} } +end +local function Start(tConfig, oTime, szKey) + MY_TeamMon.CountdownEvent(Item(tConfig, oTime, szKey), 8, 'boss', 'self', { 'capture' }) + return aItems[#aItems] +end +local function Tick(nTime) + nNow = nTime + MY_TeamMon_SpellTimer.OnFrameBreathe() +end +local function Buff(dwOwner, dwID, nStack) + tPlayers[dwOwner].buffs[dwID] = nStack and { nLevel = 1, nStackNum = nStack } or nil + Emit('BUFF_UPDATE', dwOwner, not nStack, 1, false, dwID, nStack or 0, 0, false, 1, 1) +end + +local function SixWorld() + World() + for i = 1, 25 do + tPlayers[i].buffs = {} + end + for _, aRange in ipairs({ { 1, 4, 34095, 1 }, { 5, 9, 34095, 2 }, { 16, 18, 34096, 1 }, + { 19, 21, 34096, 2 }, { 22, 22, 34095, 3 }, { 23, 23, 34095, 4 }, + { 24, 24, 34096, 3 }, { 25, 25, 34096, 4 } }) do + for i = aRange[1], aRange[2] do + tPlayers[i].buffs[aRange[3]] = { nLevel = 1, nStackNum = aRange[4] } + end + end + for _, i in ipairs({ 9, 15, 21, 25 }) do + tPlayers[i].buffs[34097] = { nLevel = 1, nStackNum = 1 } + end +end +local function SixExample() + return assert(loadfile('MY_TeamMon/docs/examples/six-state-countdown.lua'))() +end +local function SixResult() + return assert(MY_TeamMon_Compute.Evaluate(SixExample().tCountdown[1].tVars)) +end +local function SixSum(tValues) + return tValues.calc_red1 + tValues.calc_red2 + tValues.calc_zero + + tValues.calc_black1 + tValues.calc_black2 + tValues.calc_other +end + +Test('25-player count, grouping, formula and capture variables', function() + World() + local tResult = assert(MY_TeamMon_Compute.Evaluate(Config(), { [1] = 'capture' })) + Equal(tResult.calc_red2, 10); Equal(tResult.calc_black, 5); Equal(tResult.calc_diff, -5); Equal(tResult[1], 'capture') + local h = Start(Config(), 20, 'counts') + Equal(h.txt:GetText(), 'R2x10 Bx5 boss capture') +end) +Test('query sums, absence, isolation exclusion, death and offline filters', function() + World() + tPlayers[1].buffs[1003] = { nLevel = 1, nStackNum = 1 } + tInfo[2].bDeathFlag = true; tInfo[3].bOnline = false; tPlayers[3] = nil + local t = Config(); t.aQueries[1].dwExcludeBuffID = 1003 + Equal(assert(MY_TeamMon_Compute.Evaluate(t)).calc_red2, 7) + t.aQueries[1].szAggregate = 'sum' + Equal(assert(MY_TeamMon_Compute.Evaluate(t)).calc_red2, 14) + t.aQueries[1].dwExcludeBuffID = nil; t.aQueries[1].szAggregate = 'count'; t.aQueries[1].nStackNum = 0 + Equal(assert(MY_TeamMon_Compute.Evaluate(t)).calc_red2, 15) +end) +Test('missing online player is unavailable, not a zero stack', function() + World(); tPlayers[4] = nil + local r, e = MY_TeamMon_Compute.Evaluate(Config()) + Equal(r, nil); assert(e:find('not readable', 1, true)) + Equal(Start(Config(), 20, 'missing').txt:GetText(), 'waiting') +end) +Test('missing player recovers on scene event', function() + World(); local p = tPlayers[4]; tPlayers[4] = nil + local h = Start(Config(), 20, 'recovery') + tPlayers[4] = p; Emit('PLAYER_ENTER_SCENE', 4); Tick(101) + Equal(h.txt:GetText(), 'R2x10 Bx5 boss capture') +end) +Test('Buff stack change preserves timer start, deadline and progress', function() + World(); local h = Start(Config(), 20, 'update') + nNow = 5000; Buff(1, 1001, 3); Tick(5050) + Equal(h.txt:GetText(), 'R2x10 Bx5 boss capture') + Tick(5101) + Equal(h.txt:GetText(), 'R2x9 Bx5 boss capture') + Equal(h.nCreate, 0); Equal(h.nLeft, 0); Equal(h.countdown, 20) + Near(h.img.percentage, (20 - 5.101) / 20) +end) +Test('Buff gain and deletion both update aggregate values', function() + World(); local h = Start(Config(), 20, 'gain-loss') + Buff(20, 1002, 1); Tick(101); Equal(h.txt:GetText(), 'R2x10 Bx6 boss capture') + Buff(11, 1002, nil); Tick(202); Equal(h.txt:GetText(), 'R2x10 Bx5 boss capture') +end) +Test('unrelated Buff and non-team events do not query or update', function() + World(); Start(Config(), 20, 'irrelevant'); local nBefore = nReads + Buff(1, 9999, 1); Emit('BUFF_UPDATE', 999, false, 1, false, 1001, 2); Tick(200) + Equal(nReads, nBefore) +end) +Test('burst updates are coalesced with a bounded wait', function() + World(); local h = Start(Config(), 20, 'burst'); local nBefore = nReads + Buff(1, 1001, 3); nNow = 80; Buff(2, 1001, 3); Tick(101) + Equal(h.txt:GetText(), 'R2x8 Bx5 boss capture'); Equal(nReads - nBefore, 50) +end) +Test('unchanged result does not rewrite action text', function() + World(); local h = Start(Config(), 20, 'unchanged') + local nCalls = 0; local fnSet = h.txt.SetText + h.txt.SetText = function(self, s) nCalls = nCalls + 1; fnSet(self, s) end + Buff(1, 1001, 2); Tick(101); Equal(nCalls, 0) +end) +Test('multisegment update changes future text without replaying voice', function() + World(); local h = Start(Config(), '5,First {$calc_red2},VO:one;10,Second {$calc_black},VO:two;', 'segments') + Equal(nVoices, 1) + nNow = 1000; Buff(1, 1001, 3); Buff(11, 1002, nil); Tick(1101) + Equal(h.txt:GetText(), 'First 9'); Equal(h.countdown[2].szContent, 'Second 4'); Equal(nVoices, 1) + Tick(5100); Equal(h.txt:GetText(), 'Second 4'); Equal(nVoices, 2) + nNow = 6000; Buff(12, 1002, nil); Tick(6101) + Equal(h.txt:GetText(), 'Second 3'); Equal(nVoices, 2) + Near(h.countdown[1].nTime - (nNow - h.nLeft) / 1000, 3.899) +end) +Test('phase deletion via same key and nTime=0 cannot resurrect', function() + World(); local h = Start(Config(), 20, 'phase') + Buff(1, 1001, 3) + MY_TeamMon.CountdownEvent({ tCountdown = {{ key = 'phase', nClass = 4, nTime = 0 }} }, 4) + Equal(h.valid, false); local nBefore = nReads; Tick(101); Equal(nReads, nBefore) +end) +Test('DEL and CLEAR remove computed timers without later resurrection', function() + World(); local h = Start(Config(), 20, 'delete') + Emit('MY_TEAM_MON__SPELL_TIMER__DEL', 15, 'delete'); Buff(1, 1001, 3); Tick(101); Equal(h.valid, false) + h = Start(Config(), 20, 'clear'); Emit('MY_TEAM_MON__SPELL_TIMER__CLEAR'); Buff(2, 1001, 3); Tick(202) + Equal(h.valid, false) +end) +Test('same-key legacy replacement removes the old dynamic binding', function() + World(); local h = Start(Config(), 20, 'replace') + local item = Item(nil, 10, 'replace'); item.tCountdown[1].szName = 'legacy' + MY_TeamMon.CountdownEvent(item, 8); Buff(1, 1001, 3); local nBefore = nReads; Tick(101) + Equal(h.txt:GetText(), 'legacy'); Equal(h.tVars, nil); Equal(nReads, nBefore) +end) +Test('legacy fixed and segmented countdowns do not query team data', function() + World(); local h = Start(nil, 20, 'legacy') + Equal(h.txt:GetText(), 'R2x Bx boss capture'); Equal(nReads, 0) + h = Start(nil, '5,old one;10,old two;', 'legacy-parts'); Tick(5100) + Equal(h.txt:GetText(), 'old two'); Equal(nReads, 0) +end) +Test('existing nRefresh still blocks event-triggered replacement', function() + World(); local item = Item(Config(), 20, 'lock'); item.tCountdown[1].nRefresh = 10 + MY_TeamMon.CountdownEvent(item, 8); local h = aItems[#aItems] + nNow = 1000; item.tCountdown[1].nTime = 50; MY_TeamMon.CountdownEvent(item, 8) + Equal(h.nCreate, 0); Equal(h.countdown, 20) + Buff(1, 1001, 3); Tick(1101); Equal(h.txt:GetText(), 'R2x9 Bx5 ') + Equal(h.nCreate, 0); Equal(h.countdown, 20) +end) +Test('one-shot configuration does not subscribe to later Buff changes', function() + World(); local t = Config(); t.bUpdate = false; local h = Start(t, 20, 'once'); local nBefore = nReads + Buff(1, 1001, 3); Tick(101); Equal(h.txt:GetText(), 'R2x10 Bx5 boss capture'); Equal(nReads, nBefore) +end) +Test('anonymous timer identity is independent of computed values', function() + World(); local h = Start(Config(), 20); local nBefore = #aItems + Buff(1, 1001, 3); Tick(101); Equal(#aItems, nBefore); Equal(h.txt:GetText(), 'R2x9 Bx5 boss capture') +end) +Test('data reload clears computed bindings but retains legacy timers', function() + World(); local h = Start(Config(), 20, 'reload'); local old = Start(nil, 20, 'reload-old') + Emit('MY_TEAM_MON_DATA_RELOAD'); Equal(h.valid, false); Equal(old.valid, true) +end) +Test('data reload releases computed cooldowns, including already expired timers', function() + World(); local item = Item(Config(), 1, 'reload-lock'); item.tCountdown[1].nRefresh = 30 + MY_TeamMon.CountdownEvent(item, 8); local h = aItems[#aItems] + Tick(1001); Equal(h.valid, false) + Emit('MY_TEAM_MON_DATA_RELOAD'); MY_TeamMon.CountdownEvent(item, 8) + Equal(aItems[#aItems].valid, true); assert(aItems[#aItems] ~= h) +end) +Test('offline members do not require a readable life state', function() + World(); tInfo[2] = { bOnline = false }; tPlayers[2] = nil + Equal(assert(MY_TeamMon_Compute.Evaluate(Config())).calc_red2, 9) + tInfo[2].bOnline = true + Equal(MY_TeamMon_Compute.Evaluate(Config()), nil) +end) +Test('party and life events update the live aggregate', function() + World(); local h = Start(Config(), 20, 'party-life') + tInfo[1].bDeathFlag = true; Emit('PLAYER_STATE_UPDATE', 1); Tick(101) + Equal(h.txt:GetText(), 'R2x9 Bx5 boss capture') + tInfo[2].bOnline = false; Emit('PARTY_SET_MEMBER_ONLINE_FLAG', 2); Tick(202) + Equal(h.txt:GetText(), 'R2x8 Bx5 boss capture') + tInfo[2].bOnline = true; Emit('PARTY_SET_MEMBER_ONLINE_FLAG', 2); Tick(303) + Equal(h.txt:GetText(), 'R2x9 Bx5 boss capture') +end) +Test('control times keep their original cooldown and deletion behavior', function() + World(); local h = Start(Config(), 20, 'controls') + local item = Item(Config(), -2, 'controls'); item.tCountdown[1].nRefresh = 5 + local nBefore = nReads; MY_TeamMon.CountdownEvent(item, 8) + Equal(h.valid, true); Equal(nReads, nBefore) + item.tCountdown[1].nTime = -1; MY_TeamMon.CountdownEvent(item, 8) + Equal(h.valid, false); Equal(nReads, nBefore) + local nItems = #aItems; Start(Config(), 20, 'controls'); Equal(#aItems, nItems) + nNow = 5001; Start(Config(), 20, 'controls'); Equal(#aItems, nItems + 1) +end) +Test('loading removes unheld timers and does not resurrect them', function() + World(); local h = Start(Config(), 20, 'loading') + Emit('LOADING_END'); Equal(h.valid, false) + local nBefore = nReads; Buff(1, 1001, 3); Tick(101); Equal(nReads, nBefore) +end) +Test('nonpositive numeric timers cannot retain malformed variable bindings', function() + World(); local h = Start('invalid', -3, 'negative') + Equal(h.tVars, nil); Equal(nReads, 0) + Emit('BUFF_UPDATE', 1, false, 1, false, 1001, 3); Tick(101); Equal(h.valid, false) +end) +Test('queries reuse one snapshot without modifying config or Buff objects', function() + World(); local t = Config(); t.aQueries[2].dwBuffID = 1001 + local szBefore, szBuffs = Serialize(t), Serialize(tPlayers) + local r = assert(MY_TeamMon_Compute.Evaluate(t)) + Equal(r.calc_red2, 10); Equal(r.calc_black, 10); Equal(nReads, 25) + Equal(Serialize(t), szBefore); Equal(Serialize(tPlayers), szBuffs) +end) +Test('expired timer does not query or reappear after a Buff change', function() + World(); local h = Start(Config(), 1, 'expire'); Tick(1001); Equal(h.valid, false) + local nBefore = nReads; Buff(1, 1001, 3); Tick(1200); Equal(nReads, nBefore) +end) +Test('map, feature and hidden-Buff restrictions are respected', function() + World(); tRestrictions['MY_TeamMon.MapRestriction'] = true; bInDungeon = false + Equal(MY_TeamMon_Compute.Evaluate(Config()), nil); Equal(nReads, 0) + bInDungeon = true; MY_TeamMon.bEnable = false; Equal(MY_TeamMon_Compute.Evaluate(Config()), nil); Equal(nReads, 0) + MY_TeamMon.bEnable = true; tRestrictions['MY_TeamMon.HiddenBuff'] = true + local fnVisible = MY.IsVisibleBuff; MY.IsVisibleBuff = function() return false end + Equal(MY_TeamMon_Compute.Evaluate(Config()), nil); Equal(nReads, 0); MY.IsVisibleBuff = fnVisible +end) +Test('arithmetic, lazy conditionals and ordered variable references', function() + World(); local t = Config() + t.aValues = { + { szName = 'calc_a', tExpression = { 'floor', { 'div', { 'mul', 'calc_red2', 3 }, 4 } } }, + { szName = 'calc_b', tExpression = { 'if', { 'gt', 'calc_a', 5 }, { 'max', 'calc_a', 8 }, { 'div', 1, 0 } } }, + { szName = 'calc_c', tExpression = { 'ceil', { 'abs', { 'sub', 0, 1.2 } } } }, + } + local r = assert(MY_TeamMon_Compute.Evaluate(t)); Equal(r.calc_a, 7); Equal(r.calc_b, 8); Equal(r.calc_c, 2) +end) +Test('invalid expressions and numeric failures are isolated', function() + World() + for _, expr in ipairs({ { 'div', 1, 0 }, { 'mul', 1e308, 1e308 }, { 'unknown', 1, 2 }, 'missing', { 'if', 1, 2, 3 } }) do + local t = Config(); t.aValues[1].tExpression = expr + Equal(MY_TeamMon_Compute.Evaluate(t), nil) + end + local t = Config(); t.aValues[1].tExpression = { 'lt', 1, 2 }; Equal(MY_TeamMon_Compute.Evaluate(t), nil) +end) +Test('schema, names, array shapes and expression depth are bounded', function() + World(); local t = Config(); t.nVersion = 9; Equal(MY_TeamMon_Compute.Evaluate(t), nil) + t = Config(); t.aQueries[1].szName = 'sender'; Equal(MY_TeamMon_Compute.Evaluate(t), nil) + t = Config(); t.aQueries[2].szName = t.aQueries[1].szName; Equal(MY_TeamMon_Compute.Evaluate(t), nil) + t = Config(); t.aQueries.unexpected = {}; Equal(MY_TeamMon_Compute.Evaluate(t), nil) + t = Config(); local e = 1; for _ = 1, 20 do e = { 'abs', e } end + t.aValues[1].tExpression = e; Equal(MY_TeamMon_Compute.Evaluate(t), nil) +end) +Test('invalid debounce configuration cannot crash game event dispatch', function() + World(); local t = Config(); t.nDebounce = 'invalid'; local h = Start(t, 20, 'bad-delay') + Buff(1, 1001, 3); Tick(101); Equal(h.txt:GetText(), 'waiting') +end) +Test('duplicate team IDs are counted once', function() + World(); aMembers[#aMembers + 1] = 1 + Equal(assert(MY_TeamMon_Compute.Evaluate(Config())).calc_red2, 10) +end) +Test('six exclusive categories render all six counts and partition the team', function() + SixWorld(); local t = SixResult() + Equal(t.calc_red1, 4); Equal(t.calc_red2, 4); Equal(t.calc_zero, 5) + Equal(t.calc_black1, 3); Equal(t.calc_black2, 2); Equal(t.calc_other, 7) + Equal(SixSum(t), 25); Equal(t.calc_total, 25) + local data = SixExample(); local nBefore = #aItems + MY_TeamMon.CountdownEvent(data, 8) + Equal(#aItems - nBefore, 6) + for i, entry in ipairs(data.tCountdown) do + Equal(aItems[nBefore + i].txt:GetText(), MY_TeamMon.FilterCustomText(entry.szName, nil, nil, t)) + end +end) +Test('isolation takes precedence over red2 and refreshes without resetting timers', function() + SixWorld(); local data = SixExample(); local nBefore = #aItems + MY_TeamMon.CountdownEvent(data, 8) + Buff(5, 34097, 1); Tick(101) + local t = SixResult(); Equal(t.calc_red2, 3); Equal(t.calc_other, 8); Equal(SixSum(t), 25) + for i, entry in ipairs(data.tCountdown) do + local h = aItems[nBefore + i] + Equal(h.txt:GetText(), MY_TeamMon.FilterCustomText(entry.szName, nil, nil, t)) + Equal(h.nCreate, 0); Equal(h.nLeft, 0); Equal(h.countdown, 20) + end + Buff(5, 34097, nil); Tick(202) + t = SixResult(); Equal(t.calc_red2, 4); Equal(t.calc_other, 7); Equal(SixSum(t), 25) +end) +Test('zero, black2, and high layers move to other exactly once', function() + SixWorld(); Buff(10, 34097, 1); Buff(19, 34097, 1); Buff(6, 34095, 4) + local t = SixResult() + Equal(t.calc_zero, 4); Equal(t.calc_black2, 1); Equal(t.calc_red2, 3) + Equal(t.calc_other, 10); Equal(SixSum(t), 25) + Buff(10, 34097, nil); Buff(19, 34097, nil); Buff(6, 34095, 2) + Equal(SixResult().calc_other, 7) +end) +Test('conflicting raw colors are conservatively classified only as other', function() + SixWorld(); Buff(1, 34096, 1) + local t = SixResult(); Equal(t.calc_red1, 3); Equal(t.calc_black1, 3) + Equal(t.calc_other, 8); Equal(SixSum(t), 25) +end) +Test('unreadable team data does not enter zero or other in six-category display', function() + SixWorld(); tPlayers[5] = nil; local data = SixExample(); local nBefore = #aItems + Equal(MY_TeamMon_Compute.Evaluate(data.tCountdown[1].tVars), nil) + MY_TeamMon.CountdownEvent(data, 8) + for i = 1, 6 do + Equal(aItems[nBefore + i].txt:GetText(), data.tCountdown[i].tVars.szUnavailable) + end +end) +Test('multiple exclusion IDs are validated and included in Buff dependencies', function() + SixWorld(); local t = SixExample().tCountdown[1].tVars + Equal(MY_TeamMon_Compute.WatchesBuff(t, 34097), true) + Equal(MY_TeamMon_Compute.WatchesBuff(t, 99999), false) + for _, invalid in ipairs({ 'invalid', { 34097, 0 }, { [1] = 34097, [3] = 34096 }, + { 1, 2, 3, 4, 5, 6, 7, 8, 9 } }) do + t.aQueries[2].aExcludeBuffIDs = invalid + Equal(MY_TeamMon_Compute.Evaluate(t), nil) + end + t.aQueries[2].aExcludeBuffIDs = { 34097 }; t.aQueries[2].dwExcludeBuffID = 34096 + Equal(MY_TeamMon_Compute.Evaluate(t), nil) +end) +print(('RESULT %d passed, %d failed'):format(nPassed, nFailed)) +assert(nFailed == 0, 'computed countdown tests failed')