Repository navigation
Expand file tree
/
Copy pathGuides.lua
More file actions
644 lines (594 loc) · 28.2 KB
/
Copy pathGuides.lua
File metadata and controls
644 lines (594 loc) · 28.2 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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
-- The routes Headstart knows, and each player's own edits of them.
--
-- Headstart itself comes without routes. They come from a routes addon beside it (it queues its guides
-- in the global HeadstartRoutes before Headstart loads; taken in below), handed to YR:ShipGuide as text.
-- At login, RegisterGuides gives RestedXP either the player's edited version (YippRouteDB.custom: the
-- route editor saved them until it was taken out, 2026-10-29) or, if they never edited it, the shipped one.
-- RestedXP reads guides added after it has loaded straight away (RXPGuides.RegisterGuide), so nothing
-- is written to disk.
--
-- A guide is plain RestedXP text: a header, then steps that each start with a line "step".
local _, YR = ...
YR.shipped = {} -- { { key, text } } in load order
local byKey = {}
function YR:ShipGuide(key, text)
local g = { key = key, text = text }
YR.shipped[#YR.shipped + 1] = g
byKey[key] = g
end
-- A routes addon's guides: { { key = , text = } }, queued before this file ran (it loads first).
if type(HeadstartRoutes) == "table" then
for _, g in ipairs(HeadstartRoutes) do
if type(g) == "table" and type(g.key) == "string" and type(g.text) == "string" then YR:ShipGuide(g.key, g.text) end
end
end
local function Custom()
YippRouteDB.custom = YippRouteDB.custom or {}
return YippRouteDB.custom
end
-- The text RestedXP gets for this guide: the player's version if they saved one.
function YR:GuideText(key)
local c = Custom()[key]
return c and c.text or (byKey[key] and byKey[key].text)
end
function YR:IsCustom(key)
return Custom()[key] ~= nil
end
-- Routes edited or imported before the rename still name the old group: move them to the new one.
local OLD_GROUP = "YippRoute Launch %(A%)"
local function Renamed(text)
return (text:gsub(OLD_GROUP, "Headstart Launch (A)"))
end
-- A new Headstart version never overwrites an edited route: RestedXP keeps getting the player's
-- version. Each edited route remembers the shipped text it started from (base), so when an update
-- ships a different route, the player is told and can merge: our new route with their edits on top.
local function Sum(s)
local h = #s
for i = 1, #s, 7 do h = (h * 31 + s:byte(i)) % 2147483647 end
return h
end
function YR:HasUpdate(key)
local c, g = Custom()[key], byKey[key]
return c and g and c.base ~= nil and c.base ~= g.text or false
end
local function TellUpdates()
for _, g in ipairs(YR.shipped) do
local c = Custom()[g.key]
if YR:HasUpdate(g.key) and c.told ~= Sum(g.text) then
c.told = Sum(g.text)
YR.Print(("this version ships a new %s route. You're still on your own edited one: open /headstart,"
.. " Routes, to take the update with your edits kept."):format(YR.GuideName(g.key)))
end
end
end
-- The whole route side can be off (the user, 2026-10-02): Headstart is also a QoL addon, and someone who
-- only wants that shouldn't get our routes in RestedXP. Account option routes, off for a new install;
-- on for every character of the account once turned on. With it off: no routes handed to RestedXP
-- (ours or imported), and nothing keyed to them (YR:RoutesFor is empty, so nothing is kept or bought
-- for a route; no death-skip release, no Camping 101 reminder). Changing it takes a /reload.
-- An account that already played our routes (run logs or edited routes) when this came in keeps them:
-- decided once, the first time, before this login's run log starts.
function YR.RoutesOn()
if YippRouteDB.routes == nil then
YippRouteDB.routes = (next(YippRouteDB.runs or {}) ~= nil or next(YippRouteDB.custom or {}) ~= nil)
end
return YippRouteDB.routes == true
end
function YR.SetRoutesOn(on)
on = on and true or false
local was = YR.RoutesOn()
YippRouteDB.routes = on
if on ~= was then
YR.Print(on and "routes on: /reload to load them into RestedXP." or "routes off: /reload to take them out of RestedXP.")
end
end
-- RestedXP's "go to" icon (in front of every ".goto ... >> text" line) is a crop of the minimap POI
-- sheet that comes out as a red smudge on Forever (the user, 2026-10-03: "the icon is just so ugly").
-- Ours instead while the routes are on: a map pin (art/pin.tga), grey like RestedXP's talk bubble.
-- RestedXP reads the icon each time it draws the window, so setting it before its guides load is enough.
local PIN = "|TInterface\\AddOns\\Headstart\\art\\pin:0:0:0:0:64:64:0:64:0:64:210:210:210|t"
function YR.RxpIcons()
if type(RXP) ~= "table" or type(RXP.icons) ~= "table" then return end
for _, tag in ipairs({ "goto", "groundgoto", "flygoto", "explore" }) do RXP.icons[tag] = PIN end
end
-- RestedXP's pins on the world map sit under the game's own quest markers (the user, 2026-10-07: "the
-- points are always below the map objectives"): it adds them through HereBeDragons without saying which
-- layer, and the library's default is the area-POI layer, below quests. HereBeDragons takes a layer as
-- its last argument, so while the routes are on RestedXP's pins are given a higher one: the first of
-- LAYERS this client's world map knows (none known: nothing is changed). Other addons' pins are passed
-- on as they are.
local LAYERS = { "PIN_FRAME_LEVEL_WAYPOINT_LOCATION", "PIN_FRAME_LEVEL_SUPER_TRACKED_CONTENT",
"PIN_FRAME_LEVEL_SUPER_TRACKED_QUEST", "PIN_FRAME_LEVEL_ACTIVE_QUEST", "PIN_FRAME_LEVEL_TOPMOST" }
local function PinLayer()
local map = WorldMapFrame
local manager = map and type(map.GetPinFrameLevelsManager) == "function" and map:GetPinFrameLevelsManager()
local known = type(manager) == "table" and manager.definitions
if type(known) ~= "table" then return nil end
for _, want in ipairs(LAYERS) do
for _, d in ipairs(known) do
if type(d) == "table" and d.frameLevelType == want then return want end
end
end
end
local pinsRaised = false
function YR.RxpPinsOnTop()
if pinsRaised or type(RXP) ~= "table" or type(LibStub) ~= "table" then return end
local pins = LibStub("HereBeDragons-Pins-2.0", true)
if type(pins) ~= "table" or type(pins.AddWorldMapIconMap) ~= "function" then return end
pinsRaised = true
local add, layer = pins.AddWorldMapIconMap, nil
pins.AddWorldMapIconMap = function(self, ref, icon, uiMapID, x, y, showFlag, frameLevel, ...)
if ref == RXP and frameLevel == nil and YR.RoutesOn() then
if layer == nil then layer = PinLayer() or false end
frameLevel = layer or nil
end
return add(self, ref, icon, uiMapID, x, y, showFlag, frameLevel, ...)
end
end
--- RestedXP's ".turnin -ID" (done at once when you don't have the quest) leaves the hand-in's reward
--- choice nil, and GetStepQuestReward compares it with 0 when "quest reward automation" is on: an
--- error at every such hand-in ("attempt to compare number with nil", the user, 2026-10-08), and the
--- quest isn't picked at the NPC. Our routes use that mark a lot. No choice set means none: 0.
local rewardGuarded = false
function YR.RxpRewardGuard()
if rewardGuarded or type(RXP) ~= "table" or type(RXP.GetStepQuestReward) ~= "function" then return end
rewardGuarded = true
local get = RXP.GetStepQuestReward
RXP.GetStepQuestReward = function(titleOrId, ...)
local element = titleOrId and type(RXP.questTurnIn) == "table" and RXP.questTurnIn[titleOrId]
if type(element) == "table" and element.reward == nil then return 0, element end
return get(titleOrId, ...)
end
end
function YR:RegisterGuides()
if not YR.RoutesOn() then return end
YR.RxpIcons()
YR.RxpPinsOnTop()
YR.RxpRewardGuard()
for key, c in pairs(Custom()) do
c.text = Renamed(c.text)
-- edited before routes remembered their base: take today's shipped route as it
if c.base == nil and byKey[key] then c.base = byKey[key].text end
end
TellUpdates()
if UnitFactionGroup("player") == "Horde" or not (RXPGuides and RXPGuides.RegisterGuide) then return end
YR:ScanGroupRoutes()
for _, g in ipairs(YR.shipped) do
for _, text in ipairs(YR.RoleVersions(YR:GuideText(g.key), "solo")) do
local ok, err = pcall(RXPGuides.RegisterGuide, text)
if not ok then YR.Print(("guide %s did not load: %s"):format(g.key, tostring(err))) end
end
end
if YR.Role then YR:RegisterRole(YR:Role()) end
-- What the routes need and which quests they take is read from every route's text the first time
-- it's asked for, which was the first item tooltip or quest window of the session (a hitch). Read
-- them a few seconds after login instead, one a frame apart from the other.
if C_Timer and C_Timer.After then
C_Timer.After(4, function() if YR.RouteNeed then YR.RouteNeed(0) end end)
C_Timer.After(5, function() if YR.RouteQuest then YR.RouteQuest(0) end end)
end
end
-- Group routes. Two marks in a step:
-- "#share N" the Nth pick-up of quests the party can share, off the others' path (marked by
-- tools/share_split.py): one member takes it and shares it, the others skip the walk.
-- Pick-ups go round the group: a duo's A takes 1, 3, 5..., B 2, 4, 6...; a trio's A, B, C
-- take turns.
-- "#role X" (or "#role A,B", or "#role solo") only that role does the step, or only a player alone.
-- Steps without a mark are everyone's. A route with #share marks gets Duo A/B and Trio A/B/C versions;
-- "#roles A,B" or "#roles A,B,C" in the header limits it to that group size. RestedXP gets one route
-- per role, "... (Duo A)" and so on, plus the solo route under the route's own name. Their #next points
-- to the same role's version of the next route where there is one.
local LETTERS = { "A", "B", "C" }
local function HasRole(step, role, size)
local n = tonumber(step:match("\n%s*#share%s+(%d+)"))
if n and size then return LETTERS[(n - 1) % size + 1] == role end
local roles = step:match("\n%s*#role%s+([%w,]+)")
if not roles then return true end
for r in roles:gmatch("[^,]+") do if r == role then return true end end
return false
end
-- The group sizes a route has versions for: { 2, 3 }, { 2 }, { 3 } or none.
local function Sizes(text)
local roles = text:match("\n#roles%s+([%w,]+)")
if roles then return { select(2, roles:gsub("[^,]+", "")) } end
-- split pick-ups, group-only steps, or dungeon steps: a group plays it differently
if text:find("\n%s*#share%s+%d") or text:find("\n%s*#role%s") or text:find("\n%s*%.dungeon%s") then
return { 2, 3 }
end
return {}
end
local function Variant(text, role, suffix, size)
local header, steps = YR.SplitSteps(text)
local kept = {}
for _, step in ipairs(steps) do
-- a group runs the dungeons: RestedXP's dungeon steps (".dungeon DM", shown only when that
-- dungeon is ticked in its settings) always show, and their no-dungeon versions (".dungeon !DM")
-- go. Alone, RestedXP's own dungeon setting decides as usual.
local skipIfGroup = size and step:find("\n%s*%.dungeon%s+!%a")
if HasRole(step, role, size) and not skipIfGroup then
step = step:gsub("\n%s*#role%s+[%w,]+", ""):gsub("\n%s*#share%s+%d+", "")
if size then step = step:gsub("\n%s*%.dungeon%s+%a+[^\n]*", "") end
kept[#kept + 1] = step
end
end
header = ("\n" .. header):gsub("\n#roles[^\n]*", "") -- a leading newline, so line 1 matches like the rest
if suffix then
header = header:gsub("\n#name ([^\n]+)", function(n) return "\n#name " .. n .. " (" .. suffix .. ")" end, 1)
-- the next route, as this role, when it is one of ours with the same roles
header = header:gsub("\n#next ([^\n]+)", function(list)
local out = {}
for entry in list:gmatch("[^;]+") do
local group, name = entry:match("^(.-)\\(.+)$")
local ours = group == "Headstart Launch (A)" and YR.GroupRouteNames and YR.GroupRouteNames[name]
out[#out + 1] = (ours and ours[suffix]) and (entry .. " (" .. suffix .. ")") or entry
end
return "\n#next " .. table.concat(out, ";")
end, 1)
end
return YR.JoinSteps(header:sub(2), kept)
end
-- The texts to register for one route: solo first, then one per role.
-- only: one role ("Duo A"), "solo" for the solo route alone, or nil for every version.
function YR.RoleVersions(text, only)
text = text or ""
local out = {}
local sizes = Sizes("\n" .. text)
-- no group marks at all (most routes): the solo route is the text as it is, without taking every
-- step apart and putting it together again at login
local plain = #sizes == 0 and not text:find("#role", 1, true) and not text:find("#share", 1, true)
if only == nil or only == "solo" then out[1] = plain and text or Variant(text, "solo") end
for _, size in ipairs(sizes) do
local label = size == 2 and "Duo" or "Trio"
for i = 1, size do
local role = label .. " " .. LETTERS[i]
if only == nil or only == role then out[#out + 1] = Variant(text, LETTERS[i], role, size) end
end
end
return out
end
-- RestedXP parses every route it is given, and the group versions of the long routes are big: it gets
-- the solo routes and this character's role's versions; picking another role registers that role's.
local registered = {}
function YR:RegisterRole(role)
if not (RXPGuides and RXPGuides.RegisterGuide) or not role or role == "solo" or registered[role] then return end
-- (as RegisterGuides: picking a role with the routes off gave RestedXP every group route all the same)
if not YR.RoutesOn() or UnitFactionGroup("player") == "Horde" then return end
registered[role] = true
for _, g in ipairs(YR.shipped) do
for _, text in ipairs(YR.RoleVersions(YR:GuideText(g.key), role)) do
pcall(RXPGuides.RegisterGuide, text)
end
end
end
-- Which of our routes have group versions, by name: { [name] = { ["Duo A"] = true, ... } }, so a
-- role's route can hand over to the same role's next route.
function YR:ScanGroupRoutes()
YR.GroupRouteNames = {}
for _, g in ipairs(YR.shipped) do
local text = "\n" .. (YR:GuideText(g.key) or "")
local name = text:match("\n#name ([^\n]+)")
local sizes = Sizes(text)
if name and #sizes > 0 then
local set = {}
for _, size in ipairs(sizes) do
for i = 1, size do set[(size == 2 and "Duo " or "Trio ") .. LETTERS[i]] = true end
end
YR.GroupRouteNames[name] = set
end
end
end
-- The routes a character of this race can follow: its starting route and every route its #next
-- lines lead to (ours only), as a set of keys. A Dwarf never plays Human Elwynn's quests.
-- The starting route's later #next entries are options (Coldridge's "5-6 Kharanos to Elwynn"):
-- RestedXP goes on with the first, so an option's own routes count only while RestedXP has one of
-- them loaded. Else a Dwarf on Dun Morogh would keep boar meat for Elwynn's Pie for Billy.
local START = { Human = "northshire", NightElf = "shadowglen", Dwarf = "coldridge", Gnome = "coldridge" }
function YR:RoutesFor(race)
if not YR.RoutesOn() then return {} end
local byName = {}
for _, g in ipairs(YR.shipped) do
local name = (("\n" .. (YR:GuideText(g.key) or "")):match("\n#name ([^\n]+)"))
if name then byName[name] = g.key end
end
local function Nexts(key, firstOnly)
local out = {}
for line in ("\n" .. (YR:GuideText(key) or "")):gmatch("\n#next ([^\n]+)") do
for entry in (line:gsub("%s*<<.*$", "")):gmatch("[^;]+") do
local name = strtrim(entry):gsub("^Headstart Launch %(A%)\\", "")
if byName[name] then out[#out + 1] = byName[name] end
if firstOnly then break end
end
end
return out
end
local function Walk(set, from)
local todo = { unpack(from) }
while #todo > 0 do
local key = table.remove(todo)
if key and not set[key] then
set[key] = true
for _, n in ipairs(Nexts(key)) do todo[#todo + 1] = n end
end
end
return set
end
local start = START[race]
if not start then return {} end
local set = Walk({ [start] = true }, Nexts(start, true))
-- the guide RestedXP has loaded ("01-05 ..." padded; Duo/Trio versions add to the name)
local current = type(RXP) == "table" and type(RXP.currentGuide) == "table" and RXP.currentGuide.name
current = type(current) == "string" and current:gsub("%f[%d]0+(%d)", "%1") or nil
for _, opt in ipairs(Nexts(start)) do
if not set[opt] then
local own = Walk({}, { opt })
for key in pairs(set) do own[key] = nil end
local on = false
for key in pairs(own) do
local name = YR.GuideName(key)
if current and name and current:sub(1, #name) == name then on = true end
end
if on then for key in pairs(own) do set[key] = true end end
end
end
return set
end
-- header, { step texts } - each step text starts with "step" and has no trailing newline
function YR.SplitSteps(text)
local first = text:find("\nstep[^\n]*\n") or text:find("\nstep[^\n]*$")
if not first then return text, {} end
local header, rest = text:sub(1, first), text:sub(first + 1)
local steps, from = {}, 1
while true do
local nextStep = rest:find("\nstep[^\n]*\n", from) or rest:find("\nstep[^\n]*$", from)
if not nextStep then
steps[#steps + 1] = (rest:sub(from):gsub("%s+$", ""))
break
end
steps[#steps + 1] = (rest:sub(from, nextStep - 1):gsub("%s+$", ""))
from = nextStep + 1
end
return header, steps
end
function YR.JoinSteps(header, steps)
return header .. table.concat(steps, "\n") .. "\n"
end
-- One line for the editor: what the step asks you to do, without colours and icons.
local function Plain(s)
-- RestedXP's own colour tokens (|cRXP_ENEMY_ ...) as well as ordinary |cAARRGGBB colours
return (s:gsub("|cRXP_%u+_", ""):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|T.-|t", ""):gsub("|n", " "))
end
function YR.StepSummary(step)
local classes = step:match("^step%s*<<%s*([^\n]+)")
local parts = {}
for verb, rest in step:gmatch("\n%s*%.(%a+)%s[^\n]->>%s*([^\n]+)") do
if verb == "accept" or verb == "turnin" or verb == "complete" or verb == "train" or verb == "collect"
or verb == "hs" or verb == "deathskip" or verb == "xp" or verb == "destroy" or verb == "cast" then
parts[#parts + 1] = Plain(rest)
end
end
if #parts == 0 then
for text in step:gmatch("\n%s*>>%s*([^\n]+)") do parts[#parts + 1] = Plain(text) end
end
if #parts == 0 then
local where = step:match("\n%s*%.goto[^\n]->>%s*([^\n]+)")
parts[1] = where and Plain(where) or "(no text)"
end
local line = table.concat(parts, "; ")
return classes and (line .. " [" .. classes .. "]") or line
end
-- What other files read from a route's text and keep (the needs, Group play's quests, the shopping
-- list): rebuilt from the new text after an edit, a revert or a merge.
local function ForgetDerived()
if YR.ForgetNeeds then YR:ForgetNeeds() end
if YR.ForgetRouteQuests then YR:ForgetRouteQuests() end
if YR.ForgetShopping then YR:ForgetShopping() end
end
function YR:SaveCustom(key, header, steps)
ForgetDerived()
local old = Custom()[key]
Custom()[key] = { text = YR.JoinSteps(header, steps), saved = time(),
base = old and old.base or (byKey[key] and byKey[key].text) }
end
function YR:RevertGuide(key)
ForgetDerived()
Custom()[key] = nil
end
-- Which steps of a stay, in b's order: for each index of a, the index in b it matches (longest
-- common subsequence of whole step texts), or nil where a's step is gone.
local function Match(a, b)
local n, m = #a, #b
local L = {}
for i = n + 1, 1, -1 do
L[i] = {}
for j = m + 1, 1, -1 do
if i > n or j > m then L[i][j] = 0
elseif a[i] == b[j] then L[i][j] = L[i + 1][j + 1] + 1
else L[i][j] = math.max(L[i + 1][j], L[i][j + 1]) end
end
end
local map, i, j = {}, 1, 1
while i <= n and j <= m do
if a[i] == b[j] then map[i] = j i = i + 1 j = j + 1
elseif L[i + 1][j] >= L[i][j + 1] then i = i + 1
else j = j + 1 end
end
return map
end
-- What one side did to base, as hunks: base steps s .. e-1 replaced by ins (s == e: ins put in
-- before base step s; ins empty: steps removed). In base order, never overlapping each other.
local function Hunks(base, side)
local map = Match(base, side)
local hunks = {}
local i0, j0 = 0, 0
local function Gap(i1, j1)
if i1 - i0 > 1 or j1 - j0 > 1 then
local ins = {}
for j = j0 + 1, j1 - 1 do ins[#ins + 1] = side[j] end
hunks[#hunks + 1] = { s = i0 + 1, e = i1, ins = ins }
end
end
for i = 1, #base do
if map[i] then Gap(i, map[i]) i0, j0 = i, map[i] end
end
Gap(#base + 1, #side + 1)
return hunks
end
-- Whether two hunks touch the same part of base: overlapping removals, two insertions at one spot,
-- or an insertion inside what the other removes. (Next to each other is fine.)
local function Touch(a, b)
if a.s == a.e and b.s == b.e then return a.s == b.s end
if a.s == a.e then return b.s < a.s and a.s < b.e end
if b.s == b.e then return a.s < b.s and b.s < a.e end
return a.s < b.e and b.s < a.e
end
-- base steps s .. e-1 with one side's hunks (inside that range) applied
local function Apply(base, hunks, s, e)
local out, p = {}, s
for _, h in ipairs(hunks) do
for i = p, h.s - 1 do out[#out + 1] = base[i] end
for _, x in ipairs(h.ins) do out[#out + 1] = x end
p = h.e
end
for i = p, e - 1 do out[#out + 1] = base[i] end
return out
end
local function Same(a, b)
if #a ~= #b then return false end
for i = 1, #a do if a[i] ~= b[i] then return false end end
return true
end
-- Three-way merge of step lists (like diff3): theirs (the new shipped route) with mine (the
-- player's edits of base) applied. Changes to different parts of the route are all kept; where both
-- changed the same part differently, the player's version of that part wins. Returns the steps and
-- how many parts that happened in.
function YR.MergeSteps(base, mine, theirs)
local all = {}
for _, h in ipairs(Hunks(base, mine)) do h.mine = true all[#all + 1] = h end
for _, h in ipairs(Hunks(base, theirs)) do all[#all + 1] = h end
table.sort(all, function(a, b) if a.s ~= b.s then return a.s < b.s end return a.e < b.e end)
local out, clashes, pos = {}, 0, 1
local function Put(list) for _, x in ipairs(list) do out[#out + 1] = x end end
local k = 1
while k <= #all do
-- a group: this hunk and every later one touching the part it has grown to
local g = { all[k] }
local gs, ge = all[k].s, all[k].e
k = k + 1
while k <= #all and Touch({ s = gs, e = ge }, all[k]) do
g[#g + 1] = all[k]
gs, ge = math.min(gs, all[k].s), math.max(ge, all[k].e)
k = k + 1
end
for i = pos, gs - 1 do out[#out + 1] = base[i] end
local m, t = {}, {}
for _, h in ipairs(g) do if h.mine then m[#m + 1] = h else t[#t + 1] = h end end
if #t == 0 then Put(Apply(base, m, gs, ge))
elseif #m == 0 then Put(Apply(base, t, gs, ge))
else
local rm, rt = Apply(base, m, gs, ge), Apply(base, t, gs, ge)
if not Same(rm, rt) then clashes = clashes + 1 end
Put(rm)
end
pos = ge
end
for i = pos, #base do out[#out + 1] = base[i] end
return out, clashes
end
-- Take the new shipped route into the player's edited one. Returns the number of clashes.
function YR:MergeUpdate(key)
ForgetDerived()
local c, g = Custom()[key], byKey[key]
if not (c and g and c.base) then return end
local bHead, bSteps = YR.SplitSteps(c.base)
local mHead, mSteps = YR.SplitSteps(c.text)
local tHead, tSteps = YR.SplitSteps(g.text)
local steps, clashes = YR.MergeSteps(bSteps, mSteps, tSteps)
local head = (mHead == bHead) and tHead or mHead
Custom()[key] = { text = YR.JoinSteps(head, steps), saved = time(), base = g.text,
told = c.told, before = { text = c.text, base = c.base } }
return clashes
end
-- Right after a merge (until the next save): back to the route as it was before it.
function YR:CanUndoMerge(key)
local c = Custom()[key]
return c and c.before ~= nil or false
end
function YR:UndoMerge(key)
ForgetDerived()
local c = Custom()[key]
if not (c and c.before) then return end
Custom()[key] = { text = c.before.text, base = c.before.base, saved = time(), told = c.told }
end
-- Keep the edited route as it is and stop offering this update.
function YR:KeepMine(key)
local c, g = Custom()[key], byKey[key]
if c and g then c.base = g.text end
end
-- The guide's name as RestedXP shows it, from its header.
function YR.GuideName(key)
local text = YR:GuideText(key) or ""
return text:match("\n#name ([^\n]+)") or key
end
-- Guides pasted in with Import that don't replace one we ship: kept by name, registered like ours.
local function Extra()
YippRouteDB.extra = YippRouteDB.extra or {}
return YippRouteDB.extra
end
function YR:RegisterExtras()
if not YR.RoutesOn() then return end
if UnitFactionGroup("player") == "Horde" or not (RXPGuides and RXPGuides.RegisterGuide) then return end
for name, text in pairs(Extra()) do
text = Renamed(text)
Extra()[name] = text
local ok, err = pcall(RXPGuides.RegisterGuide, text)
if not ok then YR.Print(("imported guide %s did not load: %s"):format(name, tostring(err))) end
end
end
-- Import: a guide whose #name is one we ship becomes this player's version of it; any other
-- guide is kept as an extra. Returns what happened, or nil and why not.
function YR:ImportGuide(text)
text = (text or ""):gsub("\r\n", "\n")
local name = text:match("#name ([^\n]+)")
if not name or not text:find("\nstep") then return nil, "that isn't a RestedXP guide (no #name or no steps)" end
for _, g in ipairs(YR.shipped) do
if (g.text:match("\n#name ([^\n]+)")) == name then
local header, steps = YR.SplitSteps(text)
YR:SaveCustom(g.key, header, steps)
return "replaced your " .. name
end
end
Extra()[name] = text
return "added " .. name
end
-- ---------------------------------------------------------------------------
-- Recorded actions -> guide steps
-- ---------------------------------------------------------------------------
local STEP_KINDS = { accept = true, complete = true, turnin = true, buy = true, learn = true,
hearth = true, death = true, level = true }
-- One line for the list of what this character did.
local LABEL = { accept = "Took", complete = "Finished", turnin = "Handed in", buy = "Bought", learn = "Learned",
hearth = "Hearthed", death = "Died", level = "Reached level" }
function YR.ActionLabel(e, titles)
local kind = e[2]
if kind == "buy" then return ("Bought %s"):format(e.name or e.item) end
if kind == "learn" then return ("Learned %s"):format(e.name or e.spell) end
if kind == "level" then return ("Reached level %d"):format(e.to or e[4]) end
if kind == "hearth" or kind == "death" then return LABEL[kind] end
return ("%s %s"):format(LABEL[kind], titles[e[3]] or e.title or e[3])
end
-- This character's actions, oldest first: { { e = event, label = text } }
function YR:RunActions()
local r = YippRouteDB.runs and YippRouteDB.runs[YR.CharKey()]
local out, titles, touched = {}, {}, {}
for _, e in ipairs(r and r.ev or {}) do
if e[2] == "accept" then titles[e[3]] = e.title end
-- a delivery quest is "complete" the moment it is taken, and the log can note it again at the
-- turn-in: a completion within a few seconds of taking or handing in the quest is no step
local noise = e[2] == "complete" and touched[e[3]] and e[1] - touched[e[3]] <= 3
if e[2] == "accept" or e[2] == "turnin" then touched[e[3]] = e[1] end
if STEP_KINDS[e[2]] and not noise then
out[#out + 1] = { e = e, label = YR.ActionLabel(e, titles) }
end
end
return out
end