Files
2026-10-27 14:14:36 +00:00

1340 lines
41 KiB
Lua

-- Freewood-OctoWoW-Beasts: Archiv wilder Tiere
-- Lernt beim Spielen (anvisieren, Wildtierkunde, Zaehmen), kennt optional Startdaten
-- aus Freewood-OctoWoW-BeastsData und teilt eigene Funde per Export/Import.
--
-- Eigene Eintraege (db.beasts[npcID]):
-- name, fid (Familien-ID), family (Text aus dem Spiel), lvl1, lvl2, c (Klassifizierung),
-- zone, tameable, diet (Kuerzel mfcbur), seen, lored, tamed, tamedAt, firstSeen, updated,
-- grid = { [Karte] = { [Zellen-Nr] = Anzahl } } (Raster 200 x 200, Zelle = 0,5 % der Karte),
-- petAbilities = { {name, rank, spellID, passive} },
-- extra = { "Zeile aus Wildtierkunde" }
-- Importierte Eintraege liegen getrennt in db.imported[npcID] und ueberschreiben nie eigene Daten.
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 Pet = FW.Pet
if not Pet then
return
end
local GRID = 200 -- Raster: 200 x 200 Zellen pro Karte (Zelle = 0,5 %)
local MAX_CELLS = 800 -- hoechstens so viele verschiedene Zellen pro Tier
local MAX_EXTRA = 8
local MAX_PETABIL = 60
local MAX_IMPORT = 500
local STAY_INTERVAL = 10 -- Sekunden: in derselben Zelle erst danach erneut zaehlen
local LORE_INTERVAL = 4 -- Sekunden zwischen zwei Tooltip-Auslesungen pro Tier
local DIET_ORDER = "mfcbur"
local FOOD_ALIASES = {
m = { "fleisch", "meat" },
f = { "fisch", "fish" },
c = { "käse", "kaese", "cheese" },
b = { "brot", "bread" },
u = { "fungus", "pilz", "pilze", "mushroom" },
r = { "obst", "frucht", "früchte", "fruit" },
}
local FOOD_BY_NAME = {}
for code, names in pairs(FOOD_ALIASES) do
for _, n in ipairs(names) do
FOOD_BY_NAME[n] = code
end
end
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 Beasts = FW:NewModule("Beasts", {
version = GetMeta("Version") or "dev",
title = L.BEAST_TITLE,
description = L.BEAST_DESC,
defaults = {
beasts = {}, imported = {}, relay = {}, familyNames = {},
settings = { seed = true, mapPower = 1.0, share = { guild = false, party = false, raid = false, global = false }, people = {} },
sync = { have = {}, sent = 0, received = 0 },
},
})
NS.Beasts = Beasts
FW.Beasts = Beasts
local function Now()
return (GetTime and GetTime()) or time()
end
local function Num(v, lo, hi)
if type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge then
if lo and v < lo then
return nil
end
if hi and v > hi then
return nil
end
return v
end
return nil
end
local function Str(v, max)
if type(v) ~= "string" then
return nil
end
v = v:gsub("[%c]", " ")
if v == "" then
return nil
end
return v:sub(1, max or 64)
end
------------------------------------------------------------------------
-- Nahrung
------------------------------------------------------------------------
-- Liste von Texten ("Fleisch", "Fisch") in Kuerzel ("mf"), Reihenfolge fest
function Beasts.FoodsToCodes(list)
local have = {}
for _, text in ipairs(list or {}) do
if type(text) == "string" then
local code = FOOD_BY_NAME[string.lower((text:gsub("^%s+", ""):gsub("%s+$", "")))]
if code then
have[code] = true
end
end
end
local out = {}
for i = 1, #DIET_ORDER do
local c = DIET_ORDER:sub(i, i)
if have[c] then
out[#out + 1] = c
end
end
return table.concat(out)
end
local function CleanDiet(v)
if type(v) ~= "string" then
return nil
end
local out = {}
for i = 1, #DIET_ORDER do
local c = DIET_ORDER:sub(i, i)
if v:find(c, 1, true) then
out[#out + 1] = c
end
end
if #out == 0 then
return nil
end
return table.concat(out)
end
------------------------------------------------------------------------
-- Startdaten
------------------------------------------------------------------------
function Beasts:SeedNpcs()
local seed = _G.FreewoodBeastsSeed
return seed and seed.npcs or nil
end
function Beasts:HasSeed()
return self:SeedNpcs() ~= nil
end
function Beasts:SeedEnabled()
return self.db.settings.seed ~= false and self:HasSeed()
end
function Beasts:SeedEntry(id)
if not self:SeedEnabled() then
return nil
end
return self:SeedNpcs()[id]
end
function Beasts:SetSeedEnabled(on)
self.db.settings.seed = on and true or false
self.index = nil
FW:Fire("BEASTS_UPDATED")
end
------------------------------------------------------------------------
-- Karten- und Gebietsnamen
------------------------------------------------------------------------
local mapNames, areaNames = {}, {}
function Beasts.MapName(mapID)
if not mapID then
return nil
end
local cached = mapNames[mapID]
if cached ~= nil then
return cached or nil
end
local name
if C_Map and C_Map.GetMapInfo then
local ok, info = pcall(C_Map.GetMapInfo, mapID)
if ok and info then
name = info.name
end
end
mapNames[mapID] = name or false
return name
end
function Beasts.AreaName(areaID)
if not areaID then
return nil
end
local cached = areaNames[areaID]
if cached ~= nil then
return cached or nil
end
local name
if C_Map and C_Map.GetAreaInfo then
local ok, value = pcall(C_Map.GetAreaInfo, areaID)
if ok then
name = value
end
end
areaNames[areaID] = name or false
return name
end
------------------------------------------------------------------------
-- Eigene Eintraege
------------------------------------------------------------------------
Beasts.GRID = GRID
local function CellIndex(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 cy * GRID + cx
end
local function GridCount(grid)
local n = 0
if type(grid) == "table" then
for _, cells in pairs(grid) do
for _ in pairs(cells) do
n = n + 1
end
end
end
return n
end
-- Eine Sichtung in die Zelle eintragen. Rueckgabe: true wenn die Zelle neu ist.
local function AddSighting(e, m, idx, amount)
e.grid = e.grid or {}
local cells = e.grid[m]
if not cells then
cells = {}
e.grid[m] = cells
end
local n = cells[idx]
if n then
cells[idx] = math.min(65535, n + (amount or 1))
return false
end
if GridCount(e.grid) >= MAX_CELLS then
return false
end
cells[idx] = math.min(65535, amount or 1)
return true
end
-- Raster als Liste { {m, x, y, n} } (x,y = Zellen 0..199), haeufigste zuerst
local function FlattenGrid(grid)
local list = {}
for m, cells in pairs(grid or {}) do
for idx, n in pairs(cells) do
list[#list + 1] = { m = m, x = idx % GRID, y = math.floor(idx / GRID), n = n }
end
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)
while #list > MAX_CELLS do
list[#list] = nil
end
return list
end
function Beasts:Touch(id)
local e = self.db.beasts[id]
if not e then
e = { firstSeen = time(), updated = time() }
self.db.beasts[id] = e
self.isNew = true
end
return e
end
-- Kurze Pruefsumme der wichtigen Felder: nur bei Aenderung wird neu gezeichnet und "updated" gesetzt
local function Signature(e)
local abil = 0
for _, a in ipairs(e.petAbilities or {}) do
abil = abil + (a.rank or 0) + 1
end
return table.concat({
tostring(e.name), tostring(e.fid), tostring(e.family), tostring(e.lvl1), tostring(e.lvl2), tostring(e.c),
tostring(e.zone), tostring(e.seen), tostring(e.lored), tostring(e.tameable), tostring(e.tamed),
tostring(e.diet), GridCount(e.grid), e.extra and #e.extra or 0, abil,
}, "|")
end
function Beasts:Commit(id, e, before)
if Signature(e) ~= before then
e.updated = time()
self.index = nil
FW:Fire("BEASTS_UPDATED", id)
return true
end
return false
end
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, loc.zone
end
return nil, nil, nil, loc and loc.zone
end
local function CleanLine(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
-- Liest den Tooltip einer Einheit. Rueckgabe: nil oder
-- { tameable=bool, diet="mf"|nil, extra={...} } (nur wenn Wildtierkunde-Zeilen da sind)
function Beasts:ReadLore(unit, name)
if not (C_TooltipInfo and C_TooltipInfo.GetUnit) then
return nil
end
local ok, data = pcall(C_TooltipInfo.GetUnit, unit)
if not ok or type(data) ~= "table" or type(data.lines) ~= "table" then
return nil
end
local info = { extra = {} }
local found = false
local level = UnitLevel and Util.Clean(UnitLevel(unit))
if type(level) ~= "number" then
level = nil
end
local typeLow = UnitCreatureType and Util.Clean(UnitCreatureType(unit))
typeLow = type(typeLow) == "string" and string.lower(typeLow) or nil
for i, line in ipairs(data.lines) do
local text = type(line) == "table" and Util.Clean(line.leftText)
if type(text) == "string" and i > 1 then
text = CleanLine(text)
local low = string.lower(text)
if text == "" or text == name then
-- nichts
elseif low == "wildtier" or low == "beast" or (typeLow and low == typeLow) then
-- Typzeile
elseif low == "zähmbar" or low == "zaehmbar" or low == "tameable" then
info.tameable = true
found = true
elseif low:find("^ernährung:") or low:find("^ernaehrung:") or low:find("^diet:") then
local rest = text:match(":%s*(.+)$") or ""
local foods = {}
for part in rest:gmatch("[^,]+") do
foods[#foods + 1] = part
end
local codes = Beasts.FoodsToCodes(foods)
if codes ~= "" then
info.diet = codes
end
found = true
elseif low:find("^spezialisierung:") or low:find("^specialization:") then
found = true
elseif low:find("^stufe") or low:find("^level") or (level and level > 0 and low:find(tostring(level), 1, true) and low:find("%d")) then
-- Stufenzeile
elseif #info.extra < MAX_EXTRA then
info.extra[#info.extra + 1] = text:sub(1, 120)
end
end
end
if not found then
return nil
end
return info
end
local function AddExtra(e, lines)
if not lines or #lines == 0 then
return
end
e.extra = e.extra or {}
for _, line in ipairs(lines) do
local dup = false
for _, have in ipairs(e.extra) do
if have == line then
dup = true
break
end
end
if not dup and #e.extra < MAX_EXTRA then
e.extra[#e.extra + 1] = line
end
end
end
-- Einheit beobachten und ins Archiv uebernehmen
-- Rueckgabe: true, wenn die Einheit ein Wildtier fuers Archiv ist
-- Geheime Werte (secret values) fuehren zum Abbruch ohne Fehlermeldung.
function Beasts:Observe(unit)
local ok, result = pcall(self.ObserveImpl, self, unit)
if ok then
return result and true or false
end
return false
end
function Beasts:ObserveImpl(unit)
if Util.IsSecret(unit) or not UnitExists(unit) then
return false
end
local isPlayer, controlled = UnitIsPlayer(unit), UnitPlayerControlled(unit)
if Util.IsSecret(isPlayer) or Util.IsSecret(controlled) or isPlayer or controlled then
return false
end
local guid = Util.Clean(UnitGUID(unit))
if type(guid) ~= "string" then
return false
end
-- Octo/1.12: GUID ist ein Hex-String (0xF130...), nicht "Creature-..."
local hexKind = Util.ParseHexGUID(guid)
if hexKind then
if hexKind ~= "Creature" then
return false
end
elseif not guid:find("^Creature") then
return false
end
local id = Util.GetNpcID(guid)
if not id then
return false
end
local family = UnitCreatureFamily and Util.Clean(UnitCreatureFamily(unit))
if family == "" then
family = nil
end
local known = self.db.beasts[id] ~= nil
local inSeed = self:SeedNpcs() and self:SeedNpcs()[id] ~= nil
local name = Util.Clean(UnitName(unit))
local lore
local now = Now()
local last = self.loreAt[id]
if (family or known or inSeed) and (not last or now - last >= LORE_INTERVAL) then
self.loreAt[id] = now
lore = self:ReadLore(unit, name)
end
if not (family or inSeed or known or lore) then
return false
end
local e = self:Touch(id)
local wasNew = self.isNew
self.isNew = nil
local before = wasNew and "" or Signature(e)
e.name = Str(name, 64) or e.name
e.seen = true
e.lastSeen = time()
if family then
e.family = Str(family, 48)
local fid = Pet.GetFamilyID(family)
if fid then
e.fid = fid
self.db.familyNames[fid] = e.family
end
end
local lvl = UnitLevel and Util.Clean(UnitLevel(unit))
if type(lvl) == "number" and lvl > 0 then
e.lvl1 = (e.lvl1 and math.min(e.lvl1, lvl)) or lvl
e.lvl2 = (e.lvl2 and math.max(e.lvl2, lvl)) or lvl
end
local class = UnitClassification and Util.Clean(UnitClassification(unit))
if type(class) == "string" and class ~= "normal" and class ~= "trivial" and class ~= "minus" then
e.c = class
end
local m, x, y, zone = PlayerPoint()
if zone and zone ~= "" then
e.zone = Str(zone, 64)
end
if m and x and y then
local idx = CellIndex(x, y)
local key = m * GRID * GRID + idx
local lastAt = self.trackAt[id]
if self.lastCell[id] ~= key or not lastAt or now - lastAt >= STAY_INTERVAL then
self.lastCell[id] = key
self.trackAt[id] = now
AddSighting(e, m, idx)
end
end
if lore then
e.lored = true
if lore.tameable then
e.tameable = true
end
if lore.diet then
e.diet = lore.diet
end
AddExtra(e, lore.extra)
end
if self:Commit(id, e, before) and wasNew then
FW:Debug(string.format(FW.L.BEAST_LEARNED, e.name or tostring(id)))
end
return true
end
-- Solange ein Wildtier im Ziel ist, jede Sekunde die Position festhalten
function Beasts:StartTracking()
if self.tracking then
return
end
self.tracking = true
local function tick()
if not Beasts:IsEnabled() or not UnitExists("target") or not Beasts:Observe("target") then
Beasts.tracking = false
return
end
C_Timer.After(1, tick)
end
C_Timer.After(1, tick)
end
-- Begleiter wurde erfasst: das Tier gilt als gezaehmt, Zauberbuch wird mitgenommen
function Beasts:OnPetUpdated(key, rec)
if type(rec) ~= "table" or rec.src == "import" or not rec.npcID or key ~= Pet.activeKey then
return
end
local e = self:Touch(rec.npcID)
local before = self.isNew and "" or Signature(e)
self.isNew = nil
e.tamed = true
e.tameable = true
e.tamedAt = e.tamedAt or time()
if type(rec.family) == "string" and rec.family ~= "" then
e.family = Str(rec.family, 48)
local fid = Pet.GetFamilyID(rec.family)
if fid then
e.fid = fid
self.db.familyNames[fid] = e.family
end
end
if type(rec.foods) == "table" and #rec.foods > 0 then
local codes = Beasts.FoodsToCodes(rec.foods)
if codes ~= "" then
e.diet = codes
end
end
local list = {}
for _, a in ipairs(rec.abilities or {}) do
if #list >= MAX_PETABIL then
break
end
if type(a) == "table" and type(a.name) == "string" then
list[#list + 1] = { name = a.name, rank = a.rank, spellID = a.spellID, passive = a.passive and true or nil }
end
end
if #list > 0 then
e.petAbilities = list
end
self:Commit(rec.npcID, e, before)
end
------------------------------------------------------------------------
-- Zusammengefuehrte Sicht (eigen, Import, Startdaten)
------------------------------------------------------------------------
function Beasts.StatusOf(own, imp)
local tamed = (own and own.tamed) or (imp and imp.tamed)
local lored = (own and own.lored) or (imp and imp.lored)
if tamed then
return "tamed"
elseif lored then
return "lored"
elseif own or imp then
return "seen"
end
return "unknown"
end
local function SeedZone(seed)
if not seed then
return nil
end
return Beasts.MapName(seed.m) or Beasts.AreaName(seed.a)
end
function Beasts:FamilyText(fid, fallback)
if not fid then
return fallback
end
return self.db.familyNames[fid] or Pet.FamilyDisplay(fid)
end
function Beasts:Summary(id)
local own, imp, seed = self.db.beasts[id], self.db.imported[id], self:SeedEntry(id)
local fid = (own and own.fid) or (imp and imp.fid) or (seed and seed.f ~= "" and seed.f) or nil
local familyText = (own and own.family) or (imp and imp.family)
local s = {
id = id,
own = own, imp = imp, seed = seed,
name = (own and own.name) or (imp and imp.name) or (seed and seed.n) or string.format(FW.L.BEAST_UNNAMED, id),
fid = fid,
status = Beasts.StatusOf(own, imp),
zone = (own and own.zone) or (imp and imp.zone) or SeedZone(seed),
lvl1 = (own and own.lvl1) or (imp and imp.lvl1) or (seed and seed.l and seed.l[1]),
lvl2 = (own and own.lvl2) or (imp and imp.lvl2) or (seed and seed.l and seed.l[2]),
c = (own and own.c) or (imp and imp.c) or (seed and seed.c),
diet = (own and own.diet) or (imp and imp.diet) or (seed and seed.d ~= "" and seed.d) or nil,
tameable = (own and own.tameable) or (imp and imp.tameable) or (seed and true) or nil,
}
s.family = (own and own.family) or self:FamilyText(fid, familyText)
s.sortName = string.lower(s.name)
return s
end
-- Alle Eintraege als Liste (ohne Filter), nach Namen sortiert
function Beasts:Index()
if self.index then
return self.index
end
local ids = {}
for id in pairs(self.db.beasts) do
ids[id] = true
end
for id in pairs(self.db.imported) do
ids[id] = true
end
local seed = self:SeedEnabled() and self:SeedNpcs()
if seed then
for id in pairs(seed) do
ids[id] = true
end
end
local list = {}
for id in pairs(ids) do
list[#list + 1] = self:Summary(id)
end
table.sort(list, function(a, b)
if a.sortName ~= b.sortName then
return a.sortName < b.sortName
end
return a.id < b.id
end)
self.index = list
return list
end
-- Filter: { text, fid, zone, status }
-- Name und Rang (Zahl) eines Zaubers aus den Spieldaten; nil, wenn noch nicht bekannt
function Beasts.SpellInfo(spellID)
local name, rank
if C_Spell and C_Spell.GetSpellInfo then
local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
if ok and type(info) == "table" then
name = info.name
end
end
if C_Spell and C_Spell.GetSpellSubtext then
local ok, text = pcall(C_Spell.GetSpellSubtext, spellID)
if ok and type(text) == "string" then
rank = tonumber(string.match(text, "(%d+)"))
end
end
return name, rank or 0
end
-- Faehigkeiten, die ein Tier beim Zaehmen lehrt (Startdaten) bzw. die sein Begleiter hat:
-- { [name] = { [rang] = true } } plus Hoechstrang; im Eintrag zwischengespeichert, sobald alle Namen bekannt sind
function Beasts:Abilities(s)
if s.abil then
return s.abil
end
local map, complete = {}, true
local function add(name, rank)
local e = map[name]
if not e then
e = { ranks = {}, max = 0 }
map[name] = e
end
e.ranks[rank] = true
if rank > e.max then
e.max = rank
end
end
if s.seed and s.seed.t then
for _, spellID in ipairs(s.seed.t) do
local name, rank = Beasts.SpellInfo(spellID)
if name then
add(name, rank)
else
complete = false
end
end
end
local pa = (s.own and s.own.petAbilities) or (s.imp and s.imp.petAbilities)
for _, a in ipairs(pa or {}) do
if a.name then
add(a.name, tonumber(a.rank) or 0)
end
end
if complete then
s.abil = map
end
return map
end
-- filter: text, fid, zone, status, ability (Name), rank (Zahl), rankMin (true = "ab diesem Rang")
-- Zweiter Rueckgabewert: Zahl der Tiere ohne Faehigkeitsdaten (nur bei Faehigkeitsfilter)
function Beasts:Filtered(filter)
local out = {}
local missing = 0
local text = filter.text and filter.text ~= "" and string.lower(filter.text) or nil
for _, s in ipairs(self:Index()) do
local ok = true
if filter.fid and s.fid ~= filter.fid then
ok = false
elseif filter.zone and s.zone ~= filter.zone then
ok = false
elseif filter.status and s.status ~= filter.status then
ok = false
elseif text then
local hay = s.sortName .. " " .. string.lower(s.family or "") .. " " .. string.lower(s.zone or "")
if not hay:find(text, 1, true) then
ok = false
end
end
if ok and filter.ability then
local abil = self:Abilities(s)
if not next(abil) then
missing = missing + 1
ok = false
else
local e = abil[filter.ability]
if not e then
ok = false
elseif filter.rank then
if filter.rankMin then
ok = e.max >= filter.rank
else
ok = e.ranks[filter.rank] == true
end
end
end
end
if ok then
out[#out + 1] = s
end
end
return out, missing
end
-- Alle Faehigkeiten (Namen, sortiert) und die Raenge einer Faehigkeit
function Beasts:AbilityChoices()
local names, seen = {}, {}
for _, s in ipairs(self:Index()) do
for name in pairs(self:Abilities(s)) do
if not seen[name] then
seen[name] = true
names[#names + 1] = name
end
end
end
table.sort(names)
return names
end
function Beasts:AbilityRanks(name)
local ranks, seen = {}, {}
for _, s in ipairs(self:Index()) do
local e = self:Abilities(s)[name]
if e then
for r in pairs(e.ranks) do
if r > 0 and not seen[r] then
seen[r] = true
ranks[#ranks + 1] = r
end
end
end
end
table.sort(ranks)
return ranks
end
-- Familien und Gebiete, die im Index vorkommen (fuer die Auswahllisten)
function Beasts:FilterChoices()
local fams, zones, famSeen, zoneSeen = {}, {}, {}, {}
for _, s in ipairs(self:Index()) do
if s.fid and not famSeen[s.fid] then
famSeen[s.fid] = true
fams[#fams + 1] = { id = s.fid, text = self:FamilyText(s.fid) }
end
if s.zone and not zoneSeen[s.zone] then
zoneSeen[s.zone] = true
zones[#zones + 1] = s.zone
end
end
table.sort(fams, function(a, b)
return a.text < b.text
end)
table.sort(zones)
return fams, zones
end
-- Fundorte gruppiert nach Karte: { {mapID, name, own={ {x,y,n} }, imp={...}, seed={ {x,y} }} }
-- x,y in Zehntel-Prozent (0..1000)
function Beasts:Locations(s)
local groups, order = {}, {}
local function group(m)
local g = groups[m]
if not g then
g = { mapID = m, name = Beasts.MapName(m) or ("Map " .. tostring(m)), own = {}, imp = {}, seed = {} }
groups[m] = g
order[#order + 1] = g
end
return g
end
local function addGrid(entry, key)
for m, cells in pairs(entry and entry.grid or {}) do
local g = group(m)
for idx, n in pairs(cells) do
g[key][#g[key] + 1] = { (idx % GRID) * 5 + 2.5, math.floor(idx / GRID) * 5 + 2.5, n }
end
table.sort(g[key], function(a, b)
if a[3] ~= b[3] then
return a[3] > b[3]
end
if a[2] ~= b[2] then
return a[2] < b[2]
end
return a[1] < b[1]
end)
end
end
addGrid(s.own, "own")
addGrid(s.imp, "imp")
if s.seed and s.seed.m and s.seed.p then
local g = group(s.seed.m)
for i = 1, #s.seed.p - 1, 2 do
g.seed[#g.seed + 1] = { s.seed.p[i], s.seed.p[i + 1] }
end
end
table.sort(order, function(a, b)
local ca = #a.own + #a.imp + #a.seed
local cb = #b.own + #b.imp + #b.seed
if ca ~= cb then
return ca > cb
end
return a.name < b.name
end)
return order
end
-- Gewichte pro Rasterzelle fuer eine Karte, ueber mehrere Tiere (ids = { [npcID] = true })
-- Rueckgabe: { [Zellen-Nr] = Gewicht }
function Beasts:MapWeights(ids, mapID)
local weights = {}
local function add(idx, w)
weights[idx] = (weights[idx] or 0) + w
end
for id in pairs(ids) do
local own, imp, seed = self.db.beasts[id], self.db.imported[id], self:SeedEntry(id)
local oc = own and own.grid and own.grid[mapID]
if oc then
for idx, n in pairs(oc) do
add(idx, 2 + math.min(n, 8) * 0.25)
end
end
local ic = imp and imp.grid and imp.grid[mapID]
if ic then
for idx, n in pairs(ic) do
add(idx, 1.5 + math.min(n, 8) * 0.15)
end
end
if seed and seed.m == mapID and seed.p then
for i = 1, #seed.p - 1, 2 do
local cx = math.min(GRID - 1, math.floor(seed.p[i] / 1000 * GRID))
local cy = math.min(GRID - 1, math.floor(seed.p[i + 1] / 1000 * GRID))
add(cy * GRID + cx, 1)
end
end
end
return weights
end
-- Punkte fuer die Codex-Karte (wie "Auf Karte zeigen" in der Datenbank):
-- { {m, x, y, n, name, k}, ... } mit x, y als Anteil 0..1; ids = { [npcID] = true }
local MAP_POINTS_MAX = 3000
function Beasts:MapPoints(ids)
local pts = {}
for id in pairs(ids) do
local sum = self:Summary(id)
local name = (sum and sum.name) or ("#" .. tostring(id))
local function addGrid(entry)
for m, cells in pairs(entry and entry.grid or {}) do
for idx, n in pairs(cells) do
pts[#pts + 1] = { m = m, x = ((idx % GRID) + 0.5) / GRID,
y = (math.floor(idx / GRID) + 0.5) / GRID, n = n, name = name, k = "u" }
end
end
end
addGrid(self.db.beasts[id])
addGrid(self.db.imported[id])
local seed = self:SeedEntry(id)
if seed and seed.m and seed.p then
for i = 1, #seed.p - 1, 2 do
pts[#pts + 1] = { m = seed.m, x = seed.p[i] / 1000, y = seed.p[i + 1] / 1000, n = 1, name = name, k = "u" }
end
end
end
if #pts > MAP_POINTS_MAX then
table.sort(pts, function(a, b) return a.n > b.n end)
for i = #pts, MAP_POINTS_MAX + 1, -1 do
pts[i] = nil
end
end
return pts
end
-- Tiere mit Funden in der Naehe einer Kartenzelle: { {id, own, imp, seed}, ... }, meiste Funde zuerst
function Beasts:BeastsAt(ids, mapID, cx, cy, radius)
local out = {}
local function near(idx)
local x = idx % GRID
local y = math.floor(idx / GRID)
return math.abs(x - cx) <= radius and math.abs(y - cy) <= radius
end
for id in pairs(ids) do
local own, imp, seed = self.db.beasts[id], self.db.imported[id], self:SeedEntry(id)
local h = { id = id, own = 0, imp = 0, seed = 0 }
local oc = own and own.grid and own.grid[mapID]
if oc then
for idx, n in pairs(oc) do
if near(idx) then h.own = h.own + n end
end
end
local ic = imp and imp.grid and imp.grid[mapID]
if ic then
for idx, n in pairs(ic) do
if near(idx) then h.imp = h.imp + n end
end
end
if seed and seed.m == mapID and seed.p then
for i = 1, #seed.p - 1, 2 do
local x = math.min(GRID - 1, math.floor(seed.p[i] / 1000 * GRID))
local y = math.min(GRID - 1, math.floor(seed.p[i + 1] / 1000 * GRID))
if math.abs(x - cx) <= radius and math.abs(y - cy) <= radius then h.seed = h.seed + 1 end
end
end
if h.own + h.imp + h.seed > 0 then
h.total = h.own * 3 + h.imp * 2 + h.seed
out[#out + 1] = h
end
end
table.sort(out, function(a, b)
if a.total ~= b.total then return a.total > b.total end
return a.id < b.id
end)
return out
end
------------------------------------------------------------------------
-- Ereignisse
------------------------------------------------------------------------
function Beasts:OnTargetChanged()
if self:Observe("target") then
self:StartTracking()
end
end
function Beasts:OnMouseover()
self:Observe("mouseover")
end
function Beasts:OnTargetAura(unit)
if unit ~= "target" then
return
end
-- Wildtierkunde zeigt sich als Aura: kurz warten, dann Tooltip erneut lesen
if self.auraTimer then
return
end
self.auraTimer = true
C_Timer.After(0.4, function()
Beasts.auraTimer = nil
local guid = Util.Clean(UnitGUID("target"))
local id = type(guid) == "string" and Util.GetNpcID(guid) or nil
if id then
Beasts.loreAt[id] = nil
end
Beasts:Observe("target")
end)
end
-- Alte Eintraege (Punktlisten) ins Raster uebertragen, Typzeile aus den Hinweisen entfernen
local function MigrateEntry(e)
if type(e) ~= "table" then
return
end
if type(e.locs) == "table" then
for _, p in ipairs(e.locs) do
if type(p) == "table" and type(p.m) == "number" and type(p.x) == "number" and type(p.y) == "number" then
AddSighting(e, p.m, CellIndex(p.x / 1000, p.y / 1000))
end
end
e.locs = nil
end
if type(e.extra) == "table" then
local keep = {}
for _, line in ipairs(e.extra) do
local low = type(line) == "string" and string.lower(line) or ""
if low ~= "wildtier" and low ~= "beast" and low ~= "" then
keep[#keep + 1] = line
end
end
e.extra = #keep > 0 and keep or nil
end
end
-- Alte Eintraege umwandeln. Laeuft bei OnEnable, denn OnInitialize wuerde bei schon
-- bereitem Core noch vor der Definition dieser Funktionen aufgerufen.
function Beasts:Migrate()
for _, e in pairs(self.db.beasts) do
MigrateEntry(e)
end
for _, e in pairs(self.db.imported) do
MigrateEntry(e)
end
self.index = nil
end
function Beasts:OnEnable()
self.loreAt = self.loreAt or {}
self.trackAt = self.trackAt or {}
self.lastCell = self.lastCell or {}
self:Migrate()
FW:RegisterEvent("PLAYER_TARGET_CHANGED", self.OnTargetChanged, self)
FW:RegisterEvent("UPDATE_MOUSEOVER_UNIT", self.OnMouseover, self)
FW:RegisterEvent("UNIT_AURA", self.OnTargetAura, self)
FW:On("PET_UPDATED", self.OnPetUpdated, self)
if self.Sync then
self.Sync:Start()
end
end
function Beasts:OnDisable()
if self.Sync then
self.Sync:Stop()
end
FW:UnregisterEvent("PLAYER_TARGET_CHANGED", self)
FW:UnregisterEvent("UPDATE_MOUSEOVER_UNIT", self)
FW:UnregisterEvent("UNIT_AURA", self)
FW:Off("PET_UPDATED", self)
end
------------------------------------------------------------------------
-- Export / Import
-- Geteilt werden nur eigene Funde, nichts Persoenliches.
------------------------------------------------------------------------
local function SanitizeList(src, max, fn)
local list = {}
if type(src) ~= "table" then
return list
end
for _, v in ipairs(src) do
if #list >= max then
break
end
local clean = fn(v)
if clean ~= nil then
list[#list + 1] = clean
end
end
return list
end
local function Sanitize(src)
if type(src) ~= "table" then
return nil
end
local id = Num(src.id, 1, 10000000)
if not id then
return nil
end
local r = {
id = math.floor(id),
name = Str(src.name, 64),
fid = Str(src.fid, 24),
family = Str(src.family, 48),
lvl1 = Num(src.lvl1, 1, 120),
lvl2 = Num(src.lvl2, 1, 120),
c = Str(src.c, 16),
zone = Str(src.zone, 64),
tameable = (src.tameable == true) or nil,
diet = CleanDiet(src.diet),
seen = (src.seen == true) or nil,
lored = (src.lored == true) or nil,
tamed = (src.tamed == true) or nil,
updated = Num(src.updated, 1, 4102444800) or time(),
}
-- Raster: flache Liste cells { {m,x,y,n} } (neu) oder Punkte locs (alt, Zehntel-Prozent)
local grid, total = {}, 0
local function put(m, cx, cy, n)
if total >= MAX_CELLS then
return
end
local cells = grid[m]
if not cells then
cells = {}
grid[m] = cells
end
local idx = cy * GRID + cx
if not cells[idx] then
total = total + 1
end
cells[idx] = math.max(cells[idx] or 0, n)
end
if type(src.cells) == "table" then
for _, p in ipairs(src.cells) do
if type(p) == "table" then
local m, x, y, n = Num(p.m, 1, 100000), Num(p.x, 0, GRID - 1), Num(p.y, 0, GRID - 1), Num(p.n, 1, 65535)
if m and x and y and n then
put(m, math.floor(x), math.floor(y), math.floor(n))
end
end
end
end
if type(src.locs) == "table" then
for _, p in ipairs(src.locs) do
if type(p) == "table" then
local m, x, y = Num(p.m, 1, 100000), Num(p.x, 0, 1000), Num(p.y, 0, 1000)
if m and x and y then
put(m, math.min(GRID - 1, math.floor(x / 1000 * GRID)), math.min(GRID - 1, math.floor(y / 1000 * GRID)), 1)
end
end
end
end
r.grid = total > 0 and grid or nil
r.petAbilities = SanitizeList(src.petAbilities, MAX_PETABIL, function(a)
if type(a) ~= "table" then
return nil
end
local n = Str(a.name, 64)
if not n then
return nil
end
return { name = n, rank = Num(a.rank, 0, 20), spellID = Num(a.spellID, 1, 10000000), passive = (a.passive == true) or nil }
end)
r.extra = SanitizeList(src.extra, MAX_EXTRA, function(v)
return Str(v, 120)
end)
if #r.petAbilities == 0 then r.petAbilities = nil end
if #r.extra == 0 then r.extra = nil end
return r
end
function Beasts:ExportData()
local list = {}
for id, e in pairs(self.db.beasts) do
local copy = {}
for k, v in pairs(e) do
copy[k] = v
end
copy.id = id
copy.cells = FlattenGrid(e.grid)
local clean = Sanitize(copy)
if clean then
-- Ausgabe als flache Liste statt verschachtelter Tabelle
clean.cells = FlattenGrid(clean.grid)
clean.grid = nil
if #clean.cells == 0 then
clean.cells = nil
end
list[#list + 1] = clean
end
end
table.sort(list, function(a, b)
return a.id < b.id
end)
if #list == 0 then
return {}
end
return { list = list }
end
-- Was geteilt werden darf: der eigene Eintrag, sonst ein ueber das Brett erhaltener (db.relay) aus der Ebene "importiert"
function Beasts:ShareEntry(id)
local e = self.db.beasts[id]
if e then return e end
local relay = self.db.relay
if relay and relay[id] then return self.db.imported[id] end
return nil
end
-- Ids aller teilbaren Eintraege: eigene Funde und ueber das Brett erhaltene
function Beasts:ShareIds()
local out = {}
for id, e in pairs(self.db.beasts) do
if type(id) == "number" and (e.seen or e.lored or e.tamed) then out[id] = e end
end
for id in pairs(self.db.relay or {}) do
local e = self.db.imported[id]
if type(id) == "number" and e and not out[id] then out[id] = e end
end
return out
end
-- Eigener Eintrag als bereinigter Datensatz zum Teilen (flache Zellenliste), nil wenn leer
function Beasts:ShareRecord(id)
local e = self:ShareEntry(id)
if not e then
return nil
end
local copy = {}
for k, v in pairs(e) do
copy[k] = v
end
copy.id = id
copy.cells = FlattenGrid(e.grid)
local clean = Sanitize(copy)
if not clean then
return nil
end
clean.cells = FlattenGrid(clean.grid)
clean.grid = nil
if #clean.cells == 0 then
clean.cells = nil
end
return clean
end
-- Zahl der eigenen teilbaren Eintraege und ihr Pruefwert (Reihenfolge egal)
function Beasts:ShareSummary()
local n, h = 0, 0
for id, e in pairs(self:ShareIds()) do
n = n + 1
h = (h + id * 131 + ((e.updated or 0) % 1000003)) % 2147483647
end
return n, h
end
function Beasts:ValidateImport(data)
if type(data) ~= "table" or type(data.list) ~= "table" or #data.list == 0 then
return false, L.BEAST_IMPORT_EMPTY
end
if #data.list > MAX_IMPORT then
return false, L.BEAST_IMPORT_TOO_MANY
end
return true
end
function Beasts:ImportData(data, opts)
opts = opts or {}
local added, updated, skipped = 0, 0, 0
for _, item in ipairs(data.list) do
local clean = Sanitize(item)
if not clean then
skipped = skipped + 1
else
local id = clean.id
clean.id = nil
local old = self.db.imported[id]
if old and not opts.merge and (old.updated or 0) >= clean.updated then
skipped = skipped + 1
else
if old then
-- Fundorte beider Staende zusammenfuehren, Merkmale vereinen
local merged = { grid = {} }
for _, source in ipairs({ old.grid or {}, clean.grid or {} }) do
for m, cells in pairs(source) do
for idx, n in pairs(cells) do
local have = merged.grid[m] and merged.grid[m][idx]
if have then
merged.grid[m][idx] = math.max(have, n)
else
AddSighting(merged, m, idx, n)
end
end
end
end
clean.grid = GridCount(merged.grid) > 0 and merged.grid or nil
clean.seen = clean.seen or old.seen
clean.lored = clean.lored or old.lored
clean.tamed = clean.tamed or old.tamed
clean.tameable = clean.tameable or old.tameable
clean.updated = math.max(old.updated or 0, clean.updated)
if not clean.petAbilities then clean.petAbilities = old.petAbilities end
if not clean.extra then clean.extra = old.extra end
updated = updated + 1
else
added = added + 1
end
clean.importedAt = time()
self.db.imported[id] = clean
if clean.fid and clean.family then
self.db.familyNames[clean.fid] = self.db.familyNames[clean.fid] or clean.family
end
end
end
end
self.index = nil
FW:Fire("BEASTS_UPDATED")
return string.format(FW.L.BEAST_IMPORT_STATS, added, updated, skipped)
end
FW.Export.RegisterHandler("beasts", {
label = function()
return FW.L.BEAST_EXPORT_LABEL
end,
export = function()
return Beasts:ExportData()
end,
validate = function(data)
return Beasts:ValidateImport(data)
end,
import = function(data)
return Beasts:ImportData(data)
end,
})