4475 lines
168 KiB
Lua
4475 lines
168 KiB
Lua
-- Freewood-OctoWoW-Codex: lernt Quests, Kreaturen und Beute beim Spielen.
|
|
--
|
|
-- Gespeichert (Account-weit) in Codex.db:
|
|
-- quests[questID] = {
|
|
-- t = Titel, lvl = Stufe, pl = niedrigste Spielerstufe bei Annahme, zone = Karte der Annahme,
|
|
-- give = Ref, turn = Ref, -- Ref = { k="npc"|"object"|"other", id, n, m, x, y }
|
|
-- xp, money, rep = { [Fraktion] = Wert },
|
|
-- fixed = { { id, c } }, choice = { { id, c } }, chosen = { [itemID] = Anzahl },
|
|
-- obj = { { ty, name, txt, req, ids = { [npcID] = Treffer } } },
|
|
-- qi = { [itemID] = { ["u<id>"|"o<id>"] = Anzahl } }, -- Quest-Items und ihre Quellen
|
|
-- seen, done, first, last, partial }
|
|
-- units[npcID] = { n, l1, l2, c, cells = { [m*GRID^2+zelle] = n },
|
|
-- k = gelootete Leichen, coin, coinN, d = { [itemID] = { n, q, qi } },
|
|
-- alt = { k, d } } -- alt: zweites Beutefenster (Haeuten usw.)
|
|
-- objects[objID] = wie units (ohne Stufen)
|
|
-- names[itemID] = Itemname (fuer die Suche)
|
|
-- xp[spielerstufe][gegnerstufe][klasse] = { n, sum, min, max } Kill-Erfahrung ohne Zusaetze
|
|
-- xpb[...] gleiche Form, aber mit Ruhe-/Gruppen-/Raidzusatz
|
|
-- units[npcID].xp[spielerstufe] = { n, sum, min, max }
|
|
-- units[npcID].xm[spielerstufe][gegnerstufe] = { n, sum, min, max }
|
|
-- quests[id].obj[i].item = Item-ID, gelernt aus dem Beutefenster, wenn der Zaehler steigt
|
|
-- objEmpty = Ziele ohne lesbaren Namen (max. 5, fuer /fwcodex test)
|
|
-- objects[id].n = Name, vom Objekt-Tooltip gelernt
|
|
-- units[npcID].f = Tierfamilie (Text)
|
|
-- remote[absender] = { seen, n, cursor, hash, rec = { ["u123"] = bereinigter Datensatz } } empfangene Daten (CodexShare.lua)
|
|
-- sync = { stamps = { ["u123"] = { sig, upd } }, sent, received } Aenderungsstaende eigener Daten
|
|
-- settings.share = ein Schalter fuer das gesamte Teilen
|
|
--
|
|
-- Geheime Werte (secret values) werden mit Util.Clean verworfen, jede Erfassung laeuft geschuetzt.
|
|
-- Kein Kampflog: Beute, Quest-Fenster und Ziele werden ausserhalb des Kampfes gelesen.
|
|
|
|
local ADDON, NS = ...
|
|
|
|
local FW = _G.Freewood
|
|
if not FW or not FW:RequireAPI(1, ADDON) then
|
|
return
|
|
end
|
|
|
|
local Util = FW.Util
|
|
local L = FW.L
|
|
|
|
local GRID = 200
|
|
local MAX_CELLS = 600
|
|
local MAX_DROPS = 80
|
|
local MAX_OBJ = 12
|
|
local MAX_REWARDS = 12
|
|
local MAX_IDS = 12
|
|
local MAX_NAMES = 6000
|
|
local MAX_LOOTED_GUIDS = 300
|
|
local RECENT_SECS = 60 -- so lange passt ein gelootetes Quest-Item zu einem steigenden Zaehler
|
|
|
|
local function GetMeta(field)
|
|
local fn = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata
|
|
if fn then
|
|
local ok, value = pcall(fn, ADDON, field)
|
|
if ok then
|
|
return value
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local Codex = FW:NewModule("Codex", {
|
|
version = GetMeta("Version") or "dev",
|
|
title = L.CODEX_TITLE,
|
|
description = L.CODEX_DESC,
|
|
defaults = {
|
|
quests = {},
|
|
units = {},
|
|
objects = {},
|
|
names = {},
|
|
ttk = {}, -- Kampfdauer { [Klasse] = { [Spielerstufe] = { [Stufenabstand] = { n, sum, min, max } } } }, Zehntelsekunden
|
|
qtimer = {}, -- laufende Quest-Zeitnehmer (siehe CodexTime.lua)
|
|
istats = {}, -- Itemwerte { [itemID] = { [Schluessel] = Zahl } }, vom Spiel gelesen (nur lokal)
|
|
disp = {}, -- Creature-Display-IDs fuer 2D-Bilder { [npcID] = displayID } (nur lokal)
|
|
cnames = {}, -- von Hand eingetragene Itemnamen { [itemID] = { de, en } }
|
|
xp = {},
|
|
xpb = {},
|
|
xr = {}, -- Erfahrung je Kill MIT Ruhebonus (Gesamtwert), gleiche Form wie xp (nur lokal)
|
|
xpUnparsed = {},
|
|
objEmpty = {},
|
|
remote = {},
|
|
sync = { stamps = {}, sent = 0, received = 0 },
|
|
settings = { enabled = true, share = true, shareDefaultOn = true },
|
|
recipes = {}, -- Rezepte der Berufe { [Beruf] = { [Name] = { it, rg, ob } } } (siehe CodexRecipes.lua, nur lokal)
|
|
wrq = {}, -- Ruf-Anforderungen der Waren { [haendlerID] = { v = 1, [itemID] = { f = Fraktion, s = Stufe 1-8, ok = erfuellt } } } (nur lokal)
|
|
vfac = {}, -- Fraktion von Haendlern { [haendlerID] = "A" | "H" } (nur lokal)
|
|
ied = {}, -- Eigenschaften von Items { [itemID] = { ed = { z, dg } } }, wird geteilt
|
|
inames = {}, -- vom Spiel gelernte Instanznamen { [instanceID] = Name } (nur lokal)
|
|
path = {}, -- Wegenetz { [welt] = { [zellKey] = Maske } }, siehe CodexPath.lua (nur lokal)
|
|
fav = {}, -- Favoriten und Notizen { ["q123"] = { note, t } }, nur lokal
|
|
dnotes = {}, -- Dev-Notizen { ["q123"] = { n = Text, t } }, nur lokal, nur mit Freewood-OctoWoW-Dev und Bearbeitungsrecht
|
|
},
|
|
})
|
|
NS.Codex = Codex
|
|
FW.Codex = Codex
|
|
Codex.GRID = GRID
|
|
|
|
local state = {
|
|
pool = nil, -- Ruhe-Vorrat (GetXPExhaustion) vor dem naechsten Kill
|
|
xpOpen = 0, -- Kills, deren Ruhebonus noch bestimmt wird
|
|
xpJobs = {},
|
|
detail = nil, -- zuletzt geoeffnetes Annahme-Fenster
|
|
turn = nil, -- zuletzt geoeffnetes Abgabe-Fenster
|
|
objState = {}, -- [questID] = { [i] = Anzahl }
|
|
recentItems = {}, -- [itemID] = { t, quest, qid }: zuletzt gelootete Items
|
|
requested = {}, -- [itemID] = true: Itemdaten schon angefordert
|
|
pendingInfo = 0, -- noch nicht angekommene Itemdaten
|
|
objNames = {}, -- [objectID] = Name, vom Tooltip gelernt, bevor das Objekt gelootet wurde
|
|
repBuf = {}, -- { t, name, amount }
|
|
lootedGuids = {}, -- Leichen, die in dieser Sitzung schon ein Beutefenster hatten
|
|
lootedItems = {}, -- [guid] = { [itemID] = true }: dort schon gezaehlte Items
|
|
lootedCount = 0,
|
|
noteAt = {}, -- Drosselung fuer NoteUnit
|
|
stats = { quests = 0, loot = 0, errors = 0 },
|
|
}
|
|
Codex.runtime = state
|
|
|
|
------------------------------------------------------------------------
|
|
-- Kleine Pruefer
|
|
------------------------------------------------------------------------
|
|
|
|
local function Num(v)
|
|
if Util.IsSecret(v) then
|
|
return nil
|
|
end
|
|
if type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge then
|
|
return v
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function Str(v, max)
|
|
if Util.IsSecret(v) then
|
|
return nil
|
|
end
|
|
if type(v) == "string" and v ~= "" then
|
|
return v:sub(1, max or 80)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function Strip(text)
|
|
text = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|T.-|t", "")
|
|
return (text:gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
local function Now()
|
|
return time()
|
|
end
|
|
|
|
-- Fuehrt fn geschuetzt aus; Fehler (zum Beispiel durch geheime Werte) werden still gezaehlt
|
|
local function Safe(fn, ...)
|
|
local ok, a, b, c = pcall(fn, ...)
|
|
if not ok then
|
|
state.stats.errors = state.stats.errors + 1
|
|
if FW.settings and FW.settings.debug then
|
|
FW:Debug("Codex: " .. tostring(a))
|
|
end
|
|
return nil
|
|
end
|
|
return a, b, c
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Orte und Raster
|
|
------------------------------------------------------------------------
|
|
|
|
local function PlayerPoint()
|
|
local loc = Util.GetPlayerLocation()
|
|
if loc and loc.mapID and loc.x and loc.y then
|
|
return loc.mapID, loc.x, loc.y
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function CellKey(m, x, y)
|
|
local cx = math.min(GRID - 1, math.max(0, math.floor(x * GRID)))
|
|
local cy = math.min(GRID - 1, math.max(0, math.floor(y * GRID)))
|
|
return m * GRID * GRID + cy * GRID + cx
|
|
end
|
|
|
|
local function NoteInstance(ent)
|
|
local id = Codex.CurrentInstance and Codex.CurrentInstance()
|
|
if not id then return end
|
|
local iz = ent.iz
|
|
if not iz then
|
|
iz = {}
|
|
ent.iz = iz
|
|
end
|
|
if iz[id] then
|
|
iz[id] = iz[id] + 1
|
|
return
|
|
end
|
|
local n = 0
|
|
for _ in pairs(iz) do n = n + 1 end
|
|
if n < 3 then iz[id] = 1 end
|
|
end
|
|
|
|
local function AddCell(ent)
|
|
NoteInstance(ent)
|
|
local m, x, y = PlayerPoint()
|
|
if not m then
|
|
return
|
|
end
|
|
ent.cells = ent.cells or {}
|
|
local key = CellKey(m, x, y)
|
|
if ent.cells[key] then
|
|
ent.cells[key] = ent.cells[key] + 1
|
|
return
|
|
end
|
|
local n = 0
|
|
for _ in pairs(ent.cells) do
|
|
n = n + 1
|
|
if n >= MAX_CELLS then
|
|
return
|
|
end
|
|
end
|
|
ent.cells[key] = 1
|
|
end
|
|
|
|
-- Rueckgabe: Karten-ID und Zelle als Liste { { m, cx, cy, n } }
|
|
function Codex.DecodeCells(cells)
|
|
local list = {}
|
|
for key, n in pairs(cells or {}) do
|
|
local m = math.floor(key / (GRID * GRID))
|
|
local idx = key % (GRID * GRID)
|
|
list[#list + 1] = { m = m, cx = idx % GRID, cy = math.floor(idx / GRID), n = n }
|
|
end
|
|
return list
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Instanzen: das Spiel nennt in Dungeons oft keinen Namen, aber die Instanz-ID (GetInstanceInfo, 8. Rueckgabe).
|
|
-- Die ID ist sprachunabhaengig. Als "Zone" wird sie negativ gespeichert (-389 = Flammenschlund).
|
|
------------------------------------------------------------------------
|
|
|
|
Codex.INSTANCES = {
|
|
[30] = { "Alteractal", "Alterac Valley" }, [33] = { "Burg Schattenfang", "Shadowfang Keep" },
|
|
[34] = { "Das Verlies", "The Stockade" }, [36] = { "Die Todesminen", "The Deadmines" },
|
|
[43] = { "Die Höhlen des Wehklagens", "Wailing Caverns" }, [47] = { "Kral der Klingenhauer", "Razorfen Kraul" },
|
|
[48] = { "Die Tiefschwarze Grotte", "Blackfathom Deeps" }, [70] = { "Uldaman", "Uldaman" },
|
|
[90] = { "Gnomeregan", "Gnomeregan" }, [109] = { "Der Tempel von Atal'Hakkar", "The Temple of Atal'Hakkar" },
|
|
[129] = { "Hügel der Klingenhauer", "Razorfen Downs" }, [189] = { "Das Scharlachrote Kloster", "Scarlet Monastery" },
|
|
[209] = { "Zul'Farrak", "Zul'Farrak" }, [229] = { "Schwarzfelsspitze", "Blackrock Spire" },
|
|
[230] = { "Die Schwarzfels-Tiefen", "Blackrock Depths" }, [249] = { "Onyxias Hort", "Onyxia's Lair" },
|
|
[289] = { "Scholomance", "Scholomance" }, [309] = { "Zul'Gurub", "Zul'Gurub" },
|
|
[329] = { "Stratholme", "Stratholme" }, [349] = { "Maraudon", "Maraudon" },
|
|
[369] = { "Die Tiefenbahn", "The Deeprun Tram" }, [389] = { "Flammenschlund", "Ragefire Chasm" },
|
|
[409] = { "Geschmolzener Kern", "Molten Core" }, [429] = { "Düsterbruch", "Dire Maul" },
|
|
[469] = { "Pechschwingenhort", "Blackwing Lair" }, [489] = { "Kriegshymnenschlucht", "Warsong Gulch" },
|
|
[509] = { "Ruinen von Ahn'Qiraj", "Ruins of Ahn'Qiraj" }, [529] = { "Arathibecken", "Arathi Basin" },
|
|
[531] = { "Tempel von Ahn'Qiraj", "Temple of Ahn'Qiraj" }, [533] = { "Naxxramas", "Naxxramas" },
|
|
}
|
|
|
|
-- Name einer Instanz: eingebaute Liste (Deutsch / Englisch), sonst vom Spiel gelernt, sonst "Instanz #ID"
|
|
function Codex.InstanceName(id)
|
|
local en = Codex.Lang() == "en"
|
|
local b = Codex.INSTANCES[id]
|
|
if b then return en and b[2] or b[1] end
|
|
local learned = Codex.db and Codex.db.inames and Codex.db.inames[id]
|
|
if learned then return learned end
|
|
return string.format(FW.L.CODEX_INSTANCE_UNKNOWN or "Instance #%d", id)
|
|
end
|
|
|
|
-- Instanz-ID, in der wir gerade stehen (nil in der offenen Welt)
|
|
function Codex.CurrentInstance()
|
|
if not (IsInInstance and GetInstanceInfo) then return nil end
|
|
local ok, inside, itype = pcall(IsInInstance)
|
|
if not ok or Util.IsSecret(inside) or not inside or Util.IsSecret(itype) or itype == "none" then return nil end
|
|
local ok2, name, _, _, _, _, _, _, id = pcall(GetInstanceInfo)
|
|
if not ok2 or Util.IsSecret(id) or type(id) ~= "number" or id < 1 or id > 100000 then return nil end
|
|
-- lesbaren Namen fuer unbekannte Instanzen merken (nur lokal, in der aktuellen Sprache)
|
|
if type(name) == "string" and name ~= "" and not Util.IsSecret(name) and not Codex.INSTANCES[id] and Codex.db then
|
|
Codex.db.inames = Codex.db.inames or {}
|
|
Codex.db.inames[id] = name:sub(1, 60)
|
|
end
|
|
return id
|
|
end
|
|
|
|
-- Kreaturtyp (UnitCreatureType, je nach Client englisch oder deutsch) -> fester Kurzname
|
|
Codex.CT_NAMES = {
|
|
["beast"] = "beast", ["wildtier"] = "beast", ["bestie"] = "beast",
|
|
["humanoid"] = "humanoid", ["humanoide"] = "humanoid",
|
|
["undead"] = "undead", ["untoter"] = "undead", ["untote"] = "undead",
|
|
["demon"] = "demon", ["d\195\164mon"] = "demon", ["daemon"] = "demon", ["dämon"] = "demon",
|
|
["dragonkin"] = "dragonkin", ["drachkin"] = "dragonkin",
|
|
["elemental"] = "elemental", ["elementar"] = "elemental",
|
|
["giant"] = "giant", ["riese"] = "giant",
|
|
["mechanical"] = "mechanical", ["mechanisch"] = "mechanical",
|
|
["critter"] = "critter", ["kleintier"] = "critter",
|
|
["totem"] = "totem",
|
|
["gas cloud"] = "gascloud", ["gaswolke"] = "gascloud",
|
|
}
|
|
Codex.CT_CODES = {
|
|
beast = true, humanoid = true, undead = true, demon = true, dragonkin = true, elemental = true,
|
|
giant = true, mechanical = true, critter = true, totem = true, gascloud = true, other = true, pet = true,
|
|
}
|
|
|
|
function Codex.MapName(mapID)
|
|
if type(mapID) == "number" and mapID < 0 then
|
|
return Codex.InstanceName(-mapID)
|
|
end
|
|
if C_Map and C_Map.GetMapInfo and mapID then
|
|
local ok, info = pcall(C_Map.GetMapInfo, mapID)
|
|
if ok and type(info) == "table" and type(info.name) == "string" then
|
|
return info.name
|
|
end
|
|
end
|
|
return mapID and ("#" .. mapID) or "?"
|
|
end
|
|
|
|
-- Hauptkarte einer Zellenmenge: Karte mit den meisten Treffern
|
|
function Codex.MainMap(cells)
|
|
local per, best, bestN = {}, nil, 0
|
|
for key, n in pairs(cells or {}) do
|
|
local m = math.floor(key / (GRID * GRID))
|
|
per[m] = (per[m] or 0) + n
|
|
if per[m] > bestN then
|
|
best, bestN = m, per[m]
|
|
end
|
|
end
|
|
return best
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- GUIDs, Einheiten, Objekte
|
|
------------------------------------------------------------------------
|
|
|
|
-- "Creature-0-4621-1-27730-4107-0000477262" -> "npc", 4107; GameObject -> "object", id
|
|
local function ParseGUID(guid)
|
|
if Util.IsSecret(guid) or type(guid) ~= "string" then
|
|
return nil
|
|
end
|
|
local kind, _, _, _, _, id = strsplit("-", guid)
|
|
id = tonumber(id)
|
|
if not id then
|
|
local hk, hid = Util.ParseHexGUID(guid)
|
|
if not hk then
|
|
return nil
|
|
end
|
|
kind, id = hk, hid
|
|
end
|
|
if kind == "Creature" or kind == "Vehicle" then
|
|
return "npc", id
|
|
end
|
|
if kind == "GameObject" then
|
|
return "object", id
|
|
end
|
|
return nil
|
|
end
|
|
Codex.ParseGUID = ParseGUID
|
|
|
|
function Codex:Unit(id, create)
|
|
local u = self.db.units[id]
|
|
if not u and create then
|
|
u = {}
|
|
self.db.units[id] = u
|
|
end
|
|
return u
|
|
end
|
|
|
|
function Codex:Object(id, create)
|
|
local o = self.db.objects[id]
|
|
if not o and create then
|
|
o = {}
|
|
self.db.objects[id] = o
|
|
end
|
|
return o
|
|
end
|
|
|
|
function Codex:Quest(id, create)
|
|
local q = self.db.quests[id]
|
|
if not q and create then
|
|
q = { seen = 0, done = 0, first = Now() }
|
|
self.db.quests[id] = q
|
|
state.stats.quests = state.stats.quests + 1
|
|
end
|
|
return q
|
|
end
|
|
|
|
local function NoteName(itemID, name)
|
|
name = Str(name, 80)
|
|
if not itemID or not name then
|
|
return
|
|
end
|
|
local names = Codex.db.names
|
|
if names[itemID] == name then
|
|
return
|
|
end
|
|
if names[itemID] == nil then
|
|
local n = 0
|
|
for _ in pairs(names) do
|
|
n = n + 1
|
|
if n >= MAX_NAMES then
|
|
return
|
|
end
|
|
end
|
|
end
|
|
names[itemID] = name
|
|
end
|
|
|
|
-- Qualitaet (Farbe) eines Items merken, damit Namen auch farbig sind, wenn das Spiel das Item gerade nicht kennt
|
|
function Codex.RememberQuality(itemID, quality)
|
|
if type(itemID) ~= "number" or type(quality) ~= "number" or quality < 0 or quality > 8 then
|
|
return
|
|
end
|
|
local iq = Codex.db.iq
|
|
if not iq then
|
|
iq = {}
|
|
Codex.db.iq = iq
|
|
end
|
|
if iq[itemID] == quality then
|
|
return
|
|
end
|
|
if iq[itemID] == nil then
|
|
local n = 0
|
|
for _ in pairs(iq) do
|
|
n = n + 1
|
|
if n >= 8000 then
|
|
return
|
|
end
|
|
end
|
|
end
|
|
iq[itemID] = quality
|
|
end
|
|
|
|
function Codex.QualityOf(itemID)
|
|
local info = Util.GetItem(itemID)
|
|
if info and info.quality then
|
|
Codex.RememberQuality(itemID, info.quality)
|
|
return info.quality
|
|
end
|
|
return Codex.db.iq and Codex.db.iq[itemID] or nil
|
|
end
|
|
|
|
Codex.AddCell = AddCell
|
|
Codex.NoteName = NoteName
|
|
Codex.Str = Str
|
|
Codex.Safe = Safe
|
|
|
|
-- Einheit am Unit-Token beobachten (Name, Stufe, Fundort). Nur angreifbare Kreaturen.
|
|
function Codex:NoteUnit(unit)
|
|
if Util.IsSecret(unit) or not UnitExists(unit) then
|
|
return
|
|
end
|
|
local isPlayer, controlled = UnitIsPlayer(unit), UnitPlayerControlled(unit)
|
|
if Util.IsSecret(isPlayer) or Util.IsSecret(controlled) or isPlayer or controlled then
|
|
return
|
|
end
|
|
local canAttack = UnitCanAttack and UnitCanAttack("player", unit)
|
|
if Util.IsSecret(canAttack) or not canAttack then
|
|
return
|
|
end
|
|
local kind, id = ParseGUID(UnitGUID(unit))
|
|
if kind ~= "npc" then
|
|
return
|
|
end
|
|
local now = GetTime and GetTime() or 0
|
|
if state.noteAt[id] and now - state.noteAt[id] < 1.5 then
|
|
return
|
|
end
|
|
state.noteAt[id] = now
|
|
|
|
local u = self:Unit(id, true)
|
|
local name = Str(UnitName(unit), 60)
|
|
if name then
|
|
u.n = name
|
|
end
|
|
local lvl = Num(UnitLevel and UnitLevel(unit))
|
|
if lvl and lvl > 0 then
|
|
u.l1 = u.l1 and math.min(u.l1, lvl) or lvl
|
|
u.l2 = u.l2 and math.max(u.l2, lvl) or lvl
|
|
end
|
|
local class = UnitClassification and Str(UnitClassification(unit), 16)
|
|
if class and class ~= "normal" and class ~= "trivial" and class ~= "minus" then
|
|
u.c = class
|
|
end
|
|
local family = UnitCreatureFamily and Str(UnitCreatureFamily(unit), 40)
|
|
if family then
|
|
u.f = family
|
|
end
|
|
local ctype = UnitCreatureType and Str(UnitCreatureType(unit), 40)
|
|
local ctcode = ctype and Codex.CT_NAMES[ctype:lower()]
|
|
if ctcode then
|
|
u.ct = ctcode
|
|
end
|
|
AddCell(u)
|
|
end
|
|
|
|
-- Objektnamen kommen vom Tooltip (Mouseover auf Truhe, Kraut usw.); data.id ist die Objekt-ID
|
|
function Codex:OnObjectTooltip(data)
|
|
Safe(function()
|
|
if type(data) ~= "table" or Util.IsSecret(data) or not self.db.settings.enabled then
|
|
return
|
|
end
|
|
if TooltipUtil and TooltipUtil.SurfaceArgs then
|
|
pcall(TooltipUtil.SurfaceArgs, data)
|
|
end
|
|
local id = Num(data.id)
|
|
local line = type(data.lines) == "table" and data.lines[1] or nil
|
|
local name = type(line) == "table" and Str(line.leftText, 60) or nil
|
|
if not id or not name then
|
|
return
|
|
end
|
|
Codex.lastObject = { id = id, name = name, t = (GetTime and GetTime()) or 0 }
|
|
local o = self.db.objects[id]
|
|
if o then
|
|
o.n = name
|
|
else
|
|
local count = 0
|
|
for _ in pairs(state.objNames) do count = count + 1 end
|
|
if count >= 200 then
|
|
state.objNames = {}
|
|
end
|
|
state.objNames[id] = name
|
|
end
|
|
end)
|
|
end
|
|
|
|
function Codex:OnTargetChanged()
|
|
Safe(self.NoteUnit, self, "target")
|
|
if self.Names then Safe(self.Names.Read, self.Names, "target", "target") end
|
|
end
|
|
|
|
function Codex:OnMouseover()
|
|
Safe(self.NoteUnit, self, "mouseover")
|
|
if self.Names then Safe(self.Names.Read, self.Names, "mouseover", "mouse") end
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Quest-Fenster lesen
|
|
------------------------------------------------------------------------
|
|
|
|
-- NPC oder Objekt, mit dem gerade gesprochen wird. nil bei Item-Quests.
|
|
local function ReadInteraction()
|
|
local ref = { k = "other" }
|
|
local guid = UnitGUID and UnitGUID("npc")
|
|
local kind, id = ParseGUID(guid)
|
|
if kind then
|
|
ref.k = kind
|
|
ref.id = id
|
|
ref.n = Str(UnitName("npc"), 60)
|
|
if kind == "npc" then
|
|
local u = Codex:Unit(id, true)
|
|
if ref.n then
|
|
u.n = ref.n
|
|
end
|
|
else
|
|
local o = Codex:Object(id, true)
|
|
if ref.n then
|
|
o.n = ref.n
|
|
end
|
|
end
|
|
end
|
|
local m, x, y = PlayerPoint()
|
|
if m then
|
|
ref.m, ref.x, ref.y = m, Util.Round(x, 3), Util.Round(y, 3)
|
|
end
|
|
return ref
|
|
end
|
|
|
|
local function ItemIDFromLink(link)
|
|
link = Util.Clean(link)
|
|
if type(link) ~= "string" then
|
|
return nil
|
|
end
|
|
return tonumber(link:match("item:(%d+)"))
|
|
end
|
|
|
|
local function ReadRewardList(kind, count)
|
|
local list = {}
|
|
for i = 1, math.min(count or 0, MAX_REWARDS) do
|
|
local link = GetQuestItemLink and GetQuestItemLink(kind, i)
|
|
local id = ItemIDFromLink(link)
|
|
local name, _, c
|
|
if GetQuestItemInfo then
|
|
name, _, c = GetQuestItemInfo(kind, i)
|
|
end
|
|
if id then
|
|
list[#list + 1] = { id = id, c = Num(c) or 1 }
|
|
NoteName(id, name)
|
|
end
|
|
end
|
|
return list
|
|
end
|
|
|
|
local function ReadRewards()
|
|
local r = {}
|
|
r.xp = Num(GetRewardXP and GetRewardXP())
|
|
r.money = Num(GetRewardMoney and GetRewardMoney())
|
|
r.fixed = ReadRewardList("reward", Num(GetNumQuestRewards and GetNumQuestRewards()))
|
|
r.choice = ReadRewardList("choice", Num(GetNumQuestChoices and GetNumQuestChoices()))
|
|
return r
|
|
end
|
|
|
|
local function ApplyRewards(q, r, final)
|
|
if not r then
|
|
return
|
|
end
|
|
if r.xp and r.xp > 0 and (final or not q.xp) then
|
|
q.xp = r.xp
|
|
end
|
|
if r.money and (final or not q.money) then
|
|
q.money = r.money
|
|
end
|
|
if #r.fixed > 0 or final then
|
|
q.fixed = #r.fixed > 0 and r.fixed or nil
|
|
end
|
|
if #r.choice > 0 or final then
|
|
q.choice = #r.choice > 0 and r.choice or nil
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Quest-Ziele
|
|
------------------------------------------------------------------------
|
|
|
|
-- Nur das Verb ("getötet") ist kein Name: so entstanden Ziele ohne Namen, wenn der Server den Kreaturnamen noch nicht kannte
|
|
local VERBS = { "getötet", "besiegt", "erschlagen", "beseitigt", "vernichtet", "zerstört", "erledigt", "bezwungen",
|
|
"slain", "killed", "defeated", "destroyed", "vanquished", "eliminated" }
|
|
local VERB_ONLY = {}
|
|
for _, v in ipairs(VERBS) do VERB_ONLY[v] = true end
|
|
VERB_ONLY["zur strecke gebracht"] = true
|
|
function Codex.IsBadObjName(name)
|
|
return type(name) == "string" and VERB_ONLY[name:lower()] == true
|
|
end
|
|
|
|
-- Verb am Ende entfernen ("Wolf getötet" -> "Wolf"); nur das Verb ergibt nil
|
|
function Codex.StripKillVerb(name)
|
|
if type(name) ~= "string" then return nil end
|
|
name = name:gsub("^[%s:]+", ""):gsub("[%s:]+$", "")
|
|
for _ = 1, 2 do
|
|
local low = name:lower()
|
|
local cut
|
|
for _, v in ipairs(VERBS) do
|
|
local suffix = " " .. v
|
|
if #low > #suffix and low:sub(-#suffix) == suffix then cut = #suffix break end
|
|
end
|
|
if not cut then
|
|
local sfx = " zur strecke gebracht"
|
|
if #low > #sfx and low:sub(-#sfx) == sfx then cut = #sfx end
|
|
end
|
|
if not cut then break end
|
|
name = name:sub(1, #name - cut)
|
|
end
|
|
name = name:gsub("^[%s:]+", ""):gsub("[%s:]+$", "")
|
|
if name == "" or Codex.IsBadObjName(name) then return nil end
|
|
return name
|
|
end
|
|
|
|
local function ObjectiveName(text)
|
|
text = Strip(text)
|
|
local name = text:match("^(.-):%s*%d+%s*/%s*%d+") or text:match("^%d+%s*/%s*%d+%s+(.+)$") or text
|
|
-- Kill-Ziele heissen "Wolf getoetet" / "Wolf slain": das Verb gehoert nicht zum Namen
|
|
name = Codex.StripKillVerb(name) or ""
|
|
-- Nur Zaehler ohne Namen ("0/3"): kein Name
|
|
if name == "" or name:match("^%d+%s*/%s*%d+$") or Codex.IsBadObjName(name) then
|
|
return nil
|
|
end
|
|
return name:sub(1, 60)
|
|
end
|
|
|
|
-- Ziele ohne lesbaren Namen merken (fuer /fwcodex test)
|
|
local function NoteEmptyObjective(ty, text)
|
|
local list = Codex.db and Codex.db.objEmpty
|
|
if not list or #list >= 5 then
|
|
return
|
|
end
|
|
local entry = tostring(ty) .. " | " .. tostring(text):sub(1, 100)
|
|
for _, e in ipairs(list) do
|
|
if e == entry then
|
|
return
|
|
end
|
|
end
|
|
list[#list + 1] = entry
|
|
end
|
|
|
|
-- Liest die Ziele einer Quest: Liste { ty, text, name, have, req, done }
|
|
local function ReadObjectives(questID)
|
|
local list = {}
|
|
if C_QuestLog and C_QuestLog.GetQuestObjectives then
|
|
local objs = C_QuestLog.GetQuestObjectives(questID)
|
|
if type(objs) == "table" then
|
|
for _, o in ipairs(objs) do
|
|
if type(o) == "table" then
|
|
local text = Str(o.text, 120)
|
|
list[#list + 1] = {
|
|
ty = Str(o.type, 16) or "event",
|
|
text = text and Strip(text) or "",
|
|
have = Num(o.numFulfilled) or 0,
|
|
req = Num(o.numRequired) or 0,
|
|
done = o.finished and true or false,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
return list
|
|
end
|
|
local index = GetQuestLogIndexByID and GetQuestLogIndexByID(questID)
|
|
if index and GetNumQuestLeaderBoards then
|
|
for i = 1, GetNumQuestLeaderBoards(index) do
|
|
local text, ty, done = GetQuestLogLeaderBoard(i, index)
|
|
text = Str(text, 120)
|
|
local have, req = (text or ""):match("(%d+)%s*/%s*(%d+)")
|
|
list[#list + 1] = {
|
|
ty = Str(ty, 16) or "event",
|
|
text = text and Strip(text) or "",
|
|
have = tonumber(have) or 0,
|
|
req = tonumber(req) or 0,
|
|
done = done and true or false,
|
|
}
|
|
end
|
|
end
|
|
return list
|
|
end
|
|
|
|
local function StoreObjectives(q, list)
|
|
local old = q.obj or {}
|
|
local new = {}
|
|
for i, o in ipairs(list) do
|
|
if i > MAX_OBJ then
|
|
break
|
|
end
|
|
local prev = old[i]
|
|
local nm = o.text ~= "" and ObjectiveName(o.text) or nil
|
|
if not nm and prev and prev.name and not Codex.IsBadObjName(prev.name) then
|
|
nm = prev.name -- ein schon bekannter Name geht nicht wieder verloren
|
|
end
|
|
new[i] = {
|
|
ty = o.ty,
|
|
txt = o.text ~= "" and o.text:sub(1, 100) or nil,
|
|
name = nm,
|
|
req = o.req > 0 and o.req or nil,
|
|
ids = prev and prev.ids or nil,
|
|
item = prev and prev.item or nil,
|
|
}
|
|
if not new[i].name and not new[i].item then
|
|
NoteEmptyObjective(o.ty, o.text)
|
|
end
|
|
end
|
|
if #new > 0 then
|
|
q.obj = new
|
|
end
|
|
end
|
|
|
|
local function QuestLogInfo(questID)
|
|
if C_QuestLog and C_QuestLog.GetLogIndexForQuestID then
|
|
local index = C_QuestLog.GetLogIndexForQuestID(questID)
|
|
if index and C_QuestLog.GetInfo then
|
|
local info = C_QuestLog.GetInfo(index)
|
|
if type(info) == "table" then
|
|
return Str(info.title, 100), Num(info.level)
|
|
end
|
|
end
|
|
end
|
|
if GetQuestLogIndexByID and GetQuestLogTitle then
|
|
local index = GetQuestLogIndexByID(questID)
|
|
if index and index > 0 then
|
|
local title, level = GetQuestLogTitle(index)
|
|
return Str(title, 100), Num(level)
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function TrackObjectives(questID, list)
|
|
local counts = {}
|
|
for i, o in ipairs(list) do
|
|
counts[i] = o.have
|
|
end
|
|
state.objState[questID] = counts
|
|
end
|
|
|
|
-- Ziele aller Quests im Log pruefen; steigt ein Kill-Zaehler, wird das anvisierte Ziel gelernt
|
|
function Codex:CheckObjectives()
|
|
for questID, counts in pairs(state.objState) do
|
|
local list = ReadObjectives(questID)
|
|
local q = self:Quest(questID, false)
|
|
if #list == 0 then
|
|
state.objState[questID] = nil
|
|
else
|
|
for i, o in ipairs(list) do
|
|
local before = counts[i]
|
|
local entry = q and q.obj and q.obj[i]
|
|
if entry then
|
|
-- fehlender Name: aus dem aktuellen Text nachtragen (Itemnamen sind oft erst spaeter geladen)
|
|
if (not entry.name or Codex.IsBadObjName(entry.name)) and o.text ~= "" then
|
|
local nm = ObjectiveName(o.text)
|
|
if nm then
|
|
entry.name = nm
|
|
entry.txt = o.text:sub(1, 100)
|
|
if self.LinkObjectives then self.LinkObjectives(q, questID) end
|
|
end
|
|
end
|
|
if before and o.have > before then
|
|
if o.ty == "monster" then
|
|
self:LearnKillTarget(entry, o)
|
|
elseif o.ty == "item" then
|
|
self:LearnObjectiveItem(questID, entry, o.have - before, i)
|
|
end
|
|
end
|
|
end
|
|
counts[i] = o.have
|
|
end
|
|
if #list ~= #(q and q.obj or {}) and q then
|
|
StoreObjectives(q, list)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Steigt der Zaehler eines Sammel-Ziels, war es meist das Quest-Item, das gerade gelootet wurde.
|
|
-- Genau ein Kandidat (oder ein Namenstreffer) wird dem Ziel zugeordnet.
|
|
function Codex:LearnObjectiveItem(questID, entry, delta, idx)
|
|
if entry.item then
|
|
return
|
|
end
|
|
local now = Now()
|
|
local want = entry.name and entry.name:lower()
|
|
-- Kandidaten: kuerzlich gelootete Items dieser Quest (oder ohne Quest-Angabe); mit passender Menge zuerst
|
|
local strict, loose = {}, {}
|
|
local nStrict, nLoose, exact = 0, 0, nil
|
|
local key = tostring(questID) .. ":" .. tostring(idx or 0)
|
|
state.objRise = state.objRise or {}
|
|
local since = state.objRise[key] -- nur Beute nach dem letzten Anstieg dieses Zaehlers zaehlt
|
|
state.objRise[key] = now
|
|
for itemID, r in pairs(state.recentItems) do
|
|
if now - r.t <= RECENT_SECS and (not since or r.t > since) and (not r.qid or r.qid == 0 or r.qid == questID) then
|
|
loose[itemID] = true
|
|
nLoose = nLoose + 1
|
|
if not delta or not r.qty or r.qty == delta then
|
|
strict[itemID] = true
|
|
nStrict = nStrict + 1
|
|
end
|
|
local nm = self.db.names[itemID]
|
|
if want and nm and nm:lower() == want then
|
|
exact = itemID
|
|
end
|
|
end
|
|
end
|
|
local cands, n = strict, nStrict
|
|
if n == 0 then cands, n = loose, nLoose end
|
|
local found
|
|
if exact then
|
|
found = exact
|
|
else
|
|
-- ueber mehrere Zaehler-Anstiege schneiden sich die Kandidaten auf genau ein Item zusammen
|
|
state.objCand = state.objCand or {}
|
|
local prior = state.objCand[key]
|
|
if prior then
|
|
local cut, cn = {}, 0
|
|
for itemID in pairs(cands) do
|
|
if prior[itemID] then cut[itemID] = true cn = cn + 1 end
|
|
end
|
|
if cn > 0 then cands, n = cut, cn end
|
|
end
|
|
state.objCand[key] = cands
|
|
if n == 1 then
|
|
found = next(cands)
|
|
end
|
|
if found then state.objCand[key] = nil end
|
|
end
|
|
if found then
|
|
entry.item = found
|
|
-- Quelle(n) des ersten Fundes gleich eintragen (spaetere Funde laufen ueber die Item-ID)
|
|
local r = state.recentItems[found]
|
|
local q = self:Quest(questID, false)
|
|
if r and q and r.srcs then
|
|
q.qi = q.qi or {}
|
|
q.qi[found] = q.qi[found] or {}
|
|
for _, sk in ipairs(r.srcs) do
|
|
q.qi[found][sk] = (q.qi[found][sk] or 0) + 1
|
|
end
|
|
end
|
|
FW:Fire("CODEX_CHANGED", "quest", questID)
|
|
end
|
|
end
|
|
|
|
-- Quest-Zuordnungen von Hand: Index { kill[questID] = { ["u38"]=true }, item[questID][itemID] = { ["u38"]=true } }
|
|
local qsrc = { rev = -1 }
|
|
local function QuestSrcIndex()
|
|
local V = Codex.V()
|
|
if qsrc.rev == Codex.chainRev and qsrc.v == V then return qsrc end
|
|
qsrc = { rev = Codex.chainRev, v = V, kill = {}, item = {}, rel = {}, relOf = {} }
|
|
for _, def in ipairs({ { "units", "u" }, { "objects", "o" } }) do
|
|
for id, e in pairs(V[def[1]] or {}) do
|
|
local ed = type(e) == "table" and e.ed or nil
|
|
if type(ed) == "table" then
|
|
for _, qid in ipairs(ed.qk or {}) do
|
|
local t = qsrc.kill[qid]
|
|
if not t then t = {} qsrc.kill[qid] = t end
|
|
t[def[2] .. id] = true
|
|
end
|
|
for _, s in ipairs(ed.qd or {}) do
|
|
local q, it = tostring(s):match("^(%d+):(%d+)$")
|
|
q, it = tonumber(q), tonumber(it)
|
|
if q and it then
|
|
local byItem = qsrc.item[q]
|
|
if not byItem then byItem = {} qsrc.item[q] = byItem end
|
|
local t = byItem[it]
|
|
if not t then t = {} byItem[it] = t end
|
|
t[def[2] .. id] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- gehoert zur Quest: von der Kreatur/dem Objekt/dem Item aus (qb) und von der Quest aus (qr)
|
|
local function Rel(qid, letter, id)
|
|
local r = qsrc.rel[qid]
|
|
if not r then r = { i = {}, u = {}, o = {} } qsrc.rel[qid] = r end
|
|
r[letter][id] = true
|
|
local key = letter .. id
|
|
local of = qsrc.relOf[key]
|
|
if not of then of = {} qsrc.relOf[key] = of end
|
|
of[qid] = true
|
|
end
|
|
for _, def in ipairs({ { "units", "u" }, { "objects", "o" }, { "ied", "i" } }) do
|
|
for id, e in pairs(V[def[1]] or {}) do
|
|
local ed = type(e) == "table" and e.ed or nil
|
|
if type(ed) == "table" then
|
|
for _, qid in ipairs(ed.qb or {}) do Rel(qid, def[2], id) end
|
|
end
|
|
end
|
|
end
|
|
for qid, q in pairs(V.quests or {}) do
|
|
local ed = type(q) == "table" and q.ed or nil
|
|
if type(ed) == "table" then
|
|
for _, tok in ipairs(ed.qr or {}) do
|
|
local k, n = tostring(tok):match("^([iuo]):(%d+)$")
|
|
if k and n then Rel(qid, k, tonumber(n)) end
|
|
end
|
|
end
|
|
end
|
|
return qsrc
|
|
end
|
|
-- Zugeordnet zu einer Quest: { i = {Item-IDs}, u = {Kreatur-IDs}, o = {Objekt-IDs} } (sortiert)
|
|
function Codex.QuestRelated(qid)
|
|
local r = QuestSrcIndex().rel[qid]
|
|
local out = { i = {}, u = {}, o = {} }
|
|
if r then
|
|
for letter in pairs(out) do
|
|
for id in pairs(r[letter]) do out[letter][#out[letter] + 1] = id end
|
|
table.sort(out[letter])
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
-- Quests, zu denen ein Item (letter "i"), eine Kreatur ("u") oder ein Objekt ("o") gehoert (sortiert)
|
|
function Codex.EntityQuests(letter, id)
|
|
local set = QuestSrcIndex().relOf[letter .. id]
|
|
local out = {}
|
|
for qid in pairs(set or {}) do out[#out + 1] = qid end
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
function Codex.QuestKillKeys(qid) return QuestSrcIndex().kill[qid] or {} end
|
|
function Codex.QuestItemKeys(qid, itemID)
|
|
local byItem = QuestSrcIndex().item[qid]
|
|
return byItem and byItem[itemID] or {}
|
|
end
|
|
function Codex.QuestManualItems(qid)
|
|
local out = {}
|
|
for itemID in pairs(QuestSrcIndex().item[qid] or {}) do out[#out + 1] = itemID end
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
-- Was ein Eintrag von Hand zugeordnet hat: Ziel-Quests (Liste) und Quest-Items (Liste "Quest:Item")
|
|
function Codex.ManualQuestLinks(kind, id)
|
|
local V = Codex.V()
|
|
local e = (kind == "units" and V.units or V.objects)[id]
|
|
local ed = type(e) == "table" and e.ed or nil
|
|
return ed and ed.qk or {}, ed and ed.qd or {}
|
|
end
|
|
|
|
-- Items eines Ziels: gelernte Item-ID, Namenstreffer oder (bei genau einem Item-Ziel) die Quest-Items der Quest
|
|
function Codex.ObjectiveItems(q, o, qid)
|
|
local ids = {}
|
|
if o.item then
|
|
ids[1] = o.item
|
|
return ids
|
|
end
|
|
local lname = o.name and o.name:lower()
|
|
if lname then
|
|
for itemID, name in pairs(Codex.db.names) do
|
|
if name:lower() == lname then
|
|
ids[#ids + 1] = itemID
|
|
end
|
|
end
|
|
end
|
|
if #ids == 0 and o.ty == "item" and q.qi then
|
|
local itemObjs = 0
|
|
for _, ob in ipairs(q.obj or {}) do
|
|
if ob.ty == "item" then
|
|
itemObjs = itemObjs + 1
|
|
end
|
|
end
|
|
if itemObjs == 1 then
|
|
for itemID in pairs(q.qi) do
|
|
ids[#ids + 1] = itemID
|
|
end
|
|
end
|
|
end
|
|
-- von Hand eingetragene Quest-Items (bei genau einem Item-Ziel)
|
|
if #ids == 0 and o.ty == "item" and qid then
|
|
local itemObjs = 0
|
|
for _, ob in ipairs(q.obj or {}) do
|
|
if ob.ty == "item" then itemObjs = itemObjs + 1 end
|
|
end
|
|
if itemObjs == 1 then
|
|
for _, itemID in ipairs(Codex.QuestManualItems(qid)) do ids[#ids + 1] = itemID end
|
|
end
|
|
end
|
|
table.sort(ids)
|
|
return ids
|
|
end
|
|
|
|
-- Kill-Ziele einer Quest mit den Kreaturen der Vorlage verknuepfen (IDs eintragen, fehlende Namen auffuellen).
|
|
-- Erst nach Namen; Ziele ohne Namen nur der Reihe nach und nur, wenn genau so viele Kreaturen uebrig sind wie Ziele.
|
|
function Codex.LinkObjectives(q, qid)
|
|
local Seed = Codex.Seed
|
|
if not (q and q.obj and Seed and Seed.Ready and Seed.Ready()) then return false end
|
|
local sq = Seed.Quest(qid)
|
|
if not sq then return false end
|
|
local units = {}
|
|
for _, r in ipairs(sq.obj or {}) do
|
|
if r.kind == "units" then units[#units + 1] = r.id end
|
|
end
|
|
if #units == 0 then return false end
|
|
local mons = {}
|
|
for _, o in ipairs(q.obj) do
|
|
if o.ty == "monster" then mons[#mons + 1] = o end
|
|
end
|
|
if #mons == 0 then return false end
|
|
local changed, used = false, {}
|
|
for _, o in ipairs(mons) do
|
|
if o.name and not Codex.IsBadObjName(o.name) then
|
|
local l = o.name:lower()
|
|
for _, uid in ipairs(units) do
|
|
local nm = Seed.Name("units", uid)
|
|
if nm and nm:lower() == l then
|
|
used[uid] = true
|
|
if not (o.ids and o.ids[uid]) then
|
|
o.ids = o.ids or {}
|
|
o.ids[uid] = 1
|
|
changed = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local open, free = {}, {}
|
|
for _, o in ipairs(mons) do
|
|
if (not o.name or Codex.IsBadObjName(o.name)) and not (o.ids and next(o.ids)) then open[#open + 1] = o end
|
|
end
|
|
for _, uid in ipairs(units) do
|
|
if not used[uid] then free[#free + 1] = uid end
|
|
end
|
|
if #open > 0 and #open == #free then
|
|
for i, o in ipairs(open) do
|
|
local nm = Seed.Name("units", free[i])
|
|
if nm then
|
|
o.name = nm
|
|
o.ids = { [free[i]] = 1 }
|
|
changed = true
|
|
end
|
|
end
|
|
end
|
|
return changed
|
|
end
|
|
|
|
-- einmal je Sitzung: alle bekannten Quests nachtragen (sobald die Vorlage geladen ist)
|
|
function Codex.RepairObjectives()
|
|
if Codex.objRepaired or not (Codex.db and Codex.db.quests) then return 0 end
|
|
local Seed = Codex.Seed
|
|
if not (Seed and Seed.Ready and Seed.Ready()) then return 0 end
|
|
Codex.objRepaired = true
|
|
local n = 0
|
|
for qid, q in pairs(Codex.db.quests) do
|
|
if type(q) == "table" and q.obj and Codex.LinkObjectives(q, qid) then n = n + 1 end
|
|
end
|
|
return n
|
|
end
|
|
|
|
function Codex:LearnKillTarget(objEntry, o)
|
|
local guid = UnitGUID and UnitGUID("target")
|
|
local kind, id = ParseGUID(guid)
|
|
if kind ~= "npc" then
|
|
return
|
|
end
|
|
local name = Str(UnitName("target"), 60)
|
|
if not name then
|
|
return
|
|
end
|
|
local want = (objEntry.name or o.text or ""):lower()
|
|
local have = name:lower()
|
|
if want == "" or not (want == have or want:find(have, 1, true) or have:find(want, 1, true)) then
|
|
return
|
|
end
|
|
objEntry.ids = objEntry.ids or {}
|
|
if objEntry.ids[id] == nil then
|
|
local n = 0
|
|
for _ in pairs(objEntry.ids) do
|
|
n = n + 1
|
|
end
|
|
if n >= MAX_IDS then
|
|
return
|
|
end
|
|
end
|
|
objEntry.ids[id] = (objEntry.ids[id] or 0) + 1
|
|
local u = self:Unit(id, true)
|
|
u.n = u.n or name
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Ruf
|
|
------------------------------------------------------------------------
|
|
|
|
local function FormatToPattern(fmt)
|
|
if type(fmt) ~= "string" then
|
|
return nil
|
|
end
|
|
local order = {}
|
|
local marked = fmt:gsub("%%(%d?)%$?([sd])", function(_, kind)
|
|
order[#order + 1] = kind
|
|
return "\1" .. kind .. "\2"
|
|
end)
|
|
marked = marked:gsub("([%^%$%(%)%.%[%]%*%+%-%?%%])", "%%%1")
|
|
marked = marked:gsub("\1s\2", "(.-)"):gsub("\1d\2", "(%%d+)")
|
|
return "^" .. marked .. "$", order
|
|
end
|
|
|
|
local repPatterns = {}
|
|
local function BuildRepPatterns()
|
|
repPatterns = {}
|
|
for _, def in ipairs({ { "FACTION_STANDING_INCREASED", 1 }, { "FACTION_STANDING_DECREASED", -1 } }) do
|
|
local fmt = _G[def[1]]
|
|
local pattern, order = FormatToPattern(fmt)
|
|
if pattern then
|
|
local nameIdx, amountIdx
|
|
for i, kind in ipairs(order) do
|
|
if kind == "s" and not nameIdx then nameIdx = i end
|
|
if kind == "d" and not amountIdx then amountIdx = i end
|
|
end
|
|
repPatterns[#repPatterns + 1] = { pattern = pattern, name = nameIdx or 1, amount = amountIdx or 2, sign = def[2] }
|
|
end
|
|
end
|
|
end
|
|
|
|
function Codex:OnFactionMessage(text)
|
|
Safe(function()
|
|
text = Util.Clean(text)
|
|
if type(text) ~= "string" then
|
|
return
|
|
end
|
|
if #repPatterns == 0 then
|
|
BuildRepPatterns()
|
|
end
|
|
for _, p in ipairs(repPatterns) do
|
|
local caps = { text:match(p.pattern) }
|
|
if #caps >= 2 then
|
|
local amount = tonumber(caps[p.amount])
|
|
local name = Str(caps[p.name], 60)
|
|
if amount and name then
|
|
state.repBuf[#state.repBuf + 1] = { t = GetTime and GetTime() or 0, name = name, amount = amount * p.sign }
|
|
while #state.repBuf > 20 do
|
|
table.remove(state.repBuf, 1)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Quest-Ereignisse
|
|
------------------------------------------------------------------------
|
|
|
|
-- Item aus der Tasche benutzt (Rechtsklick): merken, damit eine Quest ohne NPC dem Item zugeordnet werden kann
|
|
function Codex.NoteItemUse(bag, slot)
|
|
Safe(function()
|
|
local id
|
|
local ci = _G.C_Container
|
|
if ci and ci.GetContainerItemID then id = ci.GetContainerItemID(bag, slot)
|
|
elseif _G.GetContainerItemID then id = _G.GetContainerItemID(bag, slot) end
|
|
id = Num(id)
|
|
if id then state.itemUse = { id = id, t = GetTime and GetTime() or 0 } end
|
|
end)
|
|
end
|
|
|
|
-- Quest-Texte: bereinigt (keine Farbcodes), Absatz als {br}, der eigene Charaktername als $N
|
|
local function ReplaceWord(s, word, repl)
|
|
local low, lw, out, pos = s:lower(), word:lower(), {}, 1
|
|
local n = 0
|
|
while true do
|
|
local a, b = low:find(lw, pos, true)
|
|
if not a then break end
|
|
local before = a > 1 and s:sub(a - 1, a - 1) or ""
|
|
local after = s:sub(b + 1, b + 1)
|
|
n = n + 1
|
|
if not before:find("[%a\128-\255]") and not after:find("[%a\128-\255]") then
|
|
out[n] = s:sub(pos, a - 1) .. repl
|
|
else
|
|
out[n] = s:sub(pos, b)
|
|
end
|
|
pos = b + 1
|
|
end
|
|
out[n + 1] = s:sub(pos)
|
|
return table.concat(out)
|
|
end
|
|
|
|
local function CleanQText(s, cap)
|
|
if type(s) ~= "string" or s == "" then return nil end
|
|
s = s:gsub("|[cC]%x%x%x%x%x%x%x%x", ""):gsub("|[rR]", "")
|
|
s = s:gsub("\r", ""):gsub("\n%s*\n+", "{br}"):gsub("\n", " ")
|
|
s = s:gsub("|", ""):gsub("%c", " "):gsub("%s+", " ")
|
|
s = s:gsub("^%s+", ""):gsub("%s+$", "")
|
|
local name = UnitName and UnitName("player")
|
|
if type(name) == "string" and #name >= 2 then
|
|
s = ReplaceWord(s, name, "$N")
|
|
end
|
|
if #s < 3 then return nil end
|
|
return s:sub(1, cap)
|
|
end
|
|
Codex.CleanQText = CleanQText
|
|
|
|
local QTEXT_CAP = { d = 900, o = 400, p = 400, r = 600 }
|
|
|
|
-- Merkt Texte in der Quest (nur was noch fehlt). Rueckgabe: true, wenn etwas Neues dazukam
|
|
function Codex.StoreQText(q, tx)
|
|
if type(q) ~= "table" or type(tx) ~= "table" then return false end
|
|
local added = false
|
|
for f in pairs(QTEXT_CAP) do
|
|
local v = tx[f]
|
|
if type(v) == "string" and v ~= "" then
|
|
q.tx = q.tx or {}
|
|
if not q.tx[f] then
|
|
q.tx[f] = v
|
|
added = true
|
|
end
|
|
end
|
|
end
|
|
return added
|
|
end
|
|
|
|
-- Quest zu einem offenen Quest-Fenster (Fortschritt/Abgabe): ueber die Quest-ID, sonst ueber den Titel
|
|
local function QuestForWindow()
|
|
local qid = Num(GetQuestID and GetQuestID())
|
|
local quests = Codex.db and Codex.db.quests
|
|
if type(quests) ~= "table" then return nil end
|
|
if qid and quests[qid] then return qid, quests[qid] end
|
|
local title = Str(GetTitleText and GetTitleText(), 100)
|
|
if not title then return nil end
|
|
local found, foundQ
|
|
for id, q in pairs(quests) do
|
|
if type(q) == "table" and q.t == title then
|
|
if found then return nil end -- nicht eindeutig
|
|
found, foundQ = id, q
|
|
end
|
|
end
|
|
return found, foundQ
|
|
end
|
|
|
|
function Codex:OnQuestProgress()
|
|
Safe(function()
|
|
local id, q = QuestForWindow()
|
|
if not q then return end
|
|
if Codex.StoreQText(q, { p = CleanQText(GetProgressText and GetProgressText(), QTEXT_CAP.p) }) then
|
|
FW:Fire("CODEX_CHANGED", "quest", id)
|
|
end
|
|
end)
|
|
end
|
|
|
|
function Codex:OnQuestDetail(startItem)
|
|
Safe(function()
|
|
local si = Num(startItem) and Num(startItem) > 0 and Num(startItem) or nil
|
|
local nowT = GetTime and GetTime() or 0
|
|
local giver0 = ReadInteraction()
|
|
local learned
|
|
if not si and giver0.k == "other" and state.itemUse and nowT - state.itemUse.t <= 20 then
|
|
si, learned = state.itemUse.id, true -- Quest ohne NPC kurz nach Item-Benutzung: das Item startet sie
|
|
end
|
|
Codex.lastStartItem = { id = si, learned = learned, giver = giver0.k, at = nowT, used = state.itemUse and state.itemUse.id }
|
|
state.detail = {
|
|
si = si,
|
|
t = GetTime and GetTime() or 0,
|
|
title = Str(GetTitleText and GetTitleText(), 100),
|
|
qid = Num(GetQuestID and GetQuestID()),
|
|
tx = {
|
|
d = CleanQText(GetQuestText and GetQuestText(), QTEXT_CAP.d),
|
|
o = CleanQText(GetObjectiveText and GetObjectiveText(), QTEXT_CAP.o),
|
|
},
|
|
giver = giver0,
|
|
rew = ReadRewards(),
|
|
}
|
|
Codex.LearnChainOffer(state.detail)
|
|
end)
|
|
end
|
|
|
|
-- Folgequest lernen: Das Spiel bietet die naechste Quest einer Kette gleich nach der Abgabe beim selben NPC an.
|
|
-- Erkannt wird sie am Angebot (QUEST_DETAIL) in den ersten Sekunden nach der Abgabe; eine Quest, die man spaeter
|
|
-- von Hand beim selben NPC waehlt, zaehlt nicht. Es zaehlt der Zeitpunkt des Angebots, nicht der der Annahme, und
|
|
-- die Reihenfolge von Abgabe und Angebot ist egal (das Spiel meldet beides fast gleichzeitig).
|
|
Codex.CHAIN_WINDOW = 4
|
|
local function LinkChain(q, questID, t, giver)
|
|
local last = state.lastTurnIn
|
|
if not (last and q) or last.qid == questID then return false end
|
|
if math.abs((t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return false end
|
|
-- derselbe NPC (oder dasselbe Objekt)
|
|
local g, tn = giver, last.npc
|
|
if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return false end
|
|
q.pre = q.pre or {}
|
|
for _, p in ipairs(q.pre) do
|
|
if p == last.qid then return false end
|
|
end
|
|
if #q.pre >= 8 then return false end
|
|
q.pre[#q.pre + 1] = last.qid
|
|
Codex.chainRev = (Codex.chainRev or 0) + 1
|
|
return true
|
|
end
|
|
|
|
function Codex.LearnChain(q, questID, detail)
|
|
if not detail then return false end
|
|
return LinkChain(q, questID, detail.t, detail.giver)
|
|
end
|
|
|
|
-- Wird diese Quest gerade als unmittelbare Folgequest der letzten Abgabe angeboten (selber NPC, wenige Sekunden)? Rueckgabe: Quest-ID der Abgabe
|
|
local function FollowUpOf(detail)
|
|
local last = state.lastTurnIn
|
|
if not (detail and detail.qid and last) or last.qid == detail.qid then return nil end
|
|
if math.abs((detail.t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return nil end
|
|
local g, tn = detail.giver, last.npc
|
|
if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return nil end
|
|
return last.qid
|
|
end
|
|
|
|
-- Schon beim Angebot (ohne Annahme): die Quest wird nach der Abgabe vom selben NPC angeboten
|
|
local ChainOfferCore
|
|
function Codex.LearnChainOffer(detail)
|
|
local from = FollowUpOf(detail)
|
|
local learned = ChainOfferCore(detail)
|
|
-- der Levelplaner nimmt spontane Folgequests in seinen Guide auf
|
|
if from then FW:Fire("CODEX_FOLLOWUP_OFFER", detail.qid, from, detail) end
|
|
return learned
|
|
end
|
|
|
|
ChainOfferCore = function(detail)
|
|
if not (detail and detail.qid and state.lastTurnIn) then return false end
|
|
local V = Codex.db.quests
|
|
local q = V[detail.qid]
|
|
if not q then
|
|
if not detail.title then return false end
|
|
-- Probelauf, ohne den Datensatz anzulegen, wenn es keine Kette waere
|
|
local last = state.lastTurnIn
|
|
if last.qid == detail.qid or math.abs((detail.t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return false end
|
|
local g, tn = detail.giver, last.npc
|
|
if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return false end
|
|
q = Codex:Quest(detail.qid, true)
|
|
q.t = detail.title
|
|
if q.gif then q.give = detail.giver or q.give q.gif = nil else q.give = q.give or detail.giver end
|
|
end
|
|
if LinkChain(q, detail.qid, detail.t, detail.giver) then
|
|
FW:Fire("CODEX_CHANGED", "quest", detail.qid)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Vorquests einer Quest: aus den Daten (gelernt oder mitgeliefert) und der Classic-Vorlage
|
|
-- Quest, die ein Gegenstand startet: Item-ID oder nil
|
|
function Codex.QuestStartItem(id)
|
|
local q = Codex.V().quests[id]
|
|
return q and q.si or nil
|
|
end
|
|
|
|
-- Ketten-Index: Folgequests je Quest (aus den Vorquests der Daten und den "Folgequests" der Devs) und
|
|
-- Vorquests, die Devs ueber die Folgequest-Liste der Elternquest nachtragen
|
|
local nextCache = { view = nil, rev = -1, idx = nil, devPre = nil }
|
|
Codex.chainRev = 0
|
|
local function ChainCache()
|
|
local V = Codex.V()
|
|
if nextCache.view ~= V or nextCache.rev ~= Codex.chainRev or not nextCache.idx then
|
|
local idx, devPre = {}, {}
|
|
local function Link(parent, child)
|
|
idx[parent] = idx[parent] or {}
|
|
for _, x in ipairs(idx[parent]) do if x == child then return end end
|
|
table.insert(idx[parent], child)
|
|
end
|
|
for qid, q in pairs(V.quests) do
|
|
for _, p in ipairs(q.pre or {}) do
|
|
if p ~= qid then Link(p, qid) end
|
|
end
|
|
for _, n in ipairs(q.ed and q.ed.nx or {}) do
|
|
if n ~= qid then
|
|
Link(qid, n)
|
|
devPre[n] = devPre[n] or {}
|
|
table.insert(devPre[n], qid)
|
|
end
|
|
end
|
|
end
|
|
-- Folgequests aus dem Client-Cache des Servers (CodexWdbChain.lua)
|
|
local wdbPre = {}
|
|
for parent, child in pairs(Codex.WdbNext or {}) do
|
|
if parent ~= child then
|
|
Link(parent, child)
|
|
wdbPre[child] = wdbPre[child] or {}
|
|
table.insert(wdbPre[child], parent)
|
|
end
|
|
end
|
|
for _, l in pairs(idx) do table.sort(l) end
|
|
for _, l in pairs(devPre) do table.sort(l) end
|
|
for _, l in pairs(wdbPre) do table.sort(l) end
|
|
nextCache = { view = V, rev = Codex.chainRev, idx = idx, devPre = devPre, wdbPre = wdbPre }
|
|
end
|
|
return nextCache
|
|
end
|
|
|
|
-- Vorquests einer Quest: Dev-Eintrag ersetzt alles (Daten, Vorlage); sonst Daten, Vorlage und Beobachtungen
|
|
function Codex.QuestPre(id)
|
|
local out, have = {}, {}
|
|
local q = Codex.V().quests[id]
|
|
local dev = q and q.ed and q.ed.pre
|
|
if dev then
|
|
for _, p in ipairs(dev) do
|
|
if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end
|
|
end
|
|
else
|
|
for _, p in ipairs(q and q.pre or {}) do
|
|
if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end
|
|
end
|
|
local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil
|
|
for _, p in ipairs(sq and sq.pre or {}) do
|
|
if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end
|
|
end
|
|
-- Aus Beobachtungen abgeleitet (nur wenn nichts Sicheres bekannt ist)
|
|
if #out == 0 and Codex.Avail and q then
|
|
local own = Codex.db.quests[id]
|
|
for _, p in ipairs(Codex.Avail.InferredPre(q, own ~= nil and (own.po or 0) >= 1)) do
|
|
if p ~= id then out[#out + 1] = p end
|
|
end
|
|
end
|
|
end
|
|
-- Elternquests, die diese Quest als Folgequest eingetragen haben
|
|
for _, p in ipairs(ChainCache().devPre[id] or {}) do
|
|
if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end
|
|
end
|
|
if not dev then
|
|
for _, p in ipairs(ChainCache().wdbPre[id] or {}) do
|
|
if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Folgequests: Quests, die diese als Vorquest haben (Daten, Vorlage, Dev-Eintraege)
|
|
function Codex.QuestNext(id)
|
|
local V = Codex.V()
|
|
local out, have = {}, {}
|
|
for _, n in ipairs(ChainCache().idx[id] or {}) do have[n] = true out[#out + 1] = n end
|
|
if Codex.Seed and Codex.Seed.Ready() then
|
|
for _, n in ipairs(Codex.Seed.NextQuests(id)) do
|
|
local qn = V.quests[n]
|
|
local devPre = qn and qn.ed and qn.ed.pre
|
|
local overridden = false
|
|
if devPre then
|
|
overridden = true
|
|
for _, p in ipairs(devPre) do if p == id then overridden = false end end
|
|
end
|
|
if not have[n] and n ~= id and not overridden then have[n] = true out[#out + 1] = n end
|
|
end
|
|
end
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
|
|
-- Stellung in einer Kette: "start", "mid", "end" oder nil (keine Kette)
|
|
function Codex.ChainRole(id)
|
|
local hasPre, hasNext = #Codex.QuestPre(id) > 0, #Codex.QuestNext(id) > 0
|
|
if hasPre and hasNext then return "mid" end
|
|
if hasNext then return "start" end
|
|
if hasPre then return "end" end
|
|
return nil
|
|
end
|
|
|
|
function Codex:OnQuestAccepted(a, b)
|
|
Safe(function()
|
|
local questID = Num(b) or Num(a)
|
|
if not questID then
|
|
return
|
|
end
|
|
if Codex.InferDone then Codex.InferDone({ questID }) end
|
|
local q = self:Quest(questID, true)
|
|
q.seen = (q.seen or 0) + 1
|
|
q.last = Now()
|
|
q.partial = nil
|
|
local d = state.detail
|
|
local now = GetTime and GetTime() or 0
|
|
if d and now - d.t < 300 and (not d.qid or d.qid == questID) then
|
|
q.t = d.title or q.t
|
|
if d.si then
|
|
q.si = d.si
|
|
else
|
|
q.give = d.giver or q.give
|
|
end
|
|
ApplyRewards(q, d.rew, false)
|
|
Codex.StoreQText(q, d.tx)
|
|
if d.giver and d.giver.m then
|
|
q.zone = d.giver.m
|
|
end
|
|
end
|
|
state.detail = nil
|
|
Codex.LearnChain(q, questID, d)
|
|
|
|
local title, level = QuestLogInfo(questID)
|
|
q.t = q.t or title
|
|
q.lvl = level or q.lvl
|
|
local pl = Num(UnitLevel and UnitLevel("player"))
|
|
if pl then
|
|
q.pl = q.pl and math.min(q.pl, pl) or pl
|
|
end
|
|
local list = ReadObjectives(questID)
|
|
StoreObjectives(q, list)
|
|
TrackObjectives(questID, list)
|
|
FW:Fire("CODEX_CHANGED", "quest", questID)
|
|
end)
|
|
end
|
|
|
|
function Codex:OnQuestComplete()
|
|
Safe(function()
|
|
local qid0, q0 = QuestForWindow()
|
|
if q0 and Codex.StoreQText(q0, { r = CleanQText(GetRewardText and GetRewardText(), QTEXT_CAP.r) }) then
|
|
FW:Fire("CODEX_CHANGED", "quest", qid0)
|
|
end
|
|
state.turn = {
|
|
t = GetTime and GetTime() or 0,
|
|
qid = Num(GetQuestID and GetQuestID()),
|
|
npc = ReadInteraction(),
|
|
rew = ReadRewards(),
|
|
}
|
|
end)
|
|
end
|
|
|
|
-- Merkt sich die gewaehlte Belohnung (Aufruf von GetQuestReward(index))
|
|
function Codex:OnChoice(index)
|
|
Safe(function()
|
|
local turn = state.turn
|
|
index = Num(index)
|
|
if turn and index and turn.rew and turn.rew.choice[index] then
|
|
turn.chosen = turn.rew.choice[index].id
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Luecken fuellen: Zone aus Geber/Abgabe-NPC, Geber aus der Abgabe der (einzigen) Vorquest. Echte Werte ersetzen die abgeleiteten (q.gif).
|
|
function Codex.FillGaps()
|
|
local quests = Codex.db and Codex.db.quests
|
|
if type(quests) ~= "table" then return 0 end
|
|
local n = 0
|
|
for id, q in pairs(quests) do
|
|
if type(q) == "table" then
|
|
if not q.zone then
|
|
local sp = q.turn or q.give
|
|
if type(sp) == "table" and type(sp.m) == "number" then q.zone = sp.m n = n + 1 end
|
|
end
|
|
if not q.give and not q.si and type(q.pre) == "table" and #q.pre == 1 then
|
|
local p = quests[q.pre[1]]
|
|
local tn = type(p) == "table" and p.turn or nil
|
|
if type(tn) == "table" and tn.id then
|
|
q.give = { n = tn.n, m = tn.m, x = tn.x, y = tn.y, id = tn.id, k = tn.k }
|
|
q.gif = true
|
|
n = n + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if n > 0 then Codex.chainRev = (Codex.chainRev or 0) + 1 end
|
|
return n
|
|
end
|
|
|
|
function Codex:OnQuestTurnedIn(questID, xp, money)
|
|
Safe(function()
|
|
questID = Num(questID)
|
|
if not questID then
|
|
return
|
|
end
|
|
if Codex.MarkTurned then Codex.MarkTurned(questID, true) end
|
|
local q = self:Quest(questID, true)
|
|
q.done = (q.done or 0) + 1
|
|
q.last = Now()
|
|
q.partial = nil
|
|
xp, money = Num(xp), Num(money)
|
|
if xp and xp > 0 then
|
|
q.xp = xp
|
|
end
|
|
if money then
|
|
q.money = money
|
|
end
|
|
local turn = state.turn
|
|
local tnow = GetTime and GetTime() or 0
|
|
if turn and tnow - turn.t < 300 and (not turn.qid or turn.qid == questID) then
|
|
q.turn = turn.npc or q.turn
|
|
ApplyRewards(q, turn.rew, true)
|
|
if xp and xp > 0 then
|
|
q.xp = xp
|
|
end
|
|
if money then
|
|
q.money = money
|
|
end
|
|
if turn.chosen then
|
|
q.chosen = q.chosen or {}
|
|
q.chosen[turn.chosen] = (q.chosen[turn.chosen] or 0) + 1
|
|
end
|
|
end
|
|
state.turn = nil
|
|
state.objState[questID] = nil
|
|
q.t = q.t or QuestLogInfo(questID)
|
|
-- fuer das Lernen von Folgequests: diese Abgabe samt NPC und Zeit merken
|
|
state.lastTurnIn = { qid = questID, t = tnow, npc = q.turn }
|
|
|
|
-- Ruf: Meldungen kurz vor und nach der Abgabe einsammeln
|
|
local t0 = tnow
|
|
C_Timer.After(2, function() Safe(Codex.FillGaps) end)
|
|
C_Timer.After(1.5, function()
|
|
Safe(function()
|
|
local rep
|
|
for _, e in ipairs(state.repBuf) do
|
|
if e.t >= t0 - 1 and e.t <= t0 + 2 then
|
|
rep = rep or {}
|
|
rep[e.name] = (rep[e.name] or 0) + e.amount
|
|
end
|
|
end
|
|
if rep then
|
|
q.rep = rep
|
|
FW:Fire("CODEX_CHANGED", "quest", questID)
|
|
end
|
|
end)
|
|
end)
|
|
FW:Fire("CODEX_CHANGED", "quest", questID)
|
|
end)
|
|
end
|
|
|
|
function Codex:OnQuestRemoved(questID)
|
|
questID = Num(questID)
|
|
if questID then
|
|
state.objState[questID] = nil
|
|
end
|
|
end
|
|
|
|
function Codex:OnQuestLogUpdate()
|
|
if self.checkTimer then
|
|
return
|
|
end
|
|
self.checkTimer = true
|
|
C_Timer.After(0.4, function()
|
|
Codex.checkTimer = nil
|
|
Safe(Codex.CheckObjectives, Codex)
|
|
end)
|
|
end
|
|
|
|
-- Bereits angenommene Quests im Log erfassen (ohne Geber)
|
|
function Codex:ScanLog()
|
|
Safe(function()
|
|
local count
|
|
if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then
|
|
count = C_QuestLog.GetNumQuestLogEntries()
|
|
elseif GetNumQuestLogEntries then
|
|
count = GetNumQuestLogEntries()
|
|
end
|
|
local activeIDs = {}
|
|
for i = 1, Num(count) or 0 do
|
|
local title, level, questID, isHeader
|
|
if C_QuestLog and C_QuestLog.GetInfo then
|
|
local info = C_QuestLog.GetInfo(i)
|
|
if type(info) == "table" then
|
|
title, level, questID, isHeader = info.title, info.level, info.questID, info.isHeader
|
|
end
|
|
elseif GetQuestLogTitle then
|
|
local t, l, _, h, _, _, _, id = GetQuestLogTitle(i)
|
|
title, level, questID, isHeader = t, l, id, h
|
|
end
|
|
questID = Num(questID)
|
|
if questID and questID > 0 and not isHeader then
|
|
activeIDs[#activeIDs + 1] = questID
|
|
local q = self:Quest(questID, false)
|
|
if not q then
|
|
q = self:Quest(questID, true)
|
|
q.seen = 0
|
|
q.partial = true
|
|
q.t = Str(title, 100)
|
|
q.lvl = Num(level)
|
|
local list = ReadObjectives(questID)
|
|
StoreObjectives(q, list)
|
|
end
|
|
TrackObjectives(questID, ReadObjectives(questID))
|
|
end
|
|
end
|
|
if Codex.InferDone then Codex.InferDone(activeIDs) end
|
|
end)
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Beute
|
|
------------------------------------------------------------------------
|
|
|
|
local function EntityFor(kind, id)
|
|
if kind == "npc" then
|
|
return Codex:Unit(id, true)
|
|
elseif kind == "object" then
|
|
return Codex:Object(id, true)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function AddDrop(bucket, itemID, qty, isQuest)
|
|
bucket.d = bucket.d or {}
|
|
local d = bucket.d[itemID]
|
|
if not d then
|
|
local n = 0
|
|
for _ in pairs(bucket.d) do
|
|
n = n + 1
|
|
if n >= MAX_DROPS then
|
|
return
|
|
end
|
|
end
|
|
d = { n = 0, q = 0 }
|
|
bucket.d[itemID] = d
|
|
end
|
|
d.n = d.n + 1
|
|
d.q = d.q + (qty or 1)
|
|
if isQuest then
|
|
d.qi = 1
|
|
end
|
|
end
|
|
|
|
-- Octo/1.12: GetLootSlotInfo meldet nicht, ob ein Item ein Quest-Item ist. Ersatz: Name oder ID passt zu einem Item-Ziel
|
|
-- einer Quest im Log. IDs der Quests im Log: einmal je Beutefenster gelesen.
|
|
local function LogQuestIDs()
|
|
local out = {}
|
|
local count
|
|
if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then
|
|
count = C_QuestLog.GetNumQuestLogEntries()
|
|
elseif GetNumQuestLogEntries then
|
|
count = GetNumQuestLogEntries()
|
|
end
|
|
for i = 1, Num(count) or 0 do
|
|
local questID, isHeader
|
|
if C_QuestLog and C_QuestLog.GetInfo then
|
|
local info = C_QuestLog.GetInfo(i)
|
|
if type(info) == "table" then questID, isHeader = info.questID, info.isHeader end
|
|
elseif GetQuestLogTitle then
|
|
local _, _, _, h, _, _, _, id = GetQuestLogTitle(i)
|
|
questID, isHeader = id, h
|
|
end
|
|
questID = Num(questID)
|
|
if questID and questID > 0 and not isHeader then out[#out + 1] = questID end
|
|
end
|
|
return out
|
|
end
|
|
|
|
function Codex:QuestForLootItem(ids, itemID, lootName)
|
|
local want = type(lootName) == "string" and lootName:lower() or nil
|
|
for _, qid in ipairs(ids) do
|
|
local q = self:Quest(qid, false)
|
|
for _, ob in ipairs(q and q.obj or {}) do
|
|
if ob.ty == "item" and ((ob.item and ob.item == itemID) or (want and ob.name and ob.name:lower() == want)) then
|
|
return qid
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Wird dieses Item gerade fuer eine Quest im Log gebraucht? (Item-Ziel passt nach ID oder Name und ist noch nicht erfuellt)
|
|
local function NeedsItem(self, logIDs, live, itemID, lootName)
|
|
local want = type(lootName) == "string" and lootName:lower() or nil
|
|
for _, qid in ipairs(logIDs) do
|
|
local q = self:Quest(qid, false)
|
|
if q and q.obj then
|
|
for i, ob in ipairs(q.obj) do
|
|
if ob.ty == "item" and ((ob.item and ob.item == itemID) or (want and ob.name and ob.name:lower() == want)) then
|
|
local objs = live[qid]
|
|
if not objs then
|
|
objs = ReadObjectives(qid)
|
|
live[qid] = objs
|
|
end
|
|
local lv = objs[i]
|
|
if not (lv and lv.done) then return true end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Codex:OnLootOpened()
|
|
Safe(function()
|
|
if not self.db.settings.enabled or not GetNumLootItems then
|
|
return
|
|
end
|
|
local windows, order = {}, {}
|
|
local logIDs
|
|
for slot = 1, GetNumLootItems() do
|
|
local sources = { GetLootSourceInfo(slot) }
|
|
local stype = GetLootSlotType and Num(GetLootSlotType(slot))
|
|
local link = GetLootSlotLink and Util.Clean(GetLootSlotLink(slot))
|
|
local itemID = ItemIDFromLink(link)
|
|
local _, lootName, lootQty, _, _, _, isQuest, questID
|
|
if GetLootSlotInfo then
|
|
_, lootName, lootQty, _, _, _, isQuest, questID = GetLootSlotInfo(slot)
|
|
end
|
|
logIDs = logIDs or LogQuestIDs()
|
|
if itemID and not isQuest then
|
|
local qid = self:QuestForLootItem(logIDs, itemID, lootName)
|
|
if qid then isQuest, questID = true, qid end
|
|
end
|
|
if itemID then
|
|
NoteName(itemID, lootName)
|
|
-- Alle gelooteten Items merken (nicht nur gemeldete Quest-Items): Der Client sagt auf Octo nicht,
|
|
-- ob ein Item zu einer Quest gehoert, und Item- und Zielnamen stehen in verschiedenen Sprachen.
|
|
-- Steigt spaeter ein Sammel-Zaehler, wird das Item aus diesen Kandidaten abgeleitet.
|
|
local recent = state.recentItems
|
|
local srcs = {}
|
|
for si = 1, #sources, 2 do
|
|
local g = sources[si]
|
|
if type(g) == "string" and not Util.IsSecret(g) then
|
|
local sk, sid = ParseGUID(g)
|
|
if sk and sid then srcs[#srcs + 1] = (sk == "npc" and "u" or "o") .. sid end
|
|
end
|
|
end
|
|
recent[itemID] = { t = Now(), quest = isQuest and true or false, qid = Num(questID), qty = Num(lootQty) or 1, srcs = srcs }
|
|
local n = 0
|
|
for _ in pairs(recent) do n = n + 1 end
|
|
if n > 30 then
|
|
local cutoff = Now() - RECENT_SECS
|
|
for id, r in pairs(recent) do
|
|
if r.t < cutoff then recent[id] = nil end
|
|
end
|
|
end
|
|
end
|
|
for i = 1, #sources, 2 do
|
|
local guid, qty = sources[i], Num(sources[i + 1]) or 1
|
|
if type(guid) == "string" and not Util.IsSecret(guid) then
|
|
local w = windows[guid]
|
|
if not w then
|
|
w = { items = {}, coin = 0, hasCoin = false }
|
|
windows[guid] = w
|
|
order[#order + 1] = guid
|
|
end
|
|
if itemID then
|
|
local it = w.items[itemID]
|
|
if not it then
|
|
it = { q = 0, quest = (isQuest and true or false), qid = Num(questID), name = lootName }
|
|
w.items[itemID] = it
|
|
end
|
|
it.q = it.q + qty
|
|
elseif stype == 2 then
|
|
local cq = Num(lootQty)
|
|
w.coin = w.coin + ((cq and cq > 0 and #sources == 2) and cq or qty)
|
|
w.hasCoin = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local targetGuid = UnitGUID and Util.Clean(UnitGUID("target"))
|
|
for _, guid in ipairs(order) do
|
|
local kind, id = ParseGUID(guid)
|
|
local ent = kind and EntityFor(kind, id)
|
|
if ent and kind == "object" and not ent.n then
|
|
ent.n = state.objNames[id]
|
|
if not ent.n and UnitGUID and UnitName and Util.Clean(UnitGUID("softinteract")) == guid then
|
|
ent.n = Str(UnitName("softinteract"), 60)
|
|
end
|
|
end
|
|
if ent then
|
|
local w = windows[guid]
|
|
local again = state.lootedGuids[guid]
|
|
if not again then
|
|
if state.lootedCount >= MAX_LOOTED_GUIDS then
|
|
state.lootedGuids, state.lootedItems, state.lootedCount = {}, {}, 0
|
|
end
|
|
state.lootedGuids[guid] = true
|
|
state.lootedCount = state.lootedCount + 1
|
|
end
|
|
-- zweites Beutefenster derselben Leiche: was schon gezaehlt ist (liegengelassen), nicht nochmal
|
|
local seen = state.lootedItems[guid]
|
|
if not seen then
|
|
seen = {}
|
|
state.lootedItems[guid] = seen
|
|
end
|
|
local fresh, freshCount = {}, 0
|
|
for itemID, it in pairs(w.items) do
|
|
if not (again and seen[itemID]) then
|
|
fresh[itemID] = it
|
|
freshCount = freshCount + 1
|
|
end
|
|
end
|
|
local skip = again and freshCount == 0 and not (w.hasCoin and w.coin > 0)
|
|
local bucket = ent
|
|
if again and not skip then
|
|
ent.alt = ent.alt or { k = 0 }
|
|
bucket = ent.alt
|
|
end
|
|
if not skip then
|
|
bucket.k = (bucket.k or 0) + 1
|
|
end
|
|
if w.hasCoin and w.coin > 0 and not again then
|
|
ent.coin = (ent.coin or 0) + w.coin
|
|
ent.coinN = (ent.coinN or 0) + 1
|
|
end
|
|
local srcKey = (kind == "npc" and "u" or "o") .. id
|
|
-- Quest-Items zaehlen nur, solange die Quest laeuft und das Item noch gebraucht wird
|
|
local live, neededNow = {}, {}
|
|
for itemID, it in pairs(fresh) do
|
|
seen[itemID] = true
|
|
local old = bucket.d and bucket.d[itemID]
|
|
local needed = true
|
|
if it.quest or (old and old.qi) then
|
|
needed = NeedsItem(self, logIDs or {}, live, itemID, it.name)
|
|
neededNow[itemID] = needed
|
|
end
|
|
if needed then
|
|
AddDrop(bucket, itemID, it.q, it.quest)
|
|
end
|
|
if it.quest and it.qid and it.qid > 0 then
|
|
local q = self:Quest(it.qid, false)
|
|
if q then
|
|
q.qi = q.qi or {}
|
|
q.qi[itemID] = q.qi[itemID] or {}
|
|
q.qi[itemID][srcKey] = (q.qi[itemID][srcKey] or 0) + 1
|
|
end
|
|
end
|
|
end
|
|
if not skip and not again and bucket.d then
|
|
-- Nenner der Drop-Chance fuer Quest-Items: nur Leichen, bei denen die Quest lief
|
|
for itemID, d in pairs(bucket.d) do
|
|
local need = neededNow[itemID]
|
|
if need == nil then need = NeedsItem(self, logIDs or {}, live, itemID, nil) end
|
|
if d.qi and need then
|
|
d.qk = (d.qk or 0) + 1
|
|
end
|
|
end
|
|
end
|
|
if not again then
|
|
ent.lt = Now()
|
|
AddCell(ent)
|
|
if kind == "npc" and targetGuid == guid then
|
|
state.noteAt[id] = nil
|
|
self:NoteUnit("target")
|
|
end
|
|
if kind == "npc" and self.Names then
|
|
if ent.n then
|
|
self.Names:Consume(ent.n)
|
|
else
|
|
self.Names:Resolve(id, ent, guid)
|
|
end
|
|
end
|
|
end
|
|
state.stats.loot = state.stats.loot + 1
|
|
FW:Fire("CODEX_CHANGED", kind == "npc" and "unit" or "object", id)
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
|
|
------------------------------------------------------------------------
|
|
-- Erfahrung pro Kill
|
|
------------------------------------------------------------------------
|
|
|
|
-- Klassifizierung auf wenige Schluessel vereinfachen
|
|
local function ClassKey(class)
|
|
if class == "elite" or class == "rare" or class == "rareelite" or class == "worldboss" then
|
|
return class
|
|
end
|
|
return "normal"
|
|
end
|
|
|
|
function Codex.GrayLevel(pl)
|
|
if pl <= 5 then
|
|
return 0
|
|
elseif pl <= 39 then
|
|
return pl - 5 - math.floor(pl / 10)
|
|
elseif pl <= 59 then
|
|
return pl - 1 - math.floor(pl / 5)
|
|
end
|
|
return pl - 9
|
|
end
|
|
|
|
function Codex.ZeroDiff(pl)
|
|
if pl < 8 then return 5 end
|
|
if pl <= 9 then return 6 end
|
|
if pl <= 11 then return 7 end
|
|
if pl <= 15 then return 8 end
|
|
if pl <= 19 then return 9 end
|
|
if pl <= 29 then return 11 end
|
|
if pl <= 39 then return 12 end
|
|
if pl <= 44 then return 13 end
|
|
if pl <= 49 then return 14 end
|
|
if pl <= 54 then return 15 end
|
|
if pl <= 59 then return 16 end
|
|
return 17
|
|
end
|
|
|
|
-- Farbe des Gegners (red, orange, yellow, green, gray)
|
|
function Codex.XPColor(pl, ml)
|
|
if ml >= pl + 5 then return "red" end
|
|
if ml >= pl + 3 then return "orange" end
|
|
if ml >= pl - 2 then return "yellow" end
|
|
if ml > Codex.GrayLevel(pl) then return "green" end
|
|
return "gray"
|
|
end
|
|
|
|
-- Sicht fuer die Anzeige: eigene und empfangene Daten zusammen (ohne Fremddaten einfach die Datenbank)
|
|
function Codex.V()
|
|
local share = Codex.Share
|
|
if share then
|
|
return share.View()
|
|
end
|
|
return Codex.db
|
|
end
|
|
|
|
-- Kill-Erfahrung nach der klassischen Formel (Classic 1-60), nur als Ersatz fuer fehlende Messwerte
|
|
-- Gegner gleich oder hoeher: Basis aus der Spielerstufe. Gegner niedriger: Basis aus der Gegnerstufe
|
|
-- (45 + 5 x Gegnerstufe), so gemessen in Forever (Stufe 3 gegen 2 = 44, Stufe 2 gegen 1 = 40).
|
|
function Codex.FormulaXP(pl, ml, class)
|
|
local gain
|
|
if ml >= pl then
|
|
local base = pl * 5 + 45
|
|
local d = math.min(ml - pl, 4)
|
|
gain = math.floor((base * (20 + d) / 10 + 1) / 2)
|
|
elseif ml > Codex.GrayLevel(pl) then
|
|
local zd = Codex.ZeroDiff(pl)
|
|
gain = math.floor((ml * 5 + 45) * (zd + ml - pl) / zd)
|
|
else
|
|
gain = 0
|
|
end
|
|
if class == "elite" or class == "rareelite" then
|
|
gain = gain * 2
|
|
end
|
|
return gain, Codex.XPColor(pl, ml)
|
|
end
|
|
|
|
-- Erfahrung fuer einen Kill: gemessener Wert (eigene und empfangene Messreihen), sonst die Formel.
|
|
-- Rueckgabe: XP, Farbe, gemessen (true/false), Anzahl der Messwerte
|
|
function Codex.PredictXP(pl, ml, class)
|
|
local gain, color = Codex.FormulaXP(pl, ml, class)
|
|
local share = Codex.Share
|
|
if share and gain > 0 then
|
|
local measured, n = share.XPMeasured(pl, ml, ClassKey(class))
|
|
if measured and measured > 0 then
|
|
return measured, color, true, n
|
|
end
|
|
end
|
|
return gain, color, false, 0
|
|
end
|
|
|
|
local function AddSample(s, xp)
|
|
s.n = (s.n or 0) + 1
|
|
s.sum = (s.sum or 0) + xp
|
|
s.min = s.min and math.min(s.min, xp) or xp
|
|
s.max = s.max and math.max(s.max, xp) or xp
|
|
end
|
|
|
|
local function Bucket(tbl, pl, ml, cls)
|
|
tbl[pl] = tbl[pl] or {}
|
|
tbl[pl][ml] = tbl[pl][ml] or {}
|
|
tbl[pl][ml][cls] = tbl[pl][ml][cls] or {}
|
|
return tbl[pl][ml][cls]
|
|
end
|
|
|
|
-- Alle Messreihen als Liste { pl, ml, cls, n, avg, min, max, pred, diff, bonus }
|
|
function Codex:XPRows(includeBonus)
|
|
local rows = {}
|
|
for _, def in ipairs({ { Codex.V().xp, false }, { self.db.xpb, true }, { self.db.xr or {}, true, true } }) do
|
|
if includeBonus or not def[2] then
|
|
for pl, byMob in pairs(def[1]) do
|
|
for ml, byClass in pairs(byMob) do
|
|
for cls, s in pairs(byClass) do
|
|
if s.n and s.n > 0 then
|
|
local pred, color = Codex.FormulaXP(pl, ml, cls)
|
|
local avg = s.sum / s.n
|
|
rows[#rows + 1] = {
|
|
pl = pl, ml = ml, cls = cls, n = s.n, avg = avg, min = s.min, max = s.max,
|
|
pred = pred, color = color, diff = avg - pred, bonus = def[2], rested = def[3],
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(rows, function(a, b)
|
|
if a.pl ~= b.pl then return a.pl > b.pl end
|
|
if a.ml ~= b.ml then return a.ml < b.ml end
|
|
return a.cls < b.cls
|
|
end)
|
|
return rows
|
|
end
|
|
|
|
-- Muster aus den Spiel-Texten (COMBATLOG_XPGAIN_FIRSTPERSON...) bauen
|
|
local xpPatterns, xpIgnore
|
|
|
|
local function BuildXPPatterns()
|
|
xpPatterns, xpIgnore = {}, {}
|
|
for key, fmt in pairs(_G) do
|
|
if type(key) == "string" and key:find("^COMBATLOG_XPGAIN_FIRSTPERSON") and type(fmt) == "string" then
|
|
local pattern, order = FormatToPattern(fmt)
|
|
if pattern then
|
|
if key:find("UNNAMED") then
|
|
xpIgnore[#xpIgnore + 1] = pattern
|
|
else
|
|
local nameIdx, xpIdx
|
|
for i, kind in ipairs(order) do
|
|
if kind == "s" and not nameIdx then nameIdx = i end
|
|
if kind == "d" and not xpIdx then xpIdx = i end
|
|
end
|
|
local bonusKind
|
|
if #order > 2 then
|
|
bonusKind = key:find("RAID") and "raid" or key:find("GROUP") and "group" or "bonus"
|
|
end
|
|
xpPatterns[#xpPatterns + 1] = {
|
|
pattern = pattern, name = nameIdx or 1, xp = xpIdx or 2, bonus = bonusKind, len = #fmt,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(xpPatterns, function(a, b) return a.len > b.len end)
|
|
end
|
|
|
|
function Codex:OnXPMessage(text)
|
|
Safe(function()
|
|
if self.Names then
|
|
self.Names.xpSeen = self.Names.xpSeen + 1
|
|
if Util.IsSecret(text) then
|
|
self.Names.fail.xpSecret = self.Names.fail.xpSecret + 1
|
|
end
|
|
end
|
|
text = Util.Clean(text)
|
|
if type(text) ~= "string" or not self.db.settings.enabled then
|
|
return
|
|
end
|
|
if not xpPatterns then
|
|
BuildXPPatterns()
|
|
end
|
|
local matched
|
|
for _, p in ipairs(xpPatterns) do
|
|
local caps = { text:match(p.pattern) }
|
|
if #caps >= 2 then
|
|
matched = { name = Str(caps[p.name], 60), xp = tonumber(caps[p.xp]), bonus = p.bonus }
|
|
break
|
|
end
|
|
end
|
|
if not matched then
|
|
-- Octo: Meldung mit Ruhebonus ("X dies, you gain 38 experience. (+16 exp Rested bonus)"): die Spiel-Texte passen nicht.
|
|
-- Die genannten XP enthalten den Bonus, die Grund-XP sind die Differenz.
|
|
local nm, gx, rb = text:match("^(.+) dies, you gain (%d+) experience%.%s*%(%+(%d+) exp [^%)]*bonus%)")
|
|
if not nm then nm, gx = text:match("^(.+) dies, you gain (%d+) experience%.?$") end
|
|
if nm and gx then
|
|
local base = tonumber(gx) - (tonumber(rb) or 0)
|
|
if base > 0 then matched = { name = Str(nm, 60), xp = base } end
|
|
end
|
|
end
|
|
if not matched then
|
|
for _, pattern in ipairs(xpIgnore) do
|
|
if text:match(pattern) then
|
|
return
|
|
end
|
|
end
|
|
local list = self.db.xpUnparsed
|
|
if #list < 5 then
|
|
list[#list + 1] = text:sub(1, 200)
|
|
end
|
|
return
|
|
end
|
|
if not matched.name or not matched.xp or matched.xp <= 0 then
|
|
return
|
|
end
|
|
if matched.name and not matched.bonus then
|
|
local tk0, tid0 = ParseGUID(UnitGUID and UnitGUID("target"))
|
|
FW:Fire("CODEX_KILL", matched.name, tk0 == "npc" and tid0 or nil)
|
|
end
|
|
|
|
-- Name der getoeteten Kreatur lernen (Ziel passt) oder fuer das naechste Beutefenster merken
|
|
if self.Names then
|
|
local tk, tid = ParseGUID(UnitGUID and UnitGUID("target"))
|
|
local tn = Str(UnitName and UnitName("target"), 60)
|
|
if tk == "npc" and tn == matched.name then
|
|
self.Names:Learn(tid, tn, "xp")
|
|
else
|
|
self.Names:NoteKill(matched.name)
|
|
end
|
|
end
|
|
|
|
-- Gegner: anvisiert und gleicher Name, sonst eindeutiger Name in der Datenbank
|
|
local ml, cls, id
|
|
local tkind, tid = ParseGUID(UnitGUID and UnitGUID("target"))
|
|
if tkind == "npc" and Str(UnitName("target"), 60) == matched.name then
|
|
ml = Num(UnitLevel("target"))
|
|
cls = Str(UnitClassification and UnitClassification("target"), 16)
|
|
id = tid
|
|
else
|
|
for uid, u in pairs(self.db.units) do
|
|
if u.n == matched.name and u.l1 and u.l1 == u.l2 then
|
|
if id then
|
|
id = nil
|
|
break
|
|
end
|
|
id, ml, cls = uid, u.l1, u.c
|
|
end
|
|
end
|
|
end
|
|
local pl = state.pl or Num(UnitLevel and UnitLevel("player"))
|
|
-- Kill fuer die Kampfdauer melden, auch wenn die Gegnerstufe hier unbekannt ist
|
|
-- (die Zeitmessung kennt die Stufe des anvisierten Gegners aus dem Kampf)
|
|
if self.Time and not matched.bonus then
|
|
self.Time:OnKill(pl, ml, id, ml and ClassKey(cls) or nil)
|
|
end
|
|
if not (pl and ml and ml > 0 and pl > 0) then
|
|
return
|
|
end
|
|
cls = ClassKey(cls)
|
|
|
|
if matched.bonus then
|
|
AddSample(Bucket(self.db.xpb, pl, ml, cls), matched.xp)
|
|
FW:Fire("CODEX_CHANGED", "xp", pl)
|
|
return
|
|
end
|
|
|
|
-- Ruhebonus: steht in der Meldung nicht getrennt, also am Ruhe-Vorrat erkennen (er sinkt um den Bonus)
|
|
local base = state.pool
|
|
if base and base > 0 and C_Timer and C_Timer.After then
|
|
local job = { pl = pl, ml = ml, cls = cls, id = id, xp = matched.xp, base = base }
|
|
if state.xpOpen > 0 then
|
|
-- mehrere Kills dicht hintereinander: der Bonus laesst sich nicht zuordnen
|
|
job.unsure = true
|
|
for _, j in ipairs(state.xpJobs) do j.unsure = true end
|
|
end
|
|
state.xpOpen = state.xpOpen + 1
|
|
state.xpJobs[#state.xpJobs + 1] = job
|
|
C_Timer.After(0.4, function()
|
|
Safe(function() Codex:FinishXP(job) end)
|
|
end)
|
|
return
|
|
end
|
|
self:RecordXP(pl, ml, cls, id, matched.xp, nil)
|
|
end)
|
|
end
|
|
|
|
-- Ruhe-Vorrat lesen (0 ohne Ruhebonus, nil wenn die Abfrage fehlt)
|
|
local function ReadPool()
|
|
if type(_G.GetXPExhaustion) ~= "function" then
|
|
return nil
|
|
end
|
|
local ok, v = pcall(_G.GetXPExhaustion)
|
|
if not ok then
|
|
return nil
|
|
end
|
|
return tonumber(v) or 0
|
|
end
|
|
|
|
-- normal: Erfahrung ohne Ruhebonus; total: mit Ruhebonus (nil, wenn keiner dabei war)
|
|
function Codex:RecordXP(pl, ml, cls, id, normal, total)
|
|
self.db.xr = self.db.xr or {}
|
|
AddSample(Bucket(self.db.xp, pl, ml, cls), normal)
|
|
if total then
|
|
AddSample(Bucket(self.db.xr, pl, ml, cls), total)
|
|
end
|
|
if id then
|
|
local u = self:Unit(id, true)
|
|
u.xp = u.xp or {}
|
|
u.xp[pl] = u.xp[pl] or {}
|
|
AddSample(u.xp[pl], normal)
|
|
-- zusaetzlich nach Gegnerstufe getrennt (Kreaturen mit Stufe 1-2 usw.)
|
|
u.xm = u.xm or {}
|
|
u.xm[pl] = u.xm[pl] or {}
|
|
u.xm[pl][ml] = u.xm[pl][ml] or {}
|
|
AddSample(u.xm[pl][ml], normal)
|
|
if total then
|
|
u.xr = u.xr or {}
|
|
u.xr[pl] = u.xr[pl] or {}
|
|
u.xr[pl][ml] = u.xr[pl][ml] or {}
|
|
AddSample(u.xr[pl][ml], total)
|
|
end
|
|
end
|
|
FW:Fire("CODEX_CHANGED", "xp", pl)
|
|
if id then
|
|
FW:Fire("CODEX_CHANGED", "unit", id)
|
|
end
|
|
end
|
|
|
|
function Codex:FinishXP(job)
|
|
state.xpOpen = math.max(0, state.xpOpen - 1)
|
|
for i, j in ipairs(state.xpJobs) do
|
|
if j == job then table.remove(state.xpJobs, i) break end
|
|
end
|
|
local after = ReadPool()
|
|
if after then
|
|
state.pool = after
|
|
end
|
|
local bonus = after and (job.base - after) or 0
|
|
if bonus <= 0 then
|
|
self:RecordXP(job.pl, job.ml, job.cls, job.id, job.xp, nil)
|
|
elseif job.unsure or bonus >= job.xp then
|
|
self.xpDropped = (self.xpDropped or 0) + 1
|
|
else
|
|
self:RecordXP(job.pl, job.ml, job.cls, job.id, job.xp - bonus, job.xp)
|
|
end
|
|
end
|
|
|
|
-- Ruhe-Vorrat merken: beim Kampfbeginn und ausserhalb des Kampfes (Ausruhen fuellt ihn)
|
|
function Codex:OnPoolEvent(event)
|
|
if event == "PLAYER_REGEN_DISABLED" or event == "PLAYER_ENTERING_WORLD"
|
|
or not (UnitAffectingCombat and UnitAffectingCombat("player")) then
|
|
if state.xpOpen == 0 then
|
|
state.pool = ReadPool()
|
|
end
|
|
end
|
|
end
|
|
|
|
function Codex:OnLevelUp(newLevel)
|
|
newLevel = Num(newLevel)
|
|
if newLevel then
|
|
state.pl = newLevel
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Abfragen fuer Oberflaeche und andere Addons
|
|
------------------------------------------------------------------------
|
|
|
|
-- Quest-Status des eingeloggten Charakters: "active" (im Questlog), "done" (abgegeben) oder nil.
|
|
-- Das Spiel kennt beides lokal, es wird nichts gespeichert.
|
|
-- Simulation (Guide-Berechnung im Levelplaner): Codex.sim = { done = {}, active = {}, memo = {}, level = n }.
|
|
-- Waehrend eine Teilstrecke gerechnet wird, gelten die simulierten Quests als erledigt oder angenommen; alles andere
|
|
-- liefert das Spiel (einmal je Quest gemerkt, damit die Rechnung keine Schnittstellenaufrufe wiederholt).
|
|
local realQuestStatus
|
|
function Codex.QuestStatus(id)
|
|
local sim = Codex.sim
|
|
if sim then
|
|
if sim.done[id] then return "done" end
|
|
if sim.active[id] then return "active" end
|
|
local m = sim.memo[id]
|
|
if m == nil then
|
|
m = realQuestStatus(id) or false
|
|
sim.memo[id] = m
|
|
end
|
|
return m or nil
|
|
end
|
|
return realQuestStatus(id)
|
|
end
|
|
|
|
-- Eigene Quests (Freewood-OctoWoW-Creator): IDs ab LOCAL_BASE, Stand liefert das Creator-Modul (nie das Questlog des Spiels)
|
|
Codex.LOCAL_BASE = 9000000
|
|
function Codex.IsLocalQuest(id)
|
|
return type(id) == "number" and id >= Codex.LOCAL_BASE and id < Codex.LOCAL_BASE + 1000000
|
|
end
|
|
|
|
-- Octo kennt keine Abfrage "Quest erledigt": Abgaben selbst merken, je Charakter (db.turned[Schluessel][questID])
|
|
local function TurnedSet(create)
|
|
local db = Codex.db
|
|
local key = Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or nil
|
|
if not db or not key then return nil end
|
|
db.turned = db.turned or {}
|
|
local t = db.turned[key]
|
|
if not t and create ~= false then
|
|
t = {}
|
|
db.turned[key] = t
|
|
-- einmalig und nur fuer den allerersten Charakter (Umstieg auf Speicherung je Charakter): bisher aufgezeichnete Abgaben
|
|
-- diesem Charakter zuschreiben. Jeder weitere Charakter beginnt leer (sonst gaelten neue Charaktere als "alles erledigt").
|
|
local others = false
|
|
for k, v in pairs(db.turned) do
|
|
if k ~= key and type(v) == "table" then others = true break end
|
|
end
|
|
if not others then
|
|
local V = Codex.V and Codex.V()
|
|
for qid, q in pairs(V and V.quests or {}) do
|
|
if type(q) == "table" and (q.done or 0) > 0 then t[qid] = true end
|
|
end
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
function Codex.MarkTurned(id, on)
|
|
local t = TurnedSet(true)
|
|
if t and id then t[id] = on ~= false and true or nil end
|
|
end
|
|
|
|
-- Wer eine Folgequest im Log hat, hat die Vorquest erledigt (nur bei eindeutiger, einzelner Vorquest)
|
|
function Codex.InferDone(ids)
|
|
local t = TurnedSet(true)
|
|
if not t then return end
|
|
local n = 0
|
|
for _, id in ipairs(ids or {}) do
|
|
local cur, depth = id, 0
|
|
while cur and depth < 15 do
|
|
local pre = Codex.QuestPre and Codex.QuestPre(cur) or {}
|
|
if #pre ~= 1 then break end
|
|
cur = pre[1]
|
|
if not t[cur] then t[cur] = true n = n + 1 end
|
|
depth = depth + 1
|
|
end
|
|
end
|
|
if n > 0 then Codex.chainRev = (Codex.chainRev or 0) + 1 end
|
|
return n
|
|
end
|
|
|
|
_G.SLASH_FWDONE1 = "/fwdone"
|
|
_G.SlashCmdList = _G.SlashCmdList or {}
|
|
_G.SlashCmdList["FWDONE"] = function(msg)
|
|
if string.match(msg or "", "^%s*reset") then
|
|
local key = Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or nil
|
|
local say0 = function(x) if _G.DEFAULT_CHAT_FRAME then _G.DEFAULT_CHAT_FRAME:AddMessage("|cffffd100Freewood|r " .. x) end end
|
|
if not (Codex.db and key) then say0("Charakter unbekannt.") return end
|
|
if not string.match(msg, "reset%s+ja") then
|
|
say0("/fwdone reset ja loescht die gemerkten Abgaben dieses Charakters (" .. tostring(key) .. "). Danach gilt er als ohne erledigte Quests; angenommene Quests bleiben. Dann im Planer NEU druecken.")
|
|
return
|
|
end
|
|
Codex.db.turned = Codex.db.turned or {}
|
|
Codex.db.turned[key] = {}
|
|
Codex.chainRev = (Codex.chainRev or 0) + 1
|
|
say0("Abgaben dieses Charakters geloescht. Jetzt im Planer NEU druecken.")
|
|
if _G.FW and _G.FW.Fire then _G.FW:Fire("QUEST_TURNED_IN_LOCAL", 0) end
|
|
return
|
|
end
|
|
local id, undo = string.match(msg or "", "^(%d+)%s*(%a*)")
|
|
id = tonumber(id)
|
|
local say = function(x) if _G.DEFAULT_CHAT_FRAME then _G.DEFAULT_CHAT_FRAME:AddMessage("|cffffd100Freewood|r " .. x) end end
|
|
if not id then say("/fwdone <QuestID> [weg] markiert eine Quest als erledigt (oder wieder offen)") return end
|
|
Codex.MarkTurned(id, undo ~= "weg")
|
|
Codex.chainRev = (Codex.chainRev or 0) + 1
|
|
say(string.format("Quest %d: %s", id, undo == "weg" and "wieder offen" or "erledigt"))
|
|
if _G.FW and _G.FW.Fire then _G.FW:Fire("QUEST_TURNED_IN_LOCAL", id) end
|
|
end
|
|
|
|
realQuestStatus = function(id)
|
|
if Codex.LocalStatus and Codex.IsLocalQuest(id) then
|
|
return Codex.LocalStatus(id)
|
|
end
|
|
local QL = _G.C_QuestLog
|
|
if QL and QL.GetLogIndexForQuestID then
|
|
local ok, idx = pcall(QL.GetLogIndexForQuestID, id)
|
|
if ok and type(idx) == "number" and idx > 0 then
|
|
return "active"
|
|
end
|
|
end
|
|
do
|
|
local t = TurnedSet(true)
|
|
if t and t[id] then return "done" end
|
|
end
|
|
local fn = (QL and QL.IsQuestFlaggedCompleted) or _G.IsQuestFlaggedCompleted
|
|
if type(fn) == "function" then
|
|
local ok, done = pcall(fn, id)
|
|
if ok and done then
|
|
return "done"
|
|
end
|
|
end
|
|
-- Ersatz: Liste aller erledigten Quests
|
|
local list
|
|
if QL and QL.GetAllCompletedQuestIDs then
|
|
local ok, r = pcall(QL.GetAllCompletedQuestIDs)
|
|
if ok and type(r) == "table" then
|
|
for _, v in pairs(r) do if v == id then return "done" end end
|
|
end
|
|
elseif type(_G.GetQuestsCompleted) == "function" then
|
|
local ok, r = pcall(_G.GetQuestsCompleted)
|
|
if ok and type(r) == "table" and r[id] then return "done" end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Codex:GetQuest(id)
|
|
return Codex.V().quests[id]
|
|
end
|
|
|
|
-- Display-ID einer Kreatur (fuer das 2D-Bild in der Liste)
|
|
Codex.dispStats = { read = 0, fail = 0, shown = 0 }
|
|
function Codex:GetDisplayID(npcID)
|
|
local d = self.db.disp and self.db.disp[npcID]
|
|
return type(d) == "number" and d > 0 and d or nil
|
|
end
|
|
|
|
-- Octo: PlayerModel:SetCreature zeigt nur Kreaturen aus dem Client-Cache. Unbekannte
|
|
-- werden zuerst angefragt (CREATURE_DATA_LOAD_RESULT), dann laeuft fn.
|
|
local creatureWait = {}
|
|
local function RunWaiting(id)
|
|
local cbs = creatureWait[id]
|
|
creatureWait[id] = nil
|
|
if cbs then
|
|
for _, fn in ipairs(cbs) do
|
|
pcall(fn)
|
|
end
|
|
end
|
|
end
|
|
function Codex.EnsureCreature(id, fn)
|
|
local C = _G.C_CreatureInfo
|
|
if type(id) ~= "number" or not (C and C.RequestLoadCreatureByID) then
|
|
fn()
|
|
return
|
|
end
|
|
if creatureWait[id] then
|
|
table.insert(creatureWait[id], fn)
|
|
return
|
|
end
|
|
creatureWait[id] = { fn }
|
|
if not Codex.creatureWatch then
|
|
local w = CreateFrame("Frame")
|
|
w:RegisterEvent("CREATURE_DATA_LOAD_RESULT")
|
|
w:SetScript("OnEvent", function(_, _, a1)
|
|
local got = tonumber(a1) or tonumber(_G.arg1)
|
|
if got then RunWaiting(got) end
|
|
end)
|
|
Codex.creatureWatch = w
|
|
end
|
|
local ok = pcall(C.RequestLoadCreatureByID, id)
|
|
if not ok then
|
|
RunWaiting(id)
|
|
return
|
|
end
|
|
if creatureWait[id] and C_Timer and C_Timer.After then
|
|
C_Timer.After(4, function() RunWaiting(id) end)
|
|
end
|
|
end
|
|
|
|
-- Display-ID direkt aus dem Kreatur-Cache (ClassicAPI), ohne Modell
|
|
function Codex.CacheDisplayID(id)
|
|
local C = _G.C_CreatureInfo
|
|
if not (C and C.GetCreatureInfoByID) then return nil end
|
|
local ok, info = pcall(C.GetCreatureInfoByID, id)
|
|
if ok and type(info) == "table" then
|
|
local d = tonumber(info.displayID)
|
|
if d and d > 0 then return d end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Codex:SetDisplayID(npcID, displayID)
|
|
if type(npcID) ~= "number" or type(displayID) ~= "number" or displayID <= 0 then
|
|
return false
|
|
end
|
|
self.db.disp = self.db.disp or {}
|
|
if self.db.disp[npcID] ~= displayID then
|
|
self.db.disp[npcID] = displayID
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Itemwerte (Staerke, Beweglichkeit, Ausdauer, Ruestung, Schaden pro Sekunde ...)
|
|
------------------------------------------------------------------------
|
|
|
|
-- Waffen: Schadensspanne und Tempo stehen nur im Tooltip (die Wertetabelle liefert nur den Schaden pro Sekunde).
|
|
-- Gespeichert als _dmin, _dmax, _spd und _s (= gelesen) neben den anderen Werten.
|
|
local scanTip
|
|
local scanTried = {}
|
|
|
|
local function ScanTooltip()
|
|
if scanTip == nil then
|
|
scanTip = false
|
|
if CreateFrame and UIParent then
|
|
local ok, tip = pcall(CreateFrame, "GameTooltip", "FreewoodCodexScanTip", nil, "GameTooltipTemplate")
|
|
if ok and tip then
|
|
scanTip = tip
|
|
end
|
|
end
|
|
end
|
|
return scanTip or nil
|
|
end
|
|
|
|
local function TemplatePattern(template)
|
|
if type(template) ~= "string" or template == "" then
|
|
return nil
|
|
end
|
|
local p = template:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
|
|
p = p:gsub("%%%%[sd]", "(.-)")
|
|
return "^" .. p .. "$"
|
|
end
|
|
|
|
local function LineText(name, i)
|
|
local fs = _G[name .. i]
|
|
local ok, text = pcall(function() return fs and fs.GetText and fs:GetText() end)
|
|
if ok and type(text) == "string" and not Util.IsSecret(text) then
|
|
return text
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function CleanLine(text)
|
|
if type(text) ~= "string" or Util.IsSecret(text) then
|
|
return nil
|
|
end
|
|
return (text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""))
|
|
end
|
|
|
|
-- Zeilen des Item-Tooltips { { links, rechts } }: erst die Tooltip-Daten (neue Clients), sonst ein versteckter Tooltip.
|
|
-- Zweiter Rueckgabewert: woher sie stammen (fuer /fwcodex weapon)
|
|
-- Tooltip noch nicht fertig? (nur "Frage Gegenstandsinformationen ab ..." oder gar keine Zeilen)
|
|
local function LinesReady(lines)
|
|
if #lines < 2 then
|
|
return false
|
|
end
|
|
local wait = _G.RETRIEVING_ITEM_INFO
|
|
if type(wait) == "string" and lines[1][1] == wait then
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Rueckgabe: Zeilen, Quelle, fertig (false = Itemdaten noch nicht geladen, spaeter nochmal)
|
|
local function TooltipLines(itemID)
|
|
local firstLines, firstSource
|
|
if C_TooltipInfo and C_TooltipInfo.GetItemByID then
|
|
local ok, data = pcall(C_TooltipInfo.GetItemByID, itemID)
|
|
if ok and type(data) == "table" and type(data.lines) == "table" then
|
|
local lines = {}
|
|
for _, ln in ipairs(data.lines) do
|
|
if type(ln) == "table" then
|
|
lines[#lines + 1] = { CleanLine(ln.leftText), CleanLine(ln.rightText) }
|
|
end
|
|
end
|
|
if LinesReady(lines) then
|
|
return lines, "C_TooltipInfo", true
|
|
end
|
|
if #lines > 0 then
|
|
firstLines, firstSource = lines, "C_TooltipInfo"
|
|
end
|
|
end
|
|
end
|
|
local tip = ScanTooltip()
|
|
if not tip then
|
|
return firstLines or {}, firstSource or "kein Tooltip", false
|
|
end
|
|
local ok = pcall(function()
|
|
tip:SetOwner(UIParent, "ANCHOR_NONE")
|
|
tip:ClearLines()
|
|
tip:SetHyperlink("item:" .. itemID)
|
|
end)
|
|
if not ok then
|
|
return firstLines or {}, firstSource or "Tooltip-Fehler", false
|
|
end
|
|
local lines = {}
|
|
for i = 1, (tip.NumLines and tip:NumLines() or 0) do
|
|
lines[#lines + 1] = { CleanLine(LineText("FreewoodCodexScanTipTextLeft", i)), CleanLine(LineText("FreewoodCodexScanTipTextRight", i)) }
|
|
end
|
|
pcall(tip.Hide, tip)
|
|
if not LinesReady(lines) and firstLines then
|
|
return firstLines, firstSource, false
|
|
end
|
|
return lines, "versteckter Tooltip", LinesReady(lines)
|
|
end
|
|
|
|
local function Number(text)
|
|
return tonumber((tostring(text):gsub(",", ".")))
|
|
end
|
|
|
|
-- Schaden und Tempo aus den Zeilen. Fehlt das Tempo im Text, wird es aus Schaden und DPS errechnet.
|
|
local function ParseWeapon(lines, dps)
|
|
local patterns = {
|
|
TemplatePattern(_G.DAMAGE_TEMPLATE_WITH_SCHOOL), TemplatePattern(_G.DAMAGE_TEMPLATE),
|
|
}
|
|
local speedWord = type(_G.WEAPON_SPEED) == "string" and _G.WEAPON_SPEED or "Speed"
|
|
speedWord = speedWord:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
|
|
local dmin, dmax, spd
|
|
for _, ln in ipairs(lines) do
|
|
local left, right = ln[1], ln[2]
|
|
if left and not dmin then
|
|
for _, pat in ipairs(patterns) do
|
|
local a, b = left:match(pat)
|
|
a, b = a and Number(a), b and Number(b)
|
|
if a and b then
|
|
dmin, dmax = a, b
|
|
break
|
|
end
|
|
end
|
|
if not dmin then
|
|
-- allgemein: Zeile beginnt mit "min - max"
|
|
local a, b = left:match("^(%d+[%.,]?%d*)%s*[%-\226\128\147\226\128\148]+%s*(%d+[%.,]?%d*)%s*%a")
|
|
a, b = a and Number(a), b and Number(b)
|
|
if a and b then
|
|
dmin, dmax = a, b
|
|
end
|
|
end
|
|
end
|
|
if not spd then
|
|
for _, text in ipairs({ left or "", right or "" }) do
|
|
local v = text:match(speedWord .. "%s+([%d%.,]+)")
|
|
v = v and Number(v)
|
|
if v and v > 0 then
|
|
spd = v
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if dmin and dmax and not spd and dps and dps > 0 then
|
|
spd = math.floor(((dmin + dmax) / 2 / dps) * 100 + 0.5) / 100
|
|
end
|
|
if dmin and dmax and spd then
|
|
return dmin, dmax, spd
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function DPSOf(stats)
|
|
for key, value in pairs(stats) do
|
|
if key:find("DAMAGE_PER_SECOND", 1, true) and type(value) == "number" then
|
|
return value
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Rueckgabe: min, max, Tempo (oder nil, wenn der Tooltip noch leer ist oder keine Waffe zeigt)
|
|
local function ReadWeapon(itemID, dps)
|
|
local lines, _, ready = TooltipLines(itemID)
|
|
local dmin, dmax, spd = ParseWeapon(lines, dps)
|
|
return dmin, dmax, spd, ready
|
|
end
|
|
|
|
-- Ausgabe fuer /fwcodex weapon <ID>: zeigt, was der Tooltip liefert
|
|
function Codex.WeaponDebug(itemID)
|
|
if Util.RequestItem then Util.RequestItem(itemID, function() end) end -- Itemdaten anfordern
|
|
local lines, source, ready = TooltipLines(itemID)
|
|
FW:Print(string.format("Waffe %d: Quelle %s, %d Zeilen%s", itemID, source, #lines,
|
|
ready and "" or " (Itemdaten noch nicht geladen, in ein paar Sekunden noch einmal eingeben)"))
|
|
for i, ln in ipairs(lines) do
|
|
if i <= 14 then
|
|
FW:Print(string.format(" %d: [%s] [%s]", i, tostring(ln[1]), tostring(ln[2])))
|
|
end
|
|
end
|
|
local st = Codex.db.istats and Codex.db.istats[itemID]
|
|
local dmin, dmax, spd = ParseWeapon(lines, st and DPSOf(st))
|
|
FW:Print(string.format("Ergebnis: Schaden %s - %s, Tempo %s, DPS laut Werten %s", tostring(dmin), tostring(dmax), tostring(spd),
|
|
tostring(st and DPSOf(st))))
|
|
end
|
|
|
|
-- Tooltip-Scanner (/fwcodex tipscan [stop]): liest die Tooltips aller bekannten Items und sammelt die Textzeilen
|
|
-- mit Effekten ("Equip:", "Chance on hit:", "Use:" und alles, was nicht zu den Standardzeilen gehoert).
|
|
-- Ergebnis: je Textmuster (Zahlen als # ersetzt) Anzahl und ein Beispiel-Item, im Log (Reiter "Log") und in db.tipscan.
|
|
local tipScan
|
|
local STD_PATTERNS = {
|
|
"^Binds when", "^Requires ", "^Durability", "^Classes:", "^Races:", "^Unique", "^Sell", "^Soulbound", "^Quest Item",
|
|
"^Two%-Hand", "^One%-Hand", "^Main Hand", "^Off Hand", "^Ranged", "^Held In Off", "^Thrown", "^Projectile", "^Relic",
|
|
"^[%d%.,]+ %- [%d%.,]+ ", "^%(?[%d%.,]+ damage per second", "^[%d%.,]+ Armor", "^[%d%.,]+ Block", "^%+[%d%.,]+ [%a ]+$",
|
|
"^%-[%d%.,]+ [%a ]+$", "^%+?[%d%.,]+ [%a ]+ Resistance", "^%d+ Slot ", "^Item Level", "^\"", "^Cloth$", "^Leather$", "^Mail$", "^Plate$",
|
|
"^Shield$", "^Sword$", "^Axe$", "^Mace$", "^Dagger$", "^Staff$", "^Bow$", "^Gun$", "^Crossbow$", "^Wand$", "^Fist Weapon$", "^Polearm$",
|
|
"^Idol$", "^Libram$", "^Totem$", "^Back$", "^Head$", "^Neck$", "^Shoulder$", "^Chest$", "^Waist$", "^Legs$", "^Feet$", "^Wrist$", "^Hands$",
|
|
"^Finger$", "^Trinket$", "^Shirt$", "^Held", "^Bag$", "^Quiver$", "^Ammo Pouch$", "^%d+ Slot",
|
|
}
|
|
local function IsStd(text)
|
|
for _, pat in ipairs(STD_PATTERNS) do
|
|
if text:find(pat) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function Norm(text)
|
|
return (text:gsub("%d+[%.,]?%d*", "#"))
|
|
end
|
|
|
|
function Codex.TipScan(ids)
|
|
if tipScan then
|
|
FW:Print("Tooltip-Scan laeuft schon (/fwcodex tipscan stop zum Abbrechen).")
|
|
return
|
|
end
|
|
local list, seen = {}, {}
|
|
for _, id in ipairs(ids or {}) do
|
|
if type(id) == "number" and not seen[id] then seen[id] = true list[#list + 1] = id end
|
|
end
|
|
for id in pairs(Codex.db.istats or {}) do
|
|
if type(id) == "number" and not seen[id] then seen[id] = true list[#list + 1] = id end
|
|
end
|
|
table.sort(list)
|
|
local st = { list = list, pos = 1, pat = {}, done = 0, wait = 0, skipped = 0, t0 = _G.GetTime and _G.GetTime() or 0 }
|
|
tipScan = st
|
|
FW:Print(string.format("Tooltip-Scan: %d Items, laeuft im Hintergrund ...", #list))
|
|
local function Finish(aborted)
|
|
tipScan = nil
|
|
local rows = {}
|
|
for key, v in pairs(st.pat) do rows[#rows + 1] = { key = key, n = v.n, id = v.id, ex = v.ex, kind = v.kind } end
|
|
table.sort(rows, function(a, b) if a.n ~= b.n then return a.n > b.n end return a.key < b.key end)
|
|
Codex.db.tipscan = { when = _G.date and _G.date("%Y-%m-%d %H:%M:%S") or "", items = st.done, skipped = st.skipped, rows = rows }
|
|
local Log = FW.Dev and FW.Dev.Diag
|
|
local function Out(text)
|
|
if Log and Log.Add then Log.Add("scan", text) else FW:Print(text) end
|
|
end
|
|
Out(string.format("TIPSCAN %s: %d Items gelesen, %d uebersprungen (nicht geladen), %d Textmuster", aborted and "abgebrochen" or "fertig", st.done, st.skipped, #rows))
|
|
for i, r in ipairs(rows) do
|
|
Out(string.format("TIPSCAN %d x %s | %s | Beispiel %d: %s", r.n, r.kind, r.key, r.id, r.ex))
|
|
if i >= 700 then break end
|
|
end
|
|
FW:Print(string.format("Tooltip-Scan %s: %d Textmuster im Log (Reiter Log, Art \"scan\") und in den gespeicherten Daten.", aborted and "abgebrochen" or "fertig", #rows))
|
|
end
|
|
local function Step()
|
|
if tipScan ~= st then return Finish(true) end
|
|
local n = 0
|
|
while st.pos <= #list and n < 40 do
|
|
local id = list[st.pos]
|
|
if Util.RequestItem and not (Util.GetItem and Util.GetItem(id)) then pcall(Util.RequestItem, id, function() end) end
|
|
local lines, _, ready = TooltipLines(id)
|
|
if not ready and st.wait < 6 then
|
|
st.wait = st.wait + 1
|
|
break -- spaeter nochmal versuchen
|
|
end
|
|
st.wait = 0
|
|
if ready then
|
|
st.done = st.done + 1
|
|
for i = 2, #lines do
|
|
local left = lines[i][1]
|
|
if type(left) == "string" and left ~= "" and not IsStd(left) then
|
|
local kind = "sonst"
|
|
if left:find("^Equip:") then kind = "equip" elseif left:find("^Chance on hit:") then kind = "proc"
|
|
elseif left:find("^Use:") then kind = "use" end
|
|
local key = Norm(left)
|
|
local v = st.pat[key]
|
|
if not v then st.pat[key] = { n = 1, id = id, ex = left, kind = kind } else v.n = v.n + 1 end
|
|
end
|
|
end
|
|
else
|
|
st.skipped = st.skipped + 1
|
|
end
|
|
st.pos = st.pos + 1
|
|
n = n + 1
|
|
end
|
|
if st.pos > #list then return Finish(false) end
|
|
if C_Timer and C_Timer.After then C_Timer.After(0.05, Step) end
|
|
end
|
|
Step()
|
|
end
|
|
|
|
function Codex.TipScanStop() tipScan = nil end
|
|
|
|
local function HasDPS(stats)
|
|
for key in pairs(stats) do
|
|
if key:find("DAMAGE_PER_SECOND", 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Codex.ResetWeaponScan(itemID)
|
|
scanTried[itemID] = nil
|
|
end
|
|
|
|
local function AddWeapon(itemID, out)
|
|
if out._s or (scanTried[itemID] or 0) >= 6 or not HasDPS(out) then
|
|
return
|
|
end
|
|
local dmin, dmax, spd, ready = ReadWeapon(itemID, DPSOf(out))
|
|
if dmin then
|
|
out._dmin, out._dmax, out._spd, out._s = dmin, dmax, spd, 1
|
|
scanTried[itemID] = 6
|
|
elseif ready then
|
|
scanTried[itemID] = 6 -- Tooltip war fertig und enthaelt keine Waffenwerte
|
|
else
|
|
scanTried[itemID] = (scanTried[itemID] or 0) + 1 -- noch nicht geladen: spaeter erneut versuchen
|
|
end
|
|
end
|
|
|
|
-- Gemeinsamer Zaubermacht-Wert ("Schaden und Heilung um bis zu 18") gilt fuer beide:
|
|
-- Zauberschaden und Zauberheilung bekommen ihn, damit Anzeige und Filter einheitlich sind.
|
|
-- Rueckgabe: true, wenn etwas geaendert wurde.
|
|
local SP_DAMAGE, SP_HEALING = "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "ITEM_MOD_SPELL_HEALING_DONE_SHORT"
|
|
local function SplitSpellPower(t)
|
|
local changed = false
|
|
for _, key in ipairs({ "ITEM_MOD_SPELL_POWER_SHORT", "ITEM_MOD_SPELL_POWER" }) do
|
|
local v = t[key]
|
|
if type(v) == "number" and v ~= 0 then
|
|
t[SP_DAMAGE] = math.max(t[SP_DAMAGE] or 0, v)
|
|
t[SP_HEALING] = math.max(t[SP_HEALING] or 0, v)
|
|
end
|
|
if v ~= nil then
|
|
t[key] = nil
|
|
changed = true
|
|
end
|
|
end
|
|
-- Schluessel ohne "_SHORT" (ITEM_MOD_BLOCK_VALUE) und mit sind derselbe Wert: auf die Form mit "_SHORT" vereinheitlichen
|
|
local alias
|
|
for key, v in pairs(t) do
|
|
if type(key) == "string" and key:sub(1, 9) == "ITEM_MOD_" and key:sub(-6) ~= "_SHORT" then
|
|
alias = alias or {}
|
|
alias[#alias + 1] = key
|
|
end
|
|
end
|
|
if alias then
|
|
for _, key in ipairs(alias) do
|
|
local v, target = t[key], key .. "_SHORT"
|
|
if type(v) == "number" and (t[target] or 0) < v then t[target] = v end
|
|
t[key] = nil
|
|
changed = true
|
|
end
|
|
end
|
|
return changed
|
|
end
|
|
Codex.SplitSpellPower = SplitSpellPower
|
|
|
|
|
|
-- Effekte aus dem Tooltip ("Equip:", "Chance on hit:", "Use:", Set-Boni): bekannte Texte werden Zahlenwerte (FW_*), alles
|
|
-- andere nur Markierungen (Wert 1). Die Zeilen selbst stehen in _fx, _e = 1 heisst "Effekte gelesen" (Stand EFFECT_VER).
|
|
local EFFECT_VER = 3
|
|
local SCHOOLS = { ARCANE = true, FIRE = true, FROST = true, HOLY = true, NATURE = true, SHADOW = true }
|
|
local EQUIP_RULES = {
|
|
{ "^Improves your chance to get a critical strike by ([%d%.]+)%%", "FW_CRIT" },
|
|
{ "^Improves your chance to get a critical strike with spells by ([%d%.]+)%%", "FW_SPELLCRIT" },
|
|
{ "^Improves your chance to get a critical strike with missile weapons by ([%d%.]+)%%", "FW_RANGEDCRIT" },
|
|
{ "^Improves your chance to hit by ([%d%.]+)%%", "FW_HIT" },
|
|
{ "^Improves your chance to hit with spells by ([%d%.]+)%%", "FW_SPELLHIT" },
|
|
{ "^Increases your chance to dodge an attack by ([%d%.]+)%%", "FW_DODGE" },
|
|
{ "^Increases your chance to parry an attack by ([%d%.]+)%%", "FW_PARRY" },
|
|
{ "^Increases your chance to block attacks with %a+ shield by ([%d%.]+)%%", "FW_BLOCK" },
|
|
{ "^Increases the block value of your shield by ([%d%.]+)", "FW_BLOCKVAL" },
|
|
{ "^Restores ([%d%.]+) mana per 5 sec", "FW_MP5" },
|
|
{ "^Restores ([%d%.]+) health per 5 sec", "FW_HP5" },
|
|
{ "^Restores ([%d%.]+) health every 5 sec", "FW_HP5" },
|
|
{ "^Increases your attack and casting speed by ([%d%.]+)%%", "FW_HASTE" },
|
|
{ "^Increases ranged attack speed by ([%d%.]+)%%", "FW_RANGEDSPEED" },
|
|
{ "^Your attacks ignore ([%d%.]+) of the target's armor", "FW_ARMORPEN" },
|
|
{ "^Decreases the magical resistances of your spell targets by ([%d%.]+)", "FW_SPELLPEN" },
|
|
{ "^([%d%.]+)%% of damage dealt is returned as healing", "FW_LIFESTEAL" },
|
|
{ "^%+([%d%.]+) Attack Power%.?$", "ITEM_MOD_ATTACK_POWER_SHORT" },
|
|
{ "^Increases attack power by ([%d%.]+)%.?$", "ITEM_MOD_ATTACK_POWER_SHORT" },
|
|
{ "^%+([%d%.]+) Ranged Attack Power%.?$", "ITEM_MOD_RANGED_ATTACK_POWER_SHORT" },
|
|
{ "^Increases ranged attack power by ([%d%.]+)%.?$", "ITEM_MOD_RANGED_ATTACK_POWER_SHORT" },
|
|
}
|
|
local function SetMax(out, key, v)
|
|
if type(v) == "number" and v ~= 0 and (out[key] or 0) < v then out[key] = v end
|
|
end
|
|
|
|
local function ParseEffects(lines, out)
|
|
local fx = {}
|
|
local function Flag(key) out[key] = 1 end
|
|
for i = 2, #lines do
|
|
local left = lines[i][1]
|
|
if type(left) == "string" and left ~= "" and left:sub(1, 1) ~= "\n" then
|
|
local txt = left:gsub("%s+$", "")
|
|
if txt:find("^Equip:") then
|
|
local t = txt:gsub("^Equip:%s*", "")
|
|
local done = false
|
|
for _, r in ipairs(EQUIP_RULES) do
|
|
local v = t:match(r[1])
|
|
if v then SetMax(out, r[2], tonumber(v)) done = true break end
|
|
end
|
|
if not done then
|
|
local v = t:match("^Increases damage and healing done by magical spells and effects by up to (%d+)")
|
|
if v then
|
|
SetMax(out, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", tonumber(v))
|
|
SetMax(out, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", tonumber(v))
|
|
done = true
|
|
end
|
|
end
|
|
if not done then
|
|
local v = t:match("^Increases healing done by spells and effects by up to (%d+)")
|
|
if v then SetMax(out, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", tonumber(v)) done = true end
|
|
end
|
|
if not done then
|
|
local sch, v = t:match("^Increases damage done by (%a+) spells and effects by up to (%d+)")
|
|
if sch and SCHOOLS[sch:upper()] then SetMax(out, "FW_SD_" .. sch:upper(), tonumber(v)) done = true end
|
|
end
|
|
if not done then
|
|
local name, v = t:match("^Increased ([%a ]-) %+(%d+)")
|
|
if name then
|
|
local k = name:upper():gsub(" ", "_")
|
|
SetMax(out, (k == "DEFENSE" or k == "FISHING") and ("FW_" .. k) or ("FW_SK_" .. k), tonumber(v))
|
|
done = true
|
|
end
|
|
end
|
|
if not done then
|
|
-- Bewegungstempo: Schwimmen, Laufen, Reittier (ohne Prozentangabe zaehlt der Wert 1)
|
|
local low = t:lower()
|
|
local key = low:find("swim speed", 1, true) and "FW_SWIM"
|
|
or ((low:find("run speed", 1, true) or low:find("movement speed", 1, true)) and "FW_RUNSPEED")
|
|
or (low:find("mount speed", 1, true) and "FW_MOUNTSPEED") or nil
|
|
if key then
|
|
SetMax(out, key, tonumber(low:match("([%d%.]+)%%")) or 1)
|
|
done = true
|
|
end
|
|
end
|
|
if not done then Flag("FW_EQUIPFX") end
|
|
fx[#fx + 1] = txt
|
|
elseif txt:find("^Chance on hit:") then
|
|
Flag("FW_PROC") fx[#fx + 1] = txt
|
|
elseif txt:find("^Use:") then
|
|
Flag("FW_USE") fx[#fx + 1] = txt
|
|
elseif txt:find("^%(%d+%) Set:") then
|
|
Flag("FW_SET") fx[#fx + 1] = txt
|
|
elseif txt:find("^%+ ?[%d%.]+ %- [%d%.]+ %a+ Damage$") then
|
|
Flag("FW_EXTRADMG") fx[#fx + 1] = txt
|
|
else
|
|
local v = txt:match("^Adds ([%d%.]+) damage per second$")
|
|
if v then SetMax(out, "FW_AMMODPS", tonumber(v)) fx[#fx + 1] = txt end
|
|
end
|
|
end
|
|
end
|
|
while #fx > 8 do fx[#fx] = nil end
|
|
for k, t in ipairs(fx) do if #t > 200 then fx[k] = t:sub(1, 200) end end
|
|
out._fx = (#fx > 0) and fx or nil
|
|
out._e = EFFECT_VER
|
|
end
|
|
|
|
Codex.FXFLAGS = { FW_EQUIPFX = true, FW_PROC = true, FW_USE = true, FW_SET = true, FW_EXTRADMG = true }
|
|
local effectTries = {}
|
|
-- Effekte eines gespeicherten Eintrags nachtragen (nur wenn der Tooltip fertig ist)
|
|
local function AddEffects(itemID, out)
|
|
if out._e == EFFECT_VER or (effectTries[itemID] or 0) >= 3 then return false end
|
|
effectTries[itemID] = (effectTries[itemID] or 0) + 1
|
|
local lines, _, ready = TooltipLines(itemID)
|
|
if not ready then return false end
|
|
ParseEffects(lines, out)
|
|
return true
|
|
end
|
|
Codex.AddEffects = AddEffects
|
|
|
|
local function ReadStats(itemID)
|
|
local link = "item:" .. itemID
|
|
local fn = (C_Item and C_Item.GetItemStats) or GetItemStats
|
|
if type(fn) ~= "function" then
|
|
return nil
|
|
end
|
|
local ok, t = pcall(fn, link)
|
|
if not ok or type(t) ~= "table" then
|
|
return nil
|
|
end
|
|
local out, any = {}, false
|
|
for key, value in pairs(t) do
|
|
if type(key) == "string" and type(value) == "number" and value ~= 0
|
|
and not key:find("^EMPTY_SOCKET") then
|
|
out[key] = value
|
|
any = true
|
|
end
|
|
end
|
|
SplitSpellPower(out)
|
|
if any then
|
|
AddWeapon(itemID, out)
|
|
end
|
|
AddEffects(itemID, out)
|
|
if not any then
|
|
for k, v in pairs(out) do
|
|
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) == "number" and v ~= 0 then any = true break end
|
|
end
|
|
end
|
|
return out, any
|
|
end
|
|
|
|
local function HasAnyStat(st)
|
|
for k, v in pairs(st) do
|
|
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) == "number" and v ~= 0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Rüstung und Waffen haben immer Werte: eine leere Antwort heisst dort "noch nicht geladen"
|
|
local function ShouldHaveStats(itemID)
|
|
local info = Util.GetItem and Util.GetItem(itemID)
|
|
if type(info) ~= "table" then return false end
|
|
local loc = info.equipLoc
|
|
if type(loc) == "string" and loc ~= "" and loc ~= "INVTYPE_BAG" and loc ~= "INVTYPE_TABARD" and loc ~= "INVTYPE_BODY" then return true end
|
|
return info.classID == 2 or info.classID == 4
|
|
end
|
|
|
|
local emptyTries = {} -- itemID -> Versuche in dieser Sitzung (leer gelesene Ruestung/Waffen werden hoechstens dreimal neu gelesen)
|
|
|
|
-- Werte eines Items (aus der Datenbank, sonst vom Spiel lesen und merken)
|
|
function Codex:GetStats(itemID)
|
|
if type(itemID) ~= "number" then
|
|
return nil
|
|
end
|
|
local store = self.db.istats
|
|
if not store then
|
|
store = {}
|
|
self.db.istats = store
|
|
end
|
|
local st = store[itemID]
|
|
if st and not HasAnyStat(st) and (emptyTries[itemID] or 0) < 3 and ShouldHaveStats(itemID) then
|
|
-- aelterer leerer Eintrag zu Ruestung/Waffe: neu lesen
|
|
emptyTries[itemID] = (emptyTries[itemID] or 0) + 1
|
|
local again, any = ReadStats(itemID)
|
|
if any then
|
|
store[itemID] = again
|
|
st = again
|
|
end
|
|
end
|
|
if st then
|
|
SplitSpellPower(st) -- aeltere Eintraege mit gemeinsamem Zaubermacht-Wert
|
|
if st._e ~= EFFECT_VER and Util.GetItem and Util.GetItem(itemID) then
|
|
AddEffects(itemID, st) -- aeltere Eintraege: Effekte nachtragen
|
|
end
|
|
if not st._s and Util.GetItem and Util.GetItem(itemID) then
|
|
AddWeapon(itemID, st) -- aeltere Eintraege: Schaden und Tempo nachtragen
|
|
end
|
|
return st
|
|
end
|
|
if not (Util.GetItem and Util.GetItem(itemID)) then
|
|
return nil -- Item noch nicht geladen: spaeter noch einmal versuchen
|
|
end
|
|
local out, any = ReadStats(itemID)
|
|
if out then
|
|
if not any and ShouldHaveStats(itemID) and (emptyTries[itemID] or 0) < 3 then
|
|
-- Ruestung/Waffe ohne Werte gelesen: nicht merken, spaeter noch einmal versuchen
|
|
emptyTries[itemID] = (emptyTries[itemID] or 0) + 1
|
|
return out
|
|
end
|
|
store[itemID] = out -- auch leer (Item ohne Werte), damit nicht erneut gelesen wird
|
|
return out
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Im Hintergrund: gespeicherte Eintraege ohne Effekte neu lesen (kleine Schritte, keine Ruckler)
|
|
local fxUpgrading
|
|
-- Entfernt Items, die der Server nicht kennt (Item-Lader: fehlgeschlagen), aus der eigenen Datenbank
|
|
function Codex.PurgeMissing()
|
|
local db = Codex.db
|
|
local fail = db and db.itemload and db.itemload.fail
|
|
if not fail then return 0 end
|
|
local n = 0
|
|
local cat = db.cat and db.cat.i
|
|
for id in pairs(fail) do
|
|
if cat and cat[id] and not (Util and Util.GetItem and Util.GetItem(id)) then
|
|
cat[id] = nil
|
|
n = n + 1
|
|
end
|
|
if db.istats and db.istats[id] and not (Util and Util.GetItem and Util.GetItem(id)) then
|
|
db.istats[id] = nil
|
|
n = n + 1
|
|
end
|
|
end
|
|
if n > 0 and FW.Dev and FW.Dev.Diag then FW.Dev.Diag.Add("scan", "Aufgeraeumt: " .. n .. " unbekannte Items entfernt") end
|
|
return n
|
|
end
|
|
|
|
function Codex.UpgradeEffects()
|
|
if fxUpgrading or not (C_Timer and C_Timer.After) then return end
|
|
local store = Codex.db and Codex.db.istats
|
|
if not store then return end
|
|
local list = {}
|
|
for id, st in pairs(store) do
|
|
if type(id) == "number" and type(st) == "table" and st._e ~= EFFECT_VER then list[#list + 1] = id end
|
|
end
|
|
if #list == 0 then return end
|
|
table.sort(list)
|
|
fxUpgrading = true
|
|
local pos = 1
|
|
local function Step()
|
|
local n = 0
|
|
while pos <= #list and n < 15 do
|
|
local id = list[pos]
|
|
local st = store[id]
|
|
if type(st) == "table" and st._e ~= EFFECT_VER and Util.GetItem and Util.GetItem(id) then
|
|
AddEffects(id, st)
|
|
end
|
|
pos = pos + 1
|
|
n = n + 1
|
|
end
|
|
if pos <= #list then C_Timer.After(0.1, Step) else fxUpgrading = false end
|
|
end
|
|
Step()
|
|
end
|
|
|
|
-- Item-Lader (/fwcodex itemload [stop|status]): fordert die Itemdaten der Datenbank langsam an und liest dann Werte und Effekte.
|
|
-- Fortsetzen nach Neustart: erledigte Items haben Werte (_e), fehlgeschlagene stehen in db.itemload.fail.
|
|
local loader
|
|
local LOAD_RATE = 0.25 -- Sekunden zwischen zwei Schritten
|
|
local LOAD_MAX_OUT = 40 -- so viele Anfragen gleichzeitig offen
|
|
local LOAD_TIMEOUT = 5 -- Sekunden, danach gilt eine Anfrage als fehlgeschlagen
|
|
|
|
local function LoaderState()
|
|
local db = Codex.db
|
|
db.itemload = db.itemload or {}
|
|
db.itemload.fail = db.itemload.fail or {}
|
|
return db.itemload
|
|
end
|
|
|
|
function Codex.ItemLoadStatus()
|
|
local st = loader
|
|
if not st then
|
|
FW:Print("Item-Lader: aus. Start mit /fwcodex itemload")
|
|
return
|
|
end
|
|
FW:Print(string.format("Item-Lader: %d von %d bearbeitet, %d geladen, %d fehlgeschlagen, %d offen", st.pos - 1, #st.list, st.loaded, st.failed, st.out))
|
|
end
|
|
|
|
function Codex.ItemLoadStop(silent)
|
|
if loader then
|
|
loader.stop = true
|
|
loader = nil
|
|
if not silent then FW:Print("Item-Lader gestoppt (Fortsetzen mit /fwcodex itemload).") end
|
|
end
|
|
if Codex.db then LoaderState().on = nil end
|
|
end
|
|
|
|
function Codex.ItemLoadStart(ids, resume)
|
|
if loader then
|
|
Codex.ItemLoadStatus()
|
|
return
|
|
end
|
|
if not (C_Timer and C_Timer.After and Util.RequestItem) then
|
|
FW:Print("Item-Lader: nicht verfuegbar (keine Timer/Item-Abfrage).")
|
|
return
|
|
end
|
|
local ls = LoaderState()
|
|
local store = Codex.db.istats or {}
|
|
local list, seen = {}, {}
|
|
for _, id in ipairs(ids or {}) do
|
|
if type(id) == "number" and not seen[id] and not ls.fail[id] then
|
|
seen[id] = true
|
|
local st = store[id]
|
|
if not (st and st._e == EFFECT_VER) then list[#list + 1] = id end
|
|
end
|
|
end
|
|
-- zuerst Items, die schon im Cache sind (kosten keine Abfrage)
|
|
local cached, rest = {}, {}
|
|
for _, id in ipairs(list) do
|
|
if Util.GetItem and Util.GetItem(id) then cached[#cached + 1] = id else rest[#rest + 1] = id end
|
|
end
|
|
table.sort(cached) table.sort(rest)
|
|
for _, id in ipairs(rest) do cached[#cached + 1] = id end
|
|
local state = { list = cached, pos = 1, loaded = 0, failed = 0, out = 0, pending = {}, t0 = _G.GetTime and _G.GetTime() or 0 }
|
|
loader = state
|
|
ls.on = true
|
|
if #cached == 0 then
|
|
FW:Print("Item-Lader: nichts zu tun, alle bekannten Items sind eingelesen.")
|
|
loader = nil
|
|
ls.on = nil
|
|
return
|
|
end
|
|
FW:Print(string.format("Item-Lader: %d Items, im Hintergrund (Stand mit /fwcodex itemload status, Stopp mit /fwcodex itemload stop).", #cached))
|
|
local nextReport = 500
|
|
local function Now() return _G.GetTime and _G.GetTime() or 0 end
|
|
local function Finish()
|
|
if loader == state then loader = nil end
|
|
LoaderState().on = nil
|
|
if Codex.MarkItemsDirty then Codex.MarkItemsDirty() end
|
|
FW:Print(string.format("Item-Lader fertig: %d geladen, %d fehlgeschlagen. Bitte /reload, damit alles gespeichert wird.", state.loaded, state.failed))
|
|
end
|
|
local function Done(id, info)
|
|
if state.pending[id] then
|
|
state.pending[id] = nil
|
|
state.out = state.out - 1
|
|
end
|
|
if info then
|
|
state.loaded = state.loaded + 1
|
|
LoaderState().fail[id] = nil
|
|
Safe(function() Codex:GetStats(id) end)
|
|
else
|
|
state.failed = state.failed + 1
|
|
LoaderState().fail[id] = 1
|
|
end
|
|
end
|
|
local function Step()
|
|
if loader ~= state then return end
|
|
-- Zeitueberschreitungen
|
|
local now = Now()
|
|
for id, t in pairs(state.pending) do
|
|
if now - t > LOAD_TIMEOUT then Done(id, nil) end
|
|
end
|
|
local n = 0
|
|
while state.pos <= #state.list and state.out < LOAD_MAX_OUT and n < 10 do
|
|
local id = state.list[state.pos]
|
|
state.pos = state.pos + 1
|
|
n = n + 1
|
|
if Util.GetItem and Util.GetItem(id) then
|
|
state.loaded = state.loaded + 1
|
|
Safe(function() Codex:GetStats(id) end)
|
|
else
|
|
state.pending[id] = now
|
|
state.out = state.out + 1
|
|
Util.RequestItem(id, function(info) Done(id, info) end)
|
|
end
|
|
end
|
|
if state.pos > nextReport then
|
|
nextReport = nextReport + 500
|
|
if Codex.MarkItemsDirty then Codex.MarkItemsDirty() end
|
|
FW:Print(string.format("Item-Lader: %d von %d, %d geladen, %d fehlgeschlagen", state.pos - 1, #state.list, state.loaded, state.failed))
|
|
end
|
|
if state.pos > #state.list and state.out <= 0 then return Finish() end
|
|
C_Timer.After(LOAD_RATE, Step)
|
|
end
|
|
Step()
|
|
end
|
|
|
|
function Codex:CaptureStats(ids)
|
|
local n = 0
|
|
for _, id in ipairs(ids or {}) do
|
|
if self.db.istats == nil or self.db.istats[id] == nil then
|
|
if self:GetStats(id) then n = n + 1 end
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- Anzeigename eines Wertes ("ITEM_MOD_AGILITY_SHORT" -> "Beweglichkeit")
|
|
function Codex.StatName(key)
|
|
if key:sub(1, 3) == "FW_" then
|
|
local txt = FW.L["CODEX_FX_" .. key]
|
|
-- FW.L liefert bei fehlendem Schluessel den Schluessel selbst zurueck
|
|
if type(txt) == "string" and txt ~= "CODEX_FX_" .. key then return txt end
|
|
if key:sub(1, 6) == "FW_SK_" then
|
|
local nm = key:sub(7):gsub("_", " "):lower():gsub("(%a)([%w]*)", function(a, b) return a:upper() .. b end)
|
|
return string.format(FW.L.CODEX_FX_SKILL, nm)
|
|
end
|
|
end
|
|
local g = _G[key]
|
|
if type(g) == "string" and g ~= "" then
|
|
return g
|
|
end
|
|
local nm = key:gsub("^ITEM_MOD_", ""):gsub("_SHORT$", ""):gsub("_", " "):lower()
|
|
return (nm:gsub("(%a)([%w]*)", function(a, b) return a:upper() .. b end))
|
|
end
|
|
|
|
function Codex.FormatStat(value)
|
|
if math.abs(value - math.floor(value)) < 0.05 then
|
|
return string.format("%d", math.floor(value + 0.5))
|
|
end
|
|
return string.format("%.1f", value)
|
|
end
|
|
|
|
-- Sortierte Liste der Werte-Schluessel, die in der Datenbank vorkommen
|
|
Codex.FXKEYS = { "FW_CRIT", "FW_SPELLCRIT", "FW_RANGEDCRIT", "FW_HIT", "FW_SPELLHIT", "FW_DODGE", "FW_PARRY", "FW_BLOCK", "FW_BLOCKVAL",
|
|
"FW_MP5", "FW_HP5", "FW_DEFENSE", "FW_FISHING", "FW_HASTE", "FW_RANGEDSPEED", "FW_ARMORPEN", "FW_SPELLPEN", "FW_LIFESTEAL", "FW_AMMODPS",
|
|
"FW_SD_ARCANE", "FW_SD_FIRE", "FW_SD_FROST", "FW_SD_HOLY", "FW_SD_NATURE", "FW_SD_SHADOW",
|
|
"FW_SWIM", "FW_RUNSPEED", "FW_MOUNTSPEED", "FW_PROC", "FW_USE", "FW_SET", "FW_EQUIPFX", "FW_EXTRADMG" }
|
|
function Codex:StatKeys(ids)
|
|
local seen, list = {}, {}
|
|
for _, key in ipairs(Codex.FXKEYS) do seen[key] = true list[#list + 1] = key end
|
|
for _, id in ipairs(ids or {}) do
|
|
local st = self.db.istats and self.db.istats[id]
|
|
if st then
|
|
for rawKey in pairs(st) do
|
|
local key = rawKey
|
|
if key:sub(1, 9) == "ITEM_MOD_" and key:sub(-6) ~= "_SHORT" then key = key .. "_SHORT" end
|
|
if not seen[key] and key:sub(1, 1) ~= "_" then
|
|
seen[key] = true
|
|
list[#list + 1] = key
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(list, function(a, b) return Codex.StatName(a) < Codex.StatName(b) end)
|
|
return list
|
|
end
|
|
|
|
function Codex:GetUnit(id)
|
|
return Codex.V().units[id]
|
|
end
|
|
|
|
function Codex:GetObject(id)
|
|
return Codex.V().objects[id]
|
|
end
|
|
|
|
function Codex:ItemName(itemID)
|
|
local c = self.db.cnames and self.db.cnames[itemID]
|
|
local lang = Codex.Lang()
|
|
if c and c[lang] then
|
|
return c[lang]
|
|
end
|
|
local info = Util.GetItem and Util.GetItem(itemID)
|
|
if info and info.name then
|
|
return info.name
|
|
end
|
|
local seen = self.db.names[itemID] or (c and (c.de or c.en))
|
|
if seen then
|
|
return seen
|
|
end
|
|
-- zuletzt die Vorlage (Vanilla-Daten), nur fuer noch nie gesehene Items
|
|
local seed = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Name("items", itemID)
|
|
if seed then
|
|
return seed
|
|
end
|
|
return Codex.ExtraItemName and Codex.ExtraItemName(itemID) or nil
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Namen von Hand eintragen (Deutsch / Englisch)
|
|
-- Kreaturen, Objekte und Quests: Felder cde / cen am Datensatz (werden geteilt, eigener Eintrag geht vor).
|
|
-- Items: db.cnames[itemID] = { de, en } (nur lokal, Itemnamen liefert das Spiel).
|
|
------------------------------------------------------------------------
|
|
|
|
function Codex.Lang()
|
|
return (FW.activeLocale == "deDE") and "de" or "en"
|
|
end
|
|
|
|
local CUSTOM_MAX = 60
|
|
|
|
local function CleanCustom(v, max)
|
|
if type(v) ~= "string" then
|
|
return nil
|
|
end
|
|
v = v:gsub("[%c|]", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
|
if v == "" then
|
|
return nil
|
|
end
|
|
return v:sub(1, max or CUSTOM_MAX)
|
|
end
|
|
Codex.CleanCustom = CleanCustom
|
|
|
|
-- Anzeigename einer Kreatur / eines Objekts: gewaehlte Sprache, sonst vom Spiel gelernt, sonst die andere Sprache
|
|
function Codex.NameOf(ent)
|
|
if type(ent) ~= "table" then
|
|
return nil
|
|
end
|
|
local mine, other = ent.cde, ent.cen
|
|
if Codex.Lang() == "en" then
|
|
mine, other = ent.cen, ent.cde
|
|
end
|
|
return mine or ent.n or other or ent.ne
|
|
end
|
|
|
|
function Codex.QuestTitle(q, id)
|
|
-- Vorlage (Vanilla) nur als letzter Ausweg
|
|
local function SeedTitle()
|
|
return Codex.Seed and Codex.Seed.Ready() and Codex.Seed.QuestTitle(id) or nil
|
|
end
|
|
if type(q) ~= "table" then
|
|
return SeedTitle() or ("Quest #" .. tostring(id))
|
|
end
|
|
local mine, other = q.cde, q.cen
|
|
if Codex.Lang() == "en" then
|
|
mine, other = q.cen, q.cde
|
|
end
|
|
return mine or q.t or other or SeedTitle() or ("Quest #" .. tostring(id))
|
|
end
|
|
|
|
local CUSTOM_TABLE = { units = "units", objects = "objects", quests = "quests" }
|
|
local CUSTOM_EVENT = { units = "unit", objects = "object", quests = "quest", items = "item", zones = "item" }
|
|
|
|
-- Eigene Namen (de, en) zu einer Kreatur, einem Objekt, einer Quest oder einem Item
|
|
function Codex:GetCustom(kind, id)
|
|
-- vorgemerkte (noch nicht gesendete) Aenderungen eines Bearbeiters zuerst
|
|
local hook = Codex.DevHook
|
|
local staged = hook and hook.Staged and hook.Staged(kind, id)
|
|
if staged then
|
|
return staged.de, staged.en
|
|
end
|
|
if kind == "items" then
|
|
local c = self.db.cnames and self.db.cnames[id]
|
|
return c and c.de, c and c.en
|
|
end
|
|
local t = CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]]
|
|
local e = t and t[id]
|
|
return e and e.cde, e and e.cen
|
|
end
|
|
|
|
-- de / en setzen (leer oder nil loescht). Rueckgabe: true, wenn gespeichert
|
|
-- Namen aendern darf nur, wer berechtigt ist und das Modul Freewood-OctoWoW-Dev hat; die Aenderung wird dort erst
|
|
-- vorgemerkt (Vorschau) und mit "Senden" uebernommen (direct = true).
|
|
function Codex:SetCustom(kind, id, de, en, direct)
|
|
if type(id) ~= "number" or not CUSTOM_EVENT[kind] then
|
|
return false
|
|
end
|
|
local max = kind == "quests" and 100 or CUSTOM_MAX
|
|
de, en = CleanCustom(de, max), CleanCustom(en, max)
|
|
if not direct then
|
|
local hook = Codex.DevHook
|
|
if not (Codex.Trust and Codex.Trust.CanEdit() and hook and hook.Stage) then
|
|
return false, "denied"
|
|
end
|
|
return hook.Stage(kind, id, de, en)
|
|
end
|
|
if kind == "items" then
|
|
self.db.cnames = self.db.cnames or {}
|
|
if de or en then
|
|
self.db.cnames[id] = { de = de, en = en }
|
|
else
|
|
self.db.cnames[id] = nil
|
|
end
|
|
else
|
|
local t = self.db[CUSTOM_TABLE[kind]]
|
|
local e = t[id]
|
|
if not e then
|
|
if not (de or en) then
|
|
return true
|
|
end
|
|
if kind == "units" then
|
|
e = self:Unit(id, true)
|
|
elseif kind == "objects" then
|
|
e = self:Object(id, true)
|
|
else
|
|
e = self:Quest(id, true)
|
|
end
|
|
end
|
|
e.cde, e.cen = de, en
|
|
end
|
|
FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id)
|
|
return true
|
|
end
|
|
|
|
-- "fehlerhaft"-Markierung (true / false = aufgehoben / nil = keine Angabe); gleiche Regeln wie bei Namen
|
|
function Codex:SetBad(kind, id, value, direct)
|
|
if type(id) ~= "number" or not CUSTOM_TABLE[kind] then
|
|
return false
|
|
end
|
|
if not direct then
|
|
local hook = Codex.DevHook
|
|
local action = value == true and "bad" or "unbad"
|
|
if not (Codex.Trust and Codex.Trust.Can(action) and hook and hook.StageBad) then
|
|
return false, "denied"
|
|
end
|
|
return hook.StageBad(kind, id, value)
|
|
end
|
|
local t = self.db[CUSTOM_TABLE[kind]]
|
|
local e = t[id]
|
|
if not e then
|
|
if value == nil then return true end
|
|
if kind == "units" then e = self:Unit(id, true) elseif kind == "objects" then e = self:Object(id, true) else e = self:Quest(id, true) end
|
|
end
|
|
e.bad = value
|
|
FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id)
|
|
return true
|
|
end
|
|
|
|
function Codex:IsBad(kind, id)
|
|
local t = CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]]
|
|
local e = t and t[id]
|
|
local view = Codex.V()[CUSTOM_TABLE[kind] or ""]
|
|
local v = view and view[id]
|
|
return (v and v.bad == true) or (e and e.bad == true) or false
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Eigenschaften von Hand (Dev): Zone(n), Dungeon ja/nein, Klasse, Stufen
|
|
-- Kreaturen, Objekte, Quests: Feld ed am Datensatz (wird geteilt, eigener Eintrag geht vor).
|
|
-- Items: db.ied[itemID] = { ed } (eigener Datensatztyp "i"). Zonen: negative Zahl = Instanz-ID.
|
|
-- ed = { z = { mapID, ... }, dg = true/false, cl = "normal"|"elite"|"rare"|"rareelite"|"worldboss", l1, l2 }
|
|
------------------------------------------------------------------------
|
|
|
|
-- Gebiete: die von Hand gesetzte Stufenspanne liegt im Item-Datensatz "i" unter einer hohen ID
|
|
-- (50000000 + Karten-ID, Instanzen 50200000 + Instanz-ID), damit Teilen, Vormerken und Protokoll gleich laufen.
|
|
Codex.ZONE_BASE = 50000000
|
|
function Codex.ZoneEdID(key)
|
|
if type(key) ~= "number" then return nil end
|
|
if key < 0 then return Codex.ZONE_BASE + 200000 + (-key) end
|
|
return Codex.ZONE_BASE + key
|
|
end
|
|
function Codex.ZoneFromEdID(id)
|
|
if type(id) ~= "number" or id < Codex.ZONE_BASE then return nil end
|
|
local r = id - Codex.ZONE_BASE
|
|
if r >= 200000 then return -(r - 200000) end
|
|
return r
|
|
end
|
|
|
|
local ED_CLASS = { normal = true, elite = true, rare = true, rareelite = true, worldboss = true }
|
|
local MAX_ED_ZONES = 6
|
|
Codex.ED_CLASSES = { "normal", "elite", "rare", "rareelite", "worldboss" }
|
|
Codex.MAX_ED_ZONES = MAX_ED_ZONES
|
|
|
|
local function EdInt(v, lo, hi)
|
|
v = tonumber(v)
|
|
if v and v == math.floor(v) and v >= lo and v <= hi then return v end
|
|
return nil
|
|
end
|
|
|
|
-- Bereinigt eine Eigenschaften-Tabelle; Rueckgabe nil, wenn nichts uebrig bleibt
|
|
-- Quest-Zuordnung von Hand (Kreaturen und Objekte): qk = Quests, deren Ziel dies ist; qd = "Quest:Item" (laesst das Quest-Item fallen)
|
|
local function EdIdList(list, max)
|
|
if type(list) ~= "table" then return nil end
|
|
local ids, seen = {}, {}
|
|
for i, p in ipairs(list) do
|
|
if i > 20 then break end
|
|
p = EdInt(p, 1, 100000000)
|
|
if p and not seen[p] and #ids < (max or 8) then ids[#ids + 1] = p seen[p] = true end
|
|
end
|
|
return #ids > 0 and ids or nil
|
|
end
|
|
local function EdPairList(list, max)
|
|
if type(list) ~= "table" then return nil end
|
|
local out, seen = {}, {}
|
|
for i, v in ipairs(list) do
|
|
if i > 20 then break end
|
|
local q, it
|
|
if type(v) == "string" then q, it = v:match("^(%d+):(%d+)$") end
|
|
q, it = EdInt(q, 1, 100000000), EdInt(it, 1, 100000000)
|
|
if q and it then
|
|
local key = q .. ":" .. it
|
|
if not seen[key] and #out < (max or 8) then out[#out + 1] = key seen[key] = true end
|
|
end
|
|
end
|
|
return #out > 0 and out or nil
|
|
end
|
|
|
|
-- Zuordnung "gehoert zur Quest": qb (Kreatur, Objekt, Item: Quest-IDs), qr (Quest: Liste "i:Item", "u:Kreatur", "o:Objekt")
|
|
local function EdTokenList(list, max)
|
|
if type(list) ~= "table" then return nil end
|
|
local out, seen = {}, {}
|
|
for i, v in ipairs(list) do
|
|
if i > 40 then break end
|
|
local k, n
|
|
if type(v) == "string" then k, n = v:match("^([iuo]):(%d+)$") end
|
|
n = EdInt(n, 1, 100000000)
|
|
if k and n then
|
|
local key = k .. ":" .. n
|
|
if not seen[key] and #out < (max or 24) then out[#out + 1] = key seen[key] = true end
|
|
end
|
|
end
|
|
return #out > 0 and out or nil
|
|
end
|
|
|
|
function Codex.CleanEd(ed, kind)
|
|
if type(ed) ~= "table" then return nil end
|
|
local out = {}
|
|
if kind == "zones" then
|
|
-- Gebiete: nur die Stufenspanne von Hand
|
|
local l1, l2 = EdInt(ed.l1, 1, 130), EdInt(ed.l2, 1, 130)
|
|
if l1 or l2 then
|
|
l1, l2 = l1 or l2, l2 or l1
|
|
if l1 > l2 then l1, l2 = l2, l1 end
|
|
return { l1 = l1, l2 = l2 }
|
|
end
|
|
return nil
|
|
end
|
|
if type(ed.z) == "table" then
|
|
local z, seen = {}, {}
|
|
for i, m in ipairs(ed.z) do
|
|
if i > 20 then break end
|
|
m = EdInt(m, -100000, 100000)
|
|
if m and m ~= 0 and not seen[m] and #z < MAX_ED_ZONES then
|
|
z[#z + 1] = m
|
|
seen[m] = true
|
|
end
|
|
end
|
|
if #z > 0 then out.z = z end
|
|
end
|
|
if ed.dg == true or ed.dg == false then out.dg = ed.dg end
|
|
if kind == "units" then
|
|
if ED_CLASS[ed.cl] then out.cl = ed.cl end
|
|
local l1, l2 = EdInt(ed.l1, 1, 130), EdInt(ed.l2, 1, 130)
|
|
if l1 or l2 then
|
|
l1, l2 = l1 or l2, l2 or l1
|
|
if l1 > l2 then l1, l2 = l2, l1 end
|
|
out.l1, out.l2 = l1, l2
|
|
end
|
|
end
|
|
if kind == "units" or kind == "objects" then
|
|
out.qk = EdIdList(ed.qk)
|
|
out.qd = EdPairList(ed.qd)
|
|
end
|
|
if kind == "units" or kind == "objects" or kind == "items" then
|
|
out.qb = EdIdList(ed.qb, 12)
|
|
end
|
|
if kind == "quests" then
|
|
out.qr = EdTokenList(ed.qr)
|
|
end
|
|
if kind == "items" then
|
|
-- Schluessel von Hand: Item, das zum Oeffnen (Truhe) noetig ist, um dieses Item zu bekommen
|
|
local key = EdInt(ed.key, 1, 100000000)
|
|
if key then out.key = key end
|
|
end
|
|
if kind == "quests" then
|
|
-- Vorquests von Hand (jede genuegt), Folgequests, davor/danach (Reihenfolge im Plan), Stufe, Questtext
|
|
local function Ids(list, max)
|
|
if type(list) ~= "table" then return nil end
|
|
local ids, seen = {}, {}
|
|
for i, p in ipairs(list) do
|
|
if i > 20 then break end
|
|
p = EdInt(p, 1, 100000000)
|
|
if p and not seen[p] and #ids < (max or 8) then
|
|
ids[#ids + 1] = p
|
|
seen[p] = true
|
|
end
|
|
end
|
|
return #ids > 0 and ids or nil
|
|
end
|
|
out.pre = Ids(ed.pre)
|
|
out.nx = Ids(ed.nx)
|
|
out.bf = Ids(ed.bf)
|
|
out.af = Ids(ed.af)
|
|
local ql = EdInt(ed.ql, 1, 130)
|
|
if ql then out.ql = ql end
|
|
local si = EdInt(ed.si, 1, 100000000) -- Item, durch das die Quest startet
|
|
if si then out.si = si end
|
|
if type(ed.tx) == "string" then
|
|
local t = ed.tx:gsub("[%c]", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
if t ~= "" then out.tx = t:sub(1, 600) end
|
|
end
|
|
end
|
|
if next(out) == nil then return nil end
|
|
return out
|
|
end
|
|
|
|
local function CopyEd(ed)
|
|
if not ed then return nil end
|
|
local out = { dg = ed.dg, cl = ed.cl, l1 = ed.l1, l2 = ed.l2, key = ed.key, ql = ed.ql, tx = ed.tx, si = ed.si }
|
|
if ed.z then
|
|
out.z = {}
|
|
for i, m in ipairs(ed.z) do out.z[i] = m end
|
|
end
|
|
for _, f in ipairs({ "pre", "nx", "bf", "af", "qk", "qd", "qb", "qr" }) do
|
|
if ed[f] then
|
|
out[f] = {}
|
|
for i, p in ipairs(ed[f]) do out[f][i] = p end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
Codex.CopyEd = CopyEd
|
|
|
|
-- Gespeicherter Stand (ohne Vormerkung)
|
|
function Codex:GetEdStored(kind, id)
|
|
local t = (kind == "items" or kind == "zones") and self.db.ied or (CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]])
|
|
local e = t and t[id]
|
|
return e and e.ed or nil
|
|
end
|
|
|
|
-- Eigenschaften zu einer Kreatur, einem Objekt, einer Quest oder einem Item (vorgemerkte Aenderungen zuerst)
|
|
function Codex:GetEd(kind, id)
|
|
local hook = Codex.DevHook
|
|
local st = hook and hook.StagedEd and hook.StagedEd(kind, id)
|
|
if st then return CopyEd(st.ed) end
|
|
return CopyEd(self:GetEdStored(kind, id))
|
|
end
|
|
|
|
-- ed = nil loescht. Wie bei Namen: erst vormerken (Freewood-OctoWoW-Dev), mit "Senden" uebernehmen (direct = true)
|
|
function Codex:SetEd(kind, id, ed, direct)
|
|
if type(id) ~= "number" or not CUSTOM_EVENT[kind] then
|
|
return false
|
|
end
|
|
ed = Codex.CleanEd(ed, kind)
|
|
if not direct then
|
|
local hook = Codex.DevHook
|
|
if not (Codex.Trust and Codex.Trust.CanEdit() and hook and hook.StageEd) then
|
|
return false, "denied"
|
|
end
|
|
return hook.StageEd(kind, id, ed)
|
|
end
|
|
if kind == "items" or kind == "zones" then
|
|
self.db.ied = self.db.ied or {}
|
|
self.db.ied[id] = ed and { ed = CopyEd(ed) } or nil
|
|
if Codex.Share and Codex.Share.NoteEd then Codex.Share.NoteEd(ed ~= nil) end
|
|
else
|
|
local t = self.db[CUSTOM_TABLE[kind]]
|
|
local e = t[id]
|
|
if not e then
|
|
if not ed then return true end
|
|
if kind == "units" then e = self:Unit(id, true) elseif kind == "objects" then e = self:Object(id, true) else e = self:Quest(id, true) end
|
|
end
|
|
e.ed = CopyEd(ed)
|
|
if Codex.Share and Codex.Share.NoteEd then Codex.Share.NoteEd(ed ~= nil) end
|
|
end
|
|
Codex.chainRev = (Codex.chainRev or 0) + 1
|
|
FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id)
|
|
return true
|
|
end
|
|
|
|
-- Verschlossene Truhen: Item, das ein anderes Item (aus einer Truhe) erst zugaenglich macht { [Item] = Schluessel-Item }.
|
|
-- Eingebaut: Vergilbter Umschlag (Truhe auf dem Gebaeude bei Lieutenant Benedict) braucht Benedicts Schluessel.
|
|
Codex.KEY_DEFAULT = { [4881] = 4882 }
|
|
|
|
function Codex.KeyOf(itemID)
|
|
if not itemID then return nil end
|
|
local ed = Codex:GetEd("items", itemID)
|
|
if ed and ed.key then return ed.key end
|
|
return Codex.KEY_DEFAULT[itemID]
|
|
end
|
|
|
|
-- Datensatz mit Eigenschaften: ueberschreibt Klasse (c), Stufen (l1, l2), Dungeon (dg) und Zonen (ez, bei Quests zone).
|
|
-- inplace = true: direkt in der (frisch gebauten) Tabelle e, sonst eine Ueberlagerung ueber dem Original.
|
|
function Codex.ApplyEd(e, kind, inplace)
|
|
local ed = type(e) == "table" and e.ed
|
|
if not ed then return e end
|
|
local o = inplace and e or {}
|
|
if ed.cl then
|
|
if ed.cl == "normal" then o.c = false else o.c = ed.cl end
|
|
end
|
|
if ed.l1 then o.l1, o.l2 = ed.l1, ed.l2 or ed.l1 end
|
|
if ed.dg ~= nil then o.dg = ed.dg end
|
|
if ed.z then
|
|
o.ez = ed.z
|
|
if kind == "quests" then o.zone = ed.z[1] end
|
|
end
|
|
if ed.pre then o.pre = ed.pre end
|
|
if ed.key then o.key = ed.key end
|
|
if kind == "quests" then
|
|
if ed.ql then o.lvl = ed.ql end
|
|
if ed.si then o.si = ed.si end
|
|
if ed.tx then o.dtx = ed.tx end
|
|
end
|
|
if inplace then return e end
|
|
return setmetatable(o, { __index = e })
|
|
end
|
|
|
|
-- Karten eines Datensatzes: von Hand gesetzte zuerst, sonst die Hauptkarte der Fundorte
|
|
function Codex.ZonesOf(e)
|
|
if type(e) ~= "table" then return {} end
|
|
if e.ez and #e.ez > 0 then return e.ez end
|
|
if type(e.iz) == "table" then
|
|
local best, bestN
|
|
for id, n in pairs(e.iz) do
|
|
if not bestN or n > bestN then best, bestN = id, n end
|
|
end
|
|
if best then return { -best } end
|
|
end
|
|
local m = e.cells and Codex.MainMap(e.cells)
|
|
return m and { m } or {}
|
|
end
|
|
|
|
function Codex:SetCustomLang(kind, id, lang, name)
|
|
local de, en = self:GetCustom(kind, id)
|
|
if lang == "de" then
|
|
de = name
|
|
else
|
|
en = name
|
|
end
|
|
return self:SetCustom(kind, id, de, en)
|
|
end
|
|
|
|
-- Text mit Zeilen "ID Name" (auch "ID;Name", "ID=Name", "ID<Tab>Name") einlesen.
|
|
-- lang: "de" oder "en". Rueckgabe: gespeichert, uebersprungen
|
|
function Codex:ImportNames(kind, lang, text)
|
|
local done, skipped = 0, 0
|
|
if type(text) ~= "string" or not CUSTOM_EVENT[kind] or (lang ~= "de" and lang ~= "en") then
|
|
return 0, 0
|
|
end
|
|
local lines = 0
|
|
for line in (text .. "\n"):gmatch("([^\r\n]*)[\r\n]") do
|
|
local id, name = line:match("^%s*#?(%d+)%s*[=;:,\t ]%s*(.-)%s*$")
|
|
if id or line:find("%S") then
|
|
lines = lines + 1
|
|
if lines > 1000 then
|
|
skipped = skipped + 1
|
|
elseif id and CleanCustom(name) and tonumber(id) <= 100000000 then
|
|
self:SetCustomLang(kind, tonumber(id), lang, name)
|
|
done = done + 1
|
|
else
|
|
skipped = skipped + 1
|
|
end
|
|
end
|
|
end
|
|
return done, skipped
|
|
end
|
|
|
|
-- Drop-Chance: Anteil der gelooteten Leichen, in denen das Item lag. Rueckgabe: chance (0..1), n, k
|
|
function Codex.DropChance(ent, itemID, alt)
|
|
local bucket = alt and ent.alt or ent
|
|
if not bucket or not bucket.d or not bucket.d[itemID] or not bucket.k or bucket.k == 0 then
|
|
return nil, 0, bucket and bucket.k or 0
|
|
end
|
|
local d = bucket.d[itemID]
|
|
local n = d.n
|
|
if d.qi and d.qk and d.qk >= n and d.qk > 0 then
|
|
return n / d.qk, n, d.qk -- Quest-Item: nur Leichen mit laufender Quest
|
|
end
|
|
return n / bucket.k, n, bucket.k
|
|
end
|
|
|
|
-- Sortierte Beuteliste { { id, chance, n, k, avg, qi } }
|
|
function Codex.DropList(ent, alt)
|
|
local bucket = alt and ent.alt or ent
|
|
local list = {}
|
|
if bucket and bucket.d and bucket.k and bucket.k > 0 then
|
|
for id, d in pairs(bucket.d) do
|
|
local den = (d.qi and d.qk and d.qk >= d.n and d.qk > 0) and d.qk or bucket.k
|
|
list[#list + 1] = {
|
|
id = id, chance = d.n / den, n = d.n, k = den,
|
|
avg = d.n > 0 and d.q / d.n or 1, qi = d.qi and true or false,
|
|
}
|
|
end
|
|
end
|
|
table.sort(list, function(a, b)
|
|
if a.chance ~= b.chance then
|
|
return a.chance > b.chance
|
|
end
|
|
return a.id < b.id
|
|
end)
|
|
return list
|
|
end
|
|
|
|
-- Durchschnittliches Gold pro Leiche mit Gold (Kupfer)
|
|
function Codex.AverageCoin(ent)
|
|
if ent.coin and ent.coinN and ent.coinN > 0 then
|
|
return ent.coin / ent.coinN
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Orte: Zellen zu Fundstellen zusammenfassen (je BIN x BIN Zellen eine Stelle).
|
|
-- Rueckgabe: { { m, x, y, n } } sortiert nach Treffern, x/y als Anteil 0..1
|
|
local SPOT_BIN = 5
|
|
|
|
function Codex.TopSpots(cells, limit)
|
|
local bins = {}
|
|
for key, n in pairs(cells or {}) do
|
|
local m = math.floor(key / (GRID * GRID))
|
|
local idx = key % (GRID * GRID)
|
|
local cx, cy = idx % GRID, math.floor(idx / GRID)
|
|
local bk = m * 1000000 + math.floor(cy / SPOT_BIN) * 1000 + math.floor(cx / SPOT_BIN)
|
|
local b = bins[bk]
|
|
if not b then
|
|
b = { m = m, sx = 0, sy = 0, n = 0 }
|
|
bins[bk] = b
|
|
end
|
|
b.sx, b.sy, b.n = b.sx + (cx + 0.5) * n, b.sy + (cy + 0.5) * n, b.n + n
|
|
end
|
|
local list = {}
|
|
for _, b in pairs(bins) do
|
|
list[#list + 1] = { m = b.m, x = b.sx / b.n / GRID, y = b.sy / b.n / GRID, n = b.n }
|
|
end
|
|
table.sort(list, function(a, b)
|
|
if a.n ~= b.n then return a.n > b.n end
|
|
if a.m ~= b.m then return a.m < b.m end
|
|
if a.y ~= b.y then return a.y < b.y end
|
|
return a.x < b.x
|
|
end)
|
|
if limit and #list > limit then
|
|
for i = #list, limit + 1, -1 do list[i] = nil end
|
|
end
|
|
return list
|
|
end
|
|
|
|
function Codex.AddCells(dst, cells)
|
|
for key, n in pairs(cells or {}) do
|
|
dst[key] = (dst[key] or 0) + n
|
|
end
|
|
return dst
|
|
end
|
|
|
|
local MAX_POINTS = 4000
|
|
|
|
local function EntityName(ent, id, kind)
|
|
return (ent and ent.n) or ((kind == "objects" and "#" or "#") .. tostring(id))
|
|
end
|
|
|
|
local function AddEntityPoints(points, ent, id, kind, label)
|
|
if not ent or not ent.cells then
|
|
return
|
|
end
|
|
for key, n in pairs(ent.cells) do
|
|
if #points >= MAX_POINTS then
|
|
return
|
|
end
|
|
local m = math.floor(key / (GRID * GRID))
|
|
local idx = key % (GRID * GRID)
|
|
points[#points + 1] = {
|
|
m = m, x = ((idx % GRID) + 0.5) / GRID, y = (math.floor(idx / GRID) + 0.5) / GRID,
|
|
n = n, name = label or EntityName(ent, id, kind), k = kind == "objects" and "o" or "u",
|
|
}
|
|
end
|
|
end
|
|
|
|
-- Fundstellen der Vorlage (Vanilla) als Punkte
|
|
local function AddSeedPoints(points, kind, id, label)
|
|
local Seed = Codex.Seed
|
|
if not (Seed and Seed.Ready()) then
|
|
return
|
|
end
|
|
local cells = Seed.Cells(kind, id)
|
|
if next(cells) then
|
|
AddEntityPoints(points, { cells = cells, n = Seed.Name(kind, id) }, id, kind, label)
|
|
end
|
|
end
|
|
|
|
local function HasCells(ent)
|
|
return ent and ent.cells and next(ent.cells) ~= nil
|
|
end
|
|
|
|
local function AddSourceKey(points, key, label)
|
|
local tk = key:sub(1, 1) == "u" and "units" or "objects"
|
|
local id = tonumber(key:sub(2))
|
|
local ent = id and (tk == "units" and Codex.V().units[id] or Codex.V().objects[id])
|
|
if HasCells(ent) then
|
|
AddEntityPoints(points, ent, id, tk, label)
|
|
elseif id then
|
|
AddSeedPoints(points, tk, id, label)
|
|
end
|
|
end
|
|
|
|
-- Punkte fuer die Weltkarte. spec = { kind = "units"|"objects"|"items"|"quests", id = ... }
|
|
-- Punkt: { m, x, y, n, name, pin } (pin: feste Stelle wie Quest-Geber)
|
|
function Codex.CollectPoints(spec)
|
|
local points = {}
|
|
local kind, id = spec.kind, spec.id
|
|
if kind == "units" or kind == "objects" then
|
|
local ent = kind == "units" and Codex.V().units[id] or Codex.V().objects[id]
|
|
if HasCells(ent) then
|
|
AddEntityPoints(points, ent, id, kind)
|
|
else
|
|
AddSeedPoints(points, kind, id)
|
|
end
|
|
elseif kind == "items" then
|
|
for _, pair in ipairs({ { "units", Codex.V().units }, { "objects", Codex.V().objects } }) do
|
|
for eid, ent in pairs(pair[2]) do
|
|
local chance = Codex.DropChance(ent, id, false)
|
|
if chance then
|
|
AddEntityPoints(points, ent, eid, pair[1],
|
|
string.format("%s (%d%%)", EntityName(ent, eid, pair[1]), math.floor(chance * 100 + 0.5)))
|
|
end
|
|
end
|
|
end
|
|
-- Vorlage: nur wenn es noch keine eigenen Fundstellen gibt (die besten Quellen)
|
|
local Seed = Codex.Seed
|
|
if #points == 0 and Seed and Seed.Ready() then
|
|
local it = Seed.Item(id)
|
|
for _, def in ipairs({ { "units", it and it.u or {}, 8 }, { "objects", it and it.o or {}, 3 } }) do
|
|
for i, src in ipairs(def[2]) do
|
|
if i > def[3] then break end
|
|
AddSeedPoints(points, def[1], src.id,
|
|
string.format("%s (%d%%)", Seed.Name(def[1], src.id) or ("#" .. src.id), math.floor(src.chance + 0.5)))
|
|
end
|
|
end
|
|
end
|
|
elseif kind == "quests" then
|
|
local q = Codex.V().quests[id]
|
|
if q then
|
|
for _, def in ipairs({ { q.give, L.CODEX_TAG_GIVE, "give" }, { q.turn, L.CODEX_TAG_TURN, "turn" } }) do
|
|
local ref = def[1]
|
|
if ref and ref.m and ref.x and ref.y then
|
|
points[#points + 1] = { m = ref.m, x = ref.x, y = ref.y, n = 4, pin = true, tag = def[3],
|
|
name = string.format(def[2], ref.n or "?") }
|
|
end
|
|
end
|
|
for _, o in ipairs(q.obj or {}) do
|
|
if o.ty == "monster" then
|
|
for uid in pairs(o.ids or {}) do
|
|
AddEntityPoints(points, Codex.V().units[uid], uid, "units")
|
|
end
|
|
for key in pairs(Codex.QuestKillKeys(id)) do AddSourceKey(points, key) end
|
|
elseif o.ty == "item" then
|
|
for _, itemID in ipairs(Codex.ObjectiveItems(q, o, id)) do
|
|
local before = #points
|
|
for key in pairs(Codex.QuestItemKeys(id, itemID)) do AddSourceKey(points, key) end
|
|
for key in pairs(q.qi and q.qi[itemID] or {}) do
|
|
AddSourceKey(points, key)
|
|
end
|
|
-- eigene Quests (Creator) kennen keine Quest-Gegenstaende: dann die bekannten Quellen des Gegenstands zeigen
|
|
if q.loc and #points == before then
|
|
for _, p in ipairs(Codex.CollectPoints({ kind = "items", id = itemID })) do points[#points + 1] = p end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Vorlage: Geber, Abgabe und Ziele, soweit eigene Daten fehlen
|
|
local Seed = Codex.Seed
|
|
local sq = Seed and Seed.Ready() and Seed.Quest(id)
|
|
if sq then
|
|
local function Pin(refs, tag, have, tagName)
|
|
if have then return end
|
|
for _, r in ipairs(refs) do
|
|
if r.kind ~= "items" then
|
|
local m, x, y = Seed.FirstSpot(r.kind, r.id)
|
|
if m then
|
|
points[#points + 1] = { m = m, x = x, y = y, n = 4, pin = true, tag = tagName,
|
|
name = string.format(tag, Seed.Name(r.kind, r.id) or ("#" .. r.id)) }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
Pin(sq.start, L.CODEX_TAG_GIVE, q and q.give and q.give.m, "give")
|
|
Pin(sq.stop, L.CODEX_TAG_TURN, q and q.turn and q.turn.m, "turn")
|
|
if not (q and q.obj and #q.obj > 0) then
|
|
for _, r in ipairs(sq.obj) do
|
|
if r.kind ~= "items" then
|
|
AddSeedPoints(points, r.kind, r.id)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return points
|
|
end
|
|
|
|
-- Karte und uebergeordnete Karten (Stadt -> Gebiet -> Kontinent)
|
|
function Codex.MapChain(mapID)
|
|
local list, seen = { mapID }, { [mapID] = true }
|
|
local CM = _G.C_Map
|
|
local id = mapID
|
|
for _ = 1, 6 do
|
|
local ok, info = pcall(function() return CM and CM.GetMapInfo and CM.GetMapInfo(id) end)
|
|
local parent = ok and info and info.parentMapID or nil
|
|
if not parent or parent == 0 or seen[parent] then break end
|
|
list[#list + 1] = parent
|
|
seen[parent] = true
|
|
id = parent
|
|
end
|
|
return list
|
|
end
|
|
|
|
-- Position des Spielers auf der Karte mapID (auch auf einer uebergeordneten Karte)
|
|
function Codex.PlayerPosOn(mapID, loc)
|
|
if not loc then return nil end
|
|
if loc.mapID == mapID and loc.x and loc.y then return loc.x, loc.y end
|
|
local CM = _G.C_Map
|
|
if CM and CM.GetPlayerMapPosition then
|
|
local ok, pos = pcall(CM.GetPlayerMapPosition, mapID, "player")
|
|
if ok and pos and pos.GetXY then
|
|
local x, y = pos:GetXY()
|
|
if type(x) == "number" and type(y) == "number" and (x ~= 0 or y ~= 0) then return x, y end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Kann der Spieler die Quest jetzt annehmen? Nicht angenommen, nicht abgeschlossen, Mindeststufe erreicht,
|
|
-- Volk und Klasse passen, und (laut Vorlage) wurde eine Vorquest abgeschlossen.
|
|
local function HasBit(mask, index)
|
|
return math.floor(mask / 2 ^ (index - 1)) % 2 == 1
|
|
end
|
|
|
|
-- Klasse und Volk des Spielers als Nummer (Bit-Index fuer die Masken der Quest-Daten).
|
|
-- Der Client liefert UnitClass/UnitRace nur mit Name und englischem Kennwort (kein dritter Wert): daraus die Nummer ableiten.
|
|
local CLASS_ID = { WARRIOR = 1, PALADIN = 2, HUNTER = 3, ROGUE = 4, PRIEST = 5, SHAMAN = 7, MAGE = 8, WARLOCK = 9, DRUID = 11 }
|
|
local RACE_ID = { HUMAN = 1, ORC = 2, DWARF = 3, NIGHTELF = 4, SCOURGE = 5, UNDEAD = 5, TAUREN = 6, GNOME = 7, TROLL = 8,
|
|
GOBLIN = 9, BLOODELF = 10, HIGHELF = 10 }
|
|
local function PlayerClassID()
|
|
if not UnitClass then return nil end
|
|
local _, token, id = UnitClass("player")
|
|
if type(id) == "number" and id > 0 then return id end
|
|
return type(token) == "string" and CLASS_ID[token:upper()] or nil
|
|
end
|
|
local function PlayerRaceID()
|
|
if not UnitRace then return nil end
|
|
local name, token, id = UnitRace("player")
|
|
if type(id) == "number" and id > 0 then return id end
|
|
local key = type(token) == "string" and token:upper():gsub("[%s_]", "") or nil
|
|
if key and RACE_ID[key] then return RACE_ID[key] end
|
|
key = type(name) == "string" and name:upper():gsub("[%s_]", "") or nil
|
|
return key and RACE_ID[key] or nil
|
|
end
|
|
Codex.PlayerClassID, Codex.PlayerRaceID = PlayerClassID, PlayerRaceID
|
|
-- assume: Menge von Quest-IDs, die als abgegeben gelten (Plan: Folgequest nach der Abgabe)
|
|
function Codex.QuestAvailable(id, assume)
|
|
if Codex.QuestStatus(id) ~= nil then return false end
|
|
local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil
|
|
local live = Codex.V().quests[id]
|
|
if not sq and not live then return true end
|
|
-- Voraussetzungen: Vorlage, sonst die Angaben in den eigenen Daten
|
|
local min = (sq and sq.min) or (live and live.mn)
|
|
if (not min or min == 0) and live and Codex.Avail then
|
|
local own = Codex.db.quests[id]
|
|
min = Codex.Avail.InferredMin(live, own ~= nil and own.mi ~= nil) or min
|
|
end
|
|
local race = (sq and sq.race) or (live and live.rc)
|
|
local class = (sq and sq.class) or (live and live.cl)
|
|
local pl = Codex.sim and Codex.sim.level or (UnitLevel and UnitLevel("player")) or nil
|
|
if min and min > 0 and pl and pl < min then return false end
|
|
if race and race > 0 then
|
|
local raceID = PlayerRaceID()
|
|
if raceID and not HasBit(race, raceID) then return false end
|
|
end
|
|
if class and class > 0 then
|
|
local classID = PlayerClassID()
|
|
if classID and not HasBit(class, classID) then return false end
|
|
end
|
|
local pre = Codex.QuestPre(id)
|
|
if #pre > 0 then
|
|
local any = false
|
|
for _, p in ipairs(pre) do
|
|
if Codex.QuestStatus(p) == "done" or (assume and assume[p]) then any = true break end
|
|
end
|
|
if not any then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Quest-Route: eine Station (der Questgeber) je annehmbarer Quest.
|
|
-- Schritt 1 (aufwendig, einmal je Route): Kandidaten mit Position sammeln
|
|
-- opts (optional): { below = n, above = m } = nur Quests von Spielerstufe - n bis Spielerstufe + m
|
|
local function QuestLevel(id)
|
|
local q = Codex.V().quests[id]
|
|
local lv = q and tonumber(q.lvl)
|
|
if not lv or lv <= 0 then
|
|
local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil
|
|
lv = sq and tonumber(sq.lvl)
|
|
end
|
|
return lv and lv > 0 and lv or nil
|
|
end
|
|
|
|
function Codex.QuestRouteCandidates(ids, opts)
|
|
local cands = {}
|
|
local pl = UnitLevel and UnitLevel("player") or nil
|
|
local lo, hi
|
|
if opts and pl then
|
|
if opts.below then lo = pl - opts.below end
|
|
if opts.above then hi = pl + opts.above end
|
|
end
|
|
for _, id in ipairs(ids) do
|
|
local inRange = true
|
|
if lo or hi then
|
|
local lv = QuestLevel(id)
|
|
inRange = lv ~= nil and (not lo or lv >= lo) and (not hi or lv <= hi)
|
|
end
|
|
if inRange and Codex.QuestAvailable(id) then
|
|
local pick, any
|
|
for _, p in ipairs(Codex.CollectPoints({ kind = "quests", id = id })) do
|
|
if p.pin then
|
|
any = any or p
|
|
if p.tag == "give" and not pick then pick = p end
|
|
end
|
|
end
|
|
pick = pick or any
|
|
if pick then
|
|
local q = Codex.V().quests[id]
|
|
cands[#cands + 1] = { id = id, m = pick.m, x = pick.x, y = pick.y, title = Codex.QuestTitle(q, id) }
|
|
end
|
|
end
|
|
end
|
|
return cands
|
|
end
|
|
|
|
-- Schritt 2 (billig, bei jeder Bewegung): Karte waehlen und nach naechstem Nachbarn ordnen.
|
|
-- Rueckgabe: Punkte (mit Nummer im Namen), Karten-ID, Weg (Spielerposition + Stationen)
|
|
local MAX_ROUTE = 40
|
|
function Codex.QuestRouteOrder(cands, loc)
|
|
if #cands == 0 then return {}, nil, {} end
|
|
-- Karte: die des Spielers, sonst (Stadt, Hoehle ...) die naechste uebergeordnete Karte mit Stationen.
|
|
-- Ohne Spielerposition die Karte mit den meisten Stationen. Nie ein fernes Gebiet.
|
|
local perMap, bestMap, bestN = {}, nil, 0
|
|
for _, c in ipairs(cands) do
|
|
perMap[c.m] = (perMap[c.m] or 0) + 1
|
|
if perMap[c.m] > bestN or (perMap[c.m] == bestN and c.m < bestMap) then bestMap, bestN = c.m, perMap[c.m] end
|
|
end
|
|
local mapID
|
|
if loc and loc.mapID then
|
|
for _, m in ipairs(Codex.MapChain(loc.mapID)) do
|
|
if perMap[m] then mapID = m break end
|
|
end
|
|
if not mapID then return {}, nil, {} end
|
|
else
|
|
mapID = bestMap
|
|
end
|
|
local pool = {}
|
|
for _, c in ipairs(cands) do
|
|
if c.m == mapID then pool[#pool + 1] = c end
|
|
end
|
|
table.sort(pool, function(a, b) return a.id < b.id end)
|
|
local cx, cy = Codex.PlayerPosOn(mapID, loc)
|
|
local path = {}
|
|
if cx then path[1] = { m = mapID, x = cx, y = cy } end
|
|
if not cx then cx, cy = pool[1].x, pool[1].y end
|
|
local points = {}
|
|
while #pool > 0 and #points < MAX_ROUTE do
|
|
local bi, bd
|
|
for i, c in ipairs(pool) do
|
|
local d = (c.x - cx) ^ 2 + (c.y - cy) ^ 2
|
|
if not bd or d < bd then bi, bd = i, d end
|
|
end
|
|
local c = table.remove(pool, bi)
|
|
points[#points + 1] = { m = c.m, x = c.x, y = c.y, n = 4, pin = true, id = c.id,
|
|
name = string.format("%d. %s", #points + 1, c.title) }
|
|
cx, cy = c.x, c.y
|
|
path[#path + 1] = { m = c.m, x = c.x, y = c.y }
|
|
end
|
|
return points, mapID, path
|
|
end
|
|
|
|
function Codex.QuestRoute(ids, loc)
|
|
return Codex.QuestRouteOrder(Codex.QuestRouteCandidates(ids), loc)
|
|
end
|
|
|
|
-- Itemdaten (Qualitaet, Typ, Preis) nachladen; feuert CODEX_ITEMS_LOADED, wenn etwas angekommen ist.
|
|
-- Rueckgabe: Zahl neu angeforderter Items
|
|
function Codex:LoadItemInfo(ids)
|
|
local started = 0
|
|
for _, id in ipairs(ids) do
|
|
if not state.requested[id] and not Util.GetItem(id) then
|
|
state.requested[id] = true
|
|
state.pendingInfo = state.pendingInfo + 1
|
|
started = started + 1
|
|
Util.RequestItem(id, function()
|
|
state.pendingInfo = math.max(0, state.pendingInfo - 1)
|
|
if not state.infoTimer then
|
|
state.infoTimer = true
|
|
C_Timer.After(0.5, function()
|
|
state.infoTimer = nil
|
|
FW:Fire("CODEX_ITEMS_LOADED")
|
|
end)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
return started
|
|
end
|
|
|
|
-- Durchschnittlicher Wert einer gelooteten Leiche in Kupfer: tatsaechliches Gold plus Haendlerwert
|
|
-- der Items nach Drop-Chance und Durchschnittsmenge. Rueckgabe: gesamt, gold, items, Items ohne Preisdaten
|
|
function Codex.KillValue(ent, useAH)
|
|
local k = ent.k or 0
|
|
if k <= 0 then
|
|
return nil
|
|
end
|
|
local coin = (ent.coin or 0) / k
|
|
local items, missing = 0, 0
|
|
for id, d in pairs(ent.d or {}) do
|
|
if not d.qi then
|
|
local info = Util.GetItem(id)
|
|
if info then
|
|
local v = info.sellPrice or 0
|
|
if useAH then
|
|
local ah = Codex.AuctionPrice(id)
|
|
if ah and ah > v then v = ah end
|
|
end
|
|
items = items + (d.q / k) * v
|
|
else
|
|
local ah = useAH and Codex.AuctionPrice(id)
|
|
if ah then
|
|
items = items + (d.q / k) * ah
|
|
else
|
|
missing = missing + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return coin + items, coin, items, missing
|
|
end
|
|
|
|
-- Auktionshauspreis in Kupfer. Ein spaeteres Auktionsmodul setzt Codex.AuctionProvider = function(itemID) return kupfer end;
|
|
-- ohne Anbieter bleibt der Wert leer (und die Sortierung "Auktionspreis" wird nicht angeboten).
|
|
function Codex.AuctionPrice(itemID)
|
|
local fn = Codex.AuctionProvider
|
|
if type(fn) ~= "function" then
|
|
return nil
|
|
end
|
|
local ok, v = pcall(fn, itemID)
|
|
if ok and type(v) == "number" and v == v and v >= 0 then
|
|
return v
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Codex:Counts()
|
|
local q, u, o, n = 0, 0, 0, 0
|
|
for _ in pairs(Codex.V().quests) do q = q + 1 end
|
|
for _ in pairs(Codex.V().units) do u = u + 1 end
|
|
for _ in pairs(Codex.V().objects) do o = o + 1 end
|
|
for _ in pairs(self.db.names) do n = n + 1 end
|
|
return q, u, o, n
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Start
|
|
------------------------------------------------------------------------
|
|
|
|
function Codex:OnEnable()
|
|
-- Teilen ist von Anfang an an: einmalig auch bei vorhandenen Daten einschalten (danach gilt, was der Spieler waehlt)
|
|
if self.db and self.db.settings and not self.db.settings.shareDefaultOn then
|
|
self.db.settings.shareDefaultOn = true
|
|
self.db.settings.share = true
|
|
end
|
|
FW:RegisterEvent("QUEST_DETAIL", self.OnQuestDetail, self)
|
|
FW:RegisterEvent("QUEST_ACCEPTED", self.OnQuestAccepted, self)
|
|
FW:RegisterEvent("QUEST_PROGRESS", self.OnQuestProgress, self)
|
|
FW:RegisterEvent("QUEST_COMPLETE", self.OnQuestComplete, self)
|
|
FW:RegisterEvent("QUEST_TURNED_IN", self.OnQuestTurnedIn, self)
|
|
FW:RegisterEvent("QUEST_REMOVED", self.OnQuestRemoved, self)
|
|
FW:RegisterEvent("QUEST_LOG_UPDATE", self.OnQuestLogUpdate, self)
|
|
FW:RegisterEvent("UNIT_QUEST_LOG_CHANGED", self.OnQuestLogUpdate, self)
|
|
FW:RegisterEvent("LOOT_OPENED", self.OnLootOpened, self)
|
|
FW:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE", self.OnFactionMessage, self)
|
|
FW:RegisterEvent("CHAT_MSG_COMBAT_XP_GAIN", self.OnXPMessage, self)
|
|
FW:RegisterEvent("PLAYER_LEVEL_UP", self.OnLevelUp, self)
|
|
for _, event in ipairs({ "PLAYER_REGEN_DISABLED", "PLAYER_ENTERING_WORLD", "UPDATE_EXHAUSTION" }) do local event = event
|
|
FW:RegisterEvent(event, function() Codex:OnPoolEvent(event) end, self)
|
|
end
|
|
C_Timer.After(2, function() Codex:OnPoolEvent("PLAYER_ENTERING_WORLD") end)
|
|
state.pl = Num(UnitLevel and UnitLevel("player"))
|
|
FW:RegisterEvent("PLAYER_TARGET_CHANGED", self.OnTargetChanged, self)
|
|
FW:RegisterEvent("UPDATE_MOUSEOVER_UNIT", self.OnMouseover, self)
|
|
|
|
if not self.hooked and hooksecurefunc and type(_G.GetQuestReward) == "function" then
|
|
hooksecurefunc("GetQuestReward", function(index)
|
|
Codex:OnChoice(index)
|
|
end)
|
|
self.hooked = true
|
|
end
|
|
if not self.useHooked and hooksecurefunc then
|
|
local ci = _G.C_Container
|
|
if ci and type(ci.UseContainerItem) == "function" then
|
|
hooksecurefunc(ci, "UseContainerItem", function(bag, slot) Codex.NoteItemUse(bag, slot) end)
|
|
self.useHooked = true
|
|
elseif type(_G.UseContainerItem) == "function" then
|
|
hooksecurefunc("UseContainerItem", function(bag, slot) Codex.NoteItemUse(bag, slot) end)
|
|
self.useHooked = true
|
|
end
|
|
end
|
|
if not self.tipHooked and TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall
|
|
and Enum and Enum.TooltipDataType and Enum.TooltipDataType.Object then
|
|
local ok = pcall(TooltipDataProcessor.AddTooltipPostCall, Enum.TooltipDataType.Object, function(_, data)
|
|
Codex:OnObjectTooltip(data)
|
|
end)
|
|
self.tipHooked = ok
|
|
end
|
|
C_Timer.After(3, function()
|
|
Codex:ScanLog()
|
|
end)
|
|
C_Timer.After(6, function() Safe(Codex.FillGaps) end)
|
|
C_Timer.After(40, function() Safe(Codex.UpgradeEffects) end)
|
|
C_Timer.After(10, function() Safe(Codex.PurgeMissing) end)
|
|
C_Timer.After(90, function()
|
|
local ls = Codex.db and Codex.db.itemload
|
|
if ls and ls.on and Codex.AllItemIds then Safe(function() Codex.ItemLoadStart(Codex.AllItemIds(), true) end) end
|
|
end)
|
|
C_Timer.After(4, function()
|
|
if Codex.Forever then Codex.Forever.Load() end
|
|
end)
|
|
if self.Avail then
|
|
self.Avail:Start()
|
|
end
|
|
if self.Names then
|
|
self.Names:Start()
|
|
end
|
|
if self.Time then
|
|
self.Time:Start()
|
|
end
|
|
if self.Roles then
|
|
self.Roles:Start()
|
|
end
|
|
if self.Recipes then
|
|
self.Recipes:Start()
|
|
end
|
|
if self.Sync then
|
|
self.Sync:Init()
|
|
end
|
|
if self.Lookup then
|
|
self.Lookup:Start()
|
|
end
|
|
end
|
|
|
|
function Codex:OnDisable()
|
|
for _, event in ipairs({ "QUEST_DETAIL", "QUEST_ACCEPTED", "QUEST_PROGRESS", "QUEST_COMPLETE", "QUEST_TURNED_IN", "QUEST_REMOVED",
|
|
"QUEST_LOG_UPDATE", "UNIT_QUEST_LOG_CHANGED", "LOOT_OPENED", "CHAT_MSG_COMBAT_FACTION_CHANGE", "CHAT_MSG_COMBAT_XP_GAIN", "PLAYER_LEVEL_UP",
|
|
"PLAYER_TARGET_CHANGED", "UPDATE_MOUSEOVER_UNIT",
|
|
"PLAYER_REGEN_DISABLED", "PLAYER_ENTERING_WORLD", "UPDATE_EXHAUSTION" }) do
|
|
FW:UnregisterEvent(event, self)
|
|
end
|
|
if self.Names then
|
|
self.Names:Stop()
|
|
end
|
|
if self.Time then
|
|
self.Time:Stop()
|
|
end
|
|
if self.Roles then
|
|
self.Roles:Stop()
|
|
end
|
|
if self.Recipes then
|
|
self.Recipes:Stop()
|
|
end
|
|
if self.Sync then
|
|
self.Sync:Stop()
|
|
end
|
|
if self.Lookup then
|
|
self.Lookup:Stop()
|
|
end
|
|
end
|
|
|
|
-- Test im Spiel: /fwcodex test
|
|
function Codex:RunTest()
|
|
local Lc = FW.L
|
|
local function yn(v)
|
|
return v and Lc.CODEX_YES or Lc.CODEX_NO
|
|
end
|
|
FW:Print(string.format(Lc.CODEX_TEST_API,
|
|
yn(GetLootSourceInfo), yn(C_QuestLog and C_QuestLog.GetQuestObjectives), yn(GetQuestReward), yn(self.hooked)))
|
|
local q, u, o, n = self:Counts()
|
|
FW:Print(string.format(Lc.CODEX_TEST_COUNTS, q, u, o, n))
|
|
FW:Print(string.format(Lc.CODEX_TEST_STATS, state.stats.loot, state.stats.errors))
|
|
local rows = self:XPRows(true)
|
|
FW:Print(string.format(Lc.CODEX_TEST_XP, #rows, #self.db.xpUnparsed))
|
|
for _, text in ipairs(self.db.xpUnparsed) do
|
|
FW:Print(" ? " .. text)
|
|
end
|
|
if self.Share then
|
|
local own = self.Share.Summary()
|
|
local senders, records = self.Share.RemoteStats()
|
|
FW:Print(string.format(Lc.CODEX_TEST_SHARE, self.db.settings.share and Lc.CODEX_ON or Lc.CODEX_OFF, own or 0, senders, records))
|
|
end
|
|
local empty = self.db.objEmpty or {}
|
|
FW:Print(string.format(Lc.CODEX_TEST_OBJ, #empty))
|
|
for _, text in ipairs(empty) do
|
|
FW:Print(" ? " .. text)
|
|
end
|
|
end
|