-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFormat.lua
More file actions
296 lines (269 loc) · 10.3 KB
/
Copy pathFormat.lua
File metadata and controls
296 lines (269 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
-- CoolPlan plan format: parse + serialize.
-- Byte-for-byte mirror of the website's lib/export/coolplan-format.ts so plans
-- round-trip between coolplan.team and this addon. Keep the two in sync.
--
-- v1: absolute pull-relative times only. v2 (superset, back-compat): adds an
-- optional per-encounter @phase table + phase-anchored row times (`pN+M:SS.T`)
-- for phase-GATED bosses, so the Scheduler can re-anchor each phase live. A plan
-- with no phase data serializes byte-identically to v1.
local _, ns = ...
local Format = {}
ns.Format = Format
local MAGIC = "COOLPLAN"
local VERSION = 1 -- baseline (absolute-time)
local VERSION_PHASED = 2 -- adds @phase table + pN+ anchored times
Format.VERSION = VERSION
local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function sanitize(s)
if s == nil then return "" end
-- Strip the delimiter ';', the WoW escape char '|', and newlines.
return (tostring(s):gsub("[;|\r\n]+", " "):gsub("^%s+", ""):gsub("%s+$", ""))
end
-- ms -> "M:SS.T"
function Format.FormatTime(ms)
if not ms or ms < 0 then ms = 0 end
local totalSec = math.floor(ms / 1000)
local m = math.floor(totalSec / 60)
local s = totalSec % 60
local tenths = math.floor((ms % 1000) / 100)
return string.format("%d:%02d.%d", m, s, tenths)
end
-- "M:SS.T" or plain seconds -> ms
function Format.ParseTime(str)
str = trim(str)
local mm, rest = str:match("^(%d+):(.+)$")
if mm then
local r = tonumber(rest) or 0
return math.floor((tonumber(mm) * 60 + r) * 1000 + 0.5)
end
local n = tonumber(str)
if not n then return 0 end
return math.floor(n * 1000 + 0.5)
end
-- A row's time field: "M:SS.T" (absolute) or "pN+M:SS.T" (offset from phase N's
-- live anchor). Returns ms, phaseIndex (phaseIndex nil when absolute).
function Format.ParseTimeField(str)
str = trim(str)
local pidx, rest = str:match("^[pP](%d+)%+(.*)$")
if pidx then
return Format.ParseTime(rest), tonumber(pidx)
end
return Format.ParseTime(str), nil
end
local function serializeTime(ms, phaseIndex)
local t = Format.FormatTime(ms)
if phaseIndex and phaseIndex > 1 then
return "p" .. phaseIndex .. "+" .. t
end
return t
end
-- Group rows by phase (major key) then offset, so the document reads in play order.
local function rowKey(r)
local phase = (r.phaseIndex and r.phaseIndex > 1) and r.phaseIndex or 1
return phase * 1e9 + (r.timeMs or 0)
end
local function splitLines(text)
local out = {}
for line in (text .. "\n"):gmatch("(.-)\r?\n") do
out[#out + 1] = line
end
return out
end
-- Split a row into fields on ';' (current) or '|' (legacy). Normalize the old
-- pipe delimiter to ';' first so a single pass handles both.
local function splitFields(s)
s = s:gsub("|", ";")
local t = {}
for field in (s .. ";"):gmatch("(.-);") do
t[#t + 1] = field
end
return t
end
-- "@phase ; index ; label ; kind? ; (spellId|pct)? ; occurrence? ; unitCount?"
local function serializePhase(p)
local fields = { "@phase", tostring(p.index), sanitize(p.label), "", "", "", "" }
local t = p.trigger
if t then
fields[4] = t.kind or ""
if t.kind == "health" then
fields[5] = (t.pct ~= nil) and tostring(t.pct) or ""
elseif t.kind ~= "rotation-resume" then
fields[5] = (t.spellId ~= nil) and tostring(t.spellId) or ""
fields[6] = (t.occurrence ~= nil) and tostring(t.occurrence) or ""
end
if t.unitCount ~= nil then fields[7] = tostring(t.unitCount) end
end
local n = #fields
while n > 3 and fields[n] == "" do n = n - 1 end
local row = {}
for k = 1, n do row[k] = fields[k] end
return table.concat(row, ";")
end
local function hasPhases(plans)
for _, enc in pairs(plans) do
if enc.phases and #enc.phases > 0 then return true end
end
return false
end
-- plans = { [encounterID] = { name, reminders = { {timeMs, spellId, player, category?, spellName?, alert?, phaseIndex?}, ... },
-- boss? = { {timeMs, spellId, type?, spellName?, phaseIndex?}, ... },
-- phases? = { {index, label, trigger?={kind, spellId?, occurrence?, pct?}}, ... } } }
function Format.Serialize(plans, meta)
local version = hasPhases(plans) and VERSION_PHASED or VERSION
local lines = { MAGIC .. " v" .. version }
if meta then
local parts = {}
for k, v in pairs(meta) do
if v ~= nil and v ~= "" then parts[#parts + 1] = k .. "=" .. sanitize(v) end
end
table.sort(parts)
if #parts > 0 then lines[#lines + 1] = "@meta " .. table.concat(parts, "; ") end
end
local ids = {}
for id in pairs(plans) do ids[#ids + 1] = id end
table.sort(ids)
for _, id in ipairs(ids) do
local enc = plans[id]
lines[#lines + 1] = ""
lines[#lines + 1] = "[encounter] id=" .. id .. "; name=" .. sanitize(enc.name)
if enc.phases then
local ps = {}
for _, p in ipairs(enc.phases) do ps[#ps + 1] = p end
table.sort(ps, function(a, b) return a.index < b.index end)
for _, p in ipairs(ps) do lines[#lines + 1] = serializePhase(p) end
end
local rs = {}
for _, r in ipairs(enc.reminders) do rs[#rs + 1] = r end
table.sort(rs, function(a, b) return rowKey(a) < rowKey(b) end)
for _, r in ipairs(rs) do
local fields = {
serializeTime(r.timeMs, r.phaseIndex),
tostring(r.spellId),
sanitize(r.player),
sanitize(r.category),
sanitize(r.spellName),
sanitize(r.alert),
}
local n = #fields
while n > 3 and fields[n] == "" do n = n - 1 end
local row = {}
for k = 1, n do row[k] = fields[k] end
lines[#lines + 1] = table.concat(row, ";")
end
if enc.boss then
local bs = {}
for _, b in ipairs(enc.boss) do bs[#bs + 1] = b end
table.sort(bs, function(a, b) return rowKey(a) < rowKey(b) end)
for _, b in ipairs(bs) do
local fields = { "@boss", serializeTime(b.timeMs, b.phaseIndex), tostring(b.spellId), sanitize(b.type), sanitize(b.spellName) }
local n = #fields
while n > 3 and fields[n] == "" do n = n - 1 end
local row = {}
for k = 1, n do row[k] = fields[k] end
lines[#lines + 1] = table.concat(row, ";")
end
end
end
return table.concat(lines, "\n")
end
-- returns plans, meta (or nil, nil, errorMessage)
function Format.Parse(text)
local lines = splitLines(text or "")
local i = 1
while i <= #lines and trim(lines[i]) == "" do i = i + 1 end
local header = trim(lines[i] or "")
local ver = header:match("^COOLPLAN%s+v(%d+)")
if not ver then
return nil, nil, "Not a CoolPlan string (missing 'COOLPLAN v1' header)."
end
ver = tonumber(ver)
if ver ~= VERSION and ver ~= VERSION_PHASED then
-- A higher version = a plan from a newer site than this addon supports.
-- Tell the user to update rather than showing a bare "unsupported" code.
if ver > VERSION_PHASED then
return nil, nil, "This plan needs a newer CoolPlan (made for v" .. ver .. "). Please update the addon."
end
return nil, nil, "Unsupported CoolPlan version: v" .. ver .. "."
end
local plans = {}
local meta = {}
local current = nil
for j = i + 1, #lines do
local line = trim(lines[j])
if line == "" or line:sub(1, 1) == "#" then
-- skip comment / blank
elseif line:match("^@meta") then
local body = line:gsub("^@meta%s*", "")
for pair in (body .. ";"):gmatch("(.-);") do
local k, v = pair:match("^%s*(.-)%s*=%s*(.-)%s*$")
if k and k ~= "" then meta[k] = v end
end
else
local id, name = line:match("^%[encounter%]%s+id=(%d+)%s*;%s*name=(.*)$")
if id then
current = { name = trim(name), reminders = {} }
plans[tonumber(id)] = current
else
local f = splitFields(line)
local tag = trim(f[1])
if current and tag == "@phase" then
-- phase row: @phase | index | label | kind? | (spellId|pct)? | occurrence?
local idx = tonumber(f[2])
if idx then
local phase = { index = idx, label = trim(f[3] or "") }
local kind = trim(f[4] or "")
-- Hand-mirror of PHASE_TRIGGER_KINDS (lib/timeline/types/catalog.ts, the
-- canonical list). Keep in sync when adding a kind — the site's round-trip
-- + catalog vitest guard the TS/JSON side, this Lua list is manual.
if kind == "cast" or kind == "removedebuff" or kind == "applybuff"
or kind == "removebuff" or kind == "rotation-resume" or kind == "interrupt"
or kind == "health" then
local trig = { kind = kind }
if kind == "health" then
trig.pct = tonumber(f[5])
elseif kind ~= "rotation-resume" then
-- rotation-resume carries no spellId — detected live via the timeline.
trig.spellId = tonumber(f[5])
trig.occurrence = tonumber(f[6])
end
trig.unitCount = tonumber(f[7])
phase.trigger = trig
end
current.phases = current.phases or {}
current.phases[#current.phases + 1] = phase
end
elseif current and tag == "@boss" then
-- boss ability row: @boss | time | spellId | type? | spellName?
local sid = tonumber(f[3])
if sid then
local ms, pidx = Format.ParseTimeField(f[2] or "")
local b = { timeMs = ms, spellId = sid }
if pidx then b.phaseIndex = pidx end
local ty = trim(f[4] or ""); if ty ~= "" then b.type = ty end
local bn = trim(f[5] or ""); if bn ~= "" then b.spellName = bn end
current.boss = current.boss or {}
current.boss[#current.boss + 1] = b
end
elseif #f >= 3 and current then
local spellId = tonumber(f[2])
if spellId then
local ms, pidx = Format.ParseTimeField(f[1] or "")
local r = {
timeMs = ms,
spellId = spellId,
player = trim(f[3]),
}
if pidx then r.phaseIndex = pidx end
local cat = trim(f[4] or ""); if cat ~= "" then r.category = cat end
local sn = trim(f[5] or ""); if sn ~= "" then r.spellName = sn end
local al = trim(f[6] or ""); if al ~= "" then r.alert = al end
current.reminders[#current.reminders + 1] = r
end
end
end
end
end
return plans, meta
end