--[[---------------------------------------------------------------------------- C_PlayerGuide.lua ───────────────────────────────────────────────────────────────────────────── Клиентское хранилище данных Путеводителя + UI-facing API. Соответствует контракту из player-guide-requirements.md → "Client API". Подключается до PlayerGuide.xml/.lua в .toc. ------------------------------------------------------------------------------]] C_PlayerGuide = C_PlayerGuide or {} local L = C_PlayerGuide -- ────────────────────────────────────────────────────────────────────────── -- Состояние local currentData = nil -- последний валидный payload от сервера local lastError = nil -- строка с описанием последней проблемы валидации -- Состояние UI: "data" | "empty" | "loading" local uiState = "loading" -- Подписчики на обновления (PlayerGuide.lua вешает сюда свой Refresh) local listeners = {} -- ────────────────────────────────────────────────────────────────────────── -- Утилиты local function notify() for _, fn in ipairs(listeners) do -- pcall чтобы один сломанный listener не уронил остальные pcall(fn, uiState, currentData) end end local function isTable(v) return type(v) == "table" end -- Валидация payload'а. Возвращает true либо false + строка ошибки. local function validate(data) if not isTable(data) then return false, "payload is not a table" end if data.stageID and type(data.stageID) ~= "number" then return false, "stageID is not a number" end if data.progress and type(data.progress) ~= "number" then return false, "progress is not a number" end if data.objectives and not isTable(data.objectives) then return false, "objectives is not a table" end -- Уникальность id целей if isTable(data.objectives) then local seen = {} for i, o in ipairs(data.objectives) do if not isTable(o) then return false, "objective #" .. i .. " is not a table" end if o.id and seen[o.id] then return false, "duplicate objective id: " .. tostring(o.id) end if o.id then seen[o.id] = true end end end return true end -- ────────────────────────────────────────────────────────────────────────── -- Подписки (для PlayerGuide.lua) function L.RegisterListener(fn) table.insert(listeners, fn) end -- ────────────────────────────────────────────────────────────────────────── -- Публичный API (см. требования → Client API) function L.SetData(data) local ok, err = validate(data) if not ok then lastError = err uiState = "empty" notify() return false, err end currentData = data lastError = nil uiState = "data" notify() return true end function L.Clear() currentData = nil lastError = nil uiState = "empty" notify() end function L.SetLoading() if uiState ~= "data" then uiState = "loading" notify() end end function L.GetState() return uiState end function L.GetLastError() return lastError end function L.HasData() return uiState == "data" and currentData ~= nil end function L.GetStageInfo() if not L.HasData() then return nil end return currentData.stageID, currentData.stageName, currentData.stageDescription, currentData.progress or 0 end -- Доп. метод: список всех этапов (для цепочки сверху) function L.GetStages() if not L.HasData() then return nil end return currentData.stages or {} -- сервер опционально шлёт массив этапов end function L.GetNumObjectives() if not L.HasData() or not currentData.objectives then return 0 end return #currentData.objectives end function L.GetObjectiveInfo(index) if not L.HasData() or not currentData.objectives then return nil end local o = currentData.objectives[index] if not o then return nil end return { id = o.id, type = o.type or "custom", title = o.title or "", description = o.description or o.subtitle or "", subtitle = o.subtitle, -- наше расширение поля для UI completed = o.completed and true or false, progress = o.progress or 0, required = o.required or 1, targetID = o.targetID, questID = o.questID, instanceID = o.instanceID, encounterID = o.encounterID, achievementID = o.achievementID, factionID = o.factionID, itemID = o.itemID, currencyID = o.currencyID, order = o.order or index, } end function L.GetNumRecommendations() if not L.HasData() or not currentData.recommendations then return 0 end return #currentData.recommendations end function L.GetRecommendationInfo(index) if not L.HasData() or not currentData.recommendations then return nil end local r = currentData.recommendations[index] if not r then return nil end return { id = r.id, type = r.type or "dungeon", title = r.title or r.name or "", name = r.name or r.title or "", description = r.description or r.loc or "", loc = r.loc, -- локация/подзаголовок level = r.level, biome = r.biome, -- ключ биом-обложки instanceID = r.instanceID, encounterID = r.encounterID, priority = r.priority or 0, } end function L.GetNumRewards() if not L.HasData() or not currentData.rewards then return 0 end return #currentData.rewards end function L.GetRewardInfo(index) if not L.HasData() or not currentData.rewards then return nil end local r = currentData.rewards[index] if not r then return nil end return r end -- ────────────────────────────────────────────────────────────────────────── -- Сетевой слой (AIO) -- MoonWellPlayerGuide.lua перехватывает SetData() из serverToClient и вызывает -- L.SetData(payload). RequestRefresh шлёт обратно в обратную сторону. function L.RequestRefresh() L.SetLoading() if AIO and AIO.Handle then -- name берётся из serverToClient на сервере. Тут — лёгкая обёртка. AIO.Handle("MoonWellPlayerGuide", "RequestRefresh") else -- Без AIO (например, в режиме разработки) — отдаём заглушку через -- 0.5 секунды, чтобы UI не висел в Loading навечно. if C_Timer and C_Timer.After then C_Timer.After(0.5, function() L.Clear() end) end end end function L.RequestTrackObjective(objectiveID) if AIO and AIO.Handle then AIO.Handle("MoonWellPlayerGuide", "RequestTrackObjective", objectiveID) end end function L.RequestOpenTarget(objectiveID) if AIO and AIO.Handle then AIO.Handle("MoonWellPlayerGuide", "RequestOpenTarget", objectiveID) end end -- ────────────────────────────────────────────────────────────────────────── -- Статика для разработки (видна, пока сервер не прислал реальный payload). -- Можно убрать перед релизом. function L._InjectDevPayload() L.SetData({ version = 1, stageID = 5, stageName = "Подготовка к Нордсколу", stageDescription = "Завершите ключевые цепочки заданий в Запределье и докажите готовность к походу на север. ".. "По прибытии в Нордскол вам понадобится снаряжение героического уровня.", progress = 62, stages = { { id = 1, name = "Начало пути", short = "Начало пути", era = "1—58", status = "done" }, { id = 2, name = "Огненные Недра", short = "Огненные Недра", era = "60", status = "done" }, { id = 3, name = "Сквозь Тёмный портал", short = "Тёмный портал", era = "58—60", status = "done" }, { id = 4, name = "Покорение Запределья", short = "Запределье", era = "60—68", status = "done" }, { id = 5, name = "Подготовка к Нордсколу", short = "К Нордсколу", era = "68—70", status = "active" }, { id = 6, name = "Заря Нордскола", short = "Нордскол", era = "70—78", status = "locked" }, { id = 7, name = "Цитадель Ледяной Короны",short = "Ледяная Корона", era = "78—80", status = "locked" }, }, objectives = { { id = 1, type = "level", title = "Достигнуть 68 уровня", subtitle = "Текущий уровень: 68. Можно отправляться в Нордскол.", completed = true, progress = 68, required = 68, order = 1 }, { id = 2, type = "quest_chain", title = "Цепочка «Поиск виверны» в Награнде", subtitle = "Кисть Шамана • Награнд • награда: «Привязка к виверне»", completed = false, progress = 5, required = 8, order = 2 }, { id = 3, type = "dungeon", title = "Пройти Кузню Крови", subtitle = "Цитадель Адского Пламени • рек. уровень 61—64", completed = false, progress = 0, required = 1, order = 3, instanceID = 256 }, { id = 4, type = "dungeon", title = "Пройти Аукиндон: Подземелья Аукенай", subtitle = "Награнд • рек. уровень 64—66", completed = false, progress = 0, required = 1, order = 4, instanceID = 257 }, { id = 5, type = "reputation", title = "Получить «Дружелюбие» с Кенарийской экспедицией", subtitle = "Текущее положение: Нейтрально • Долина Призрачной Луны", completed = false, progress = 1200, required = 3000, order = 5, factionID = 942 }, }, recommendations = { { id = 1, type = "dungeon", name = "Кузня Крови", loc = "Цитадель Адского Пламени", level = "61—64", biome = "fire" }, { id = 2, type = "dungeon", name = "Подземелья Аукенай", loc = "Аукиндон", level = "64—66", biome = "arcane" }, { id = 3, type = "dungeon", name = "Сетеккские залы", loc = "Награнд", level = "67—69", biome = "storm" }, { id = 4, type = "dungeon", name = "Темный Лабиринт", loc = "Аукиндон", level = "68—70", biome = "shadow" }, { id = 5, type = "dungeon", name = "Ботаника", loc = "Долина Призрачной Луны", level = "69—70", biome = "nature" }, { id = 6, type = "dungeon", name = "Расколотые Залы", loc = "Цитадель Адского Пламени", level = "70 H", biome = "fire" }, }, rewards = {}, -- блок наград временно скрыт (см. SHOW_REWARDS в PlayerGuide.lua) }) end