-- Copyright © 2018 Daniel Pittman <daniel@rimspace.net> -- See LICENSE for more details. SlippyCheeze = SlippyCheeze or {} if not SlippyCheeze.ReadItOnce then SlippyCheeze.ReadItOnce = { IS_RELEASE_VERSION = false, NAME="SlippyCheezeReadItOnce", DISPLAY_NAME = "|c798BD2ReadItOnce|r", -- for double-tap bypass of the block previousBook = { id = nil, time = 0, count = 0, }, DOUBLE_TAP_TIME = 1000, -- used for reporting on our background achievement scan async = nil, lore = { added = 0, scanned = 0, start = 0, }, -- seen holds our saved variables, eg, seen books. seen = {} } end local addon = SlippyCheeze.ReadItOnce local unpack = unpack local insert = table.insert -- reduce consing at runtime in debug message display local msg_prefix = addon.DISPLAY_NAME..": " local function msg(fmt, ...) local args = {} for n=1, select('#', ...) do insert(args, tostring(select(n, ...))) end d(msg_prefix..zo_strformat(fmt, unpack(args))) end -- return bool, have we seen this before. never called before saved variables -- are loaded and initialized. function addon:HaveSeenBookBefore(id, title, body) if type(id) ~= "number" then msg("ReadItOnce: id is <<1>> (<<2>>)", type(id), id) return false end -- ensure that we index by string, not number, in the table. -- luacheck: push noredefined local id = tostring(id) -- luacheck: pop local bodyHash = HashString(body) local record = self.seen[id] if record then -- probably have seen it before, but check for changes if record.id ~= id then d("ReadItOnce: book id changed from <<1>> to <<2>>", record.id, id) end if record.title ~= title then d("ReadItOnce: book title changed from '<<1>>' to '<<2>>'", record.title, title) end if record.bodyHash ~= bodyHash then d("ReadItOnce: book body changed") end -- don't show. return true end -- have not seen, record it, and return that fact self.seen[id] = {id=id, title=title, bodyHash=bodyHash} return false end -- Called when we want to skip showing a book. Probably going to be very -- strange if you call it any other time! function addon:DoNotShowThisBook(title, onlySound) PlaySound(SOUNDS.NEGATIVE_CLICK) if not onlySound then local params = CENTER_SCREEN_ANNOUNCE:CreateMessageParams(CSA_CATEGORY_SMALL_TEXT, nil) params:SetText(zo_strformat("You have already read \"<<1>>\"", title)) params:SetCSAType(CENTER_SCREEN_ANNOUNCE_TYPE_LORE_BOOK_LEARNED) params:SetLifespanMS(850) CENTER_SCREEN_ANNOUNCE:AddMessageWithParams(params) end EndInteraction(INTERACTION_BOOK) end -- Sadly, we have to override the original method, which is a local anonymous -- function, and which we have apparently no access to in order to hook nicely. -- -- The bulk of this is a direct copy-paste from the lore reader, as of USOUI -- 100023 -- -- The HaveSeenBook logic is my addition. function addon:OnShowBookOverride(eventCode, title, body, medium, showTitle, bookId) -- never block a book if we are not in the most basic state, which is the -- world interaction state. if not SCENE_MANAGER:IsShowingBaseScene() then return self:DoNotShowThisBook(title) end -- seen before, block unless is double-tap within the limit if self:HaveSeenBookBefore(bookId, title, body) then -- different book from the last time? local sameBook = (self.previousBook.id == bookId) -- last book was more than our double-tap time ago? local now = GetGameTimeMilliseconds() local timeSinceLastTap = (now - self.previousBook.time) local doubleTap = (timeSinceLastTap <= addon.DOUBLE_TAP_TIME) -- if not self.IS_RELEASE_VERSION then -- msg('show-p: sameBook=<<1>> doubleTap=<<2>> count=<<3>> timeSinceLastTap=<<4>>', -- sameBook, doubleTap, self.previousBook.count, timeSinceLastTap) -- end if sameBook then -- allow a double-tap after a failed double-tap self.previousBook.time = now -- remember if we are being real spammy here, but reset that tracker if -- they give a long enough pause. if timeSinceLastTap < 3000 then self.previousBook.count = self.previousBook.count + 1 else self.previousBook.count = 1 end if not doubleTap then -- don't keep on yelling if they spam interact too much, just beep. local onlySound = (self.previousBook.count > 1) return self:DoNotShowThisBook(title, onlySound) end else -- otherwise record this state for the future. self.previousBook.id = bookId self.previousBook.count = 1 self.previousBook.time = now -- and block the book. return self:DoNotShowThisBook(title) end end -- meh, this is copied from the local function in the ZOS code. :( if LORE_READER:Show(title, body, medium, showTitle) then PlaySound(LORE_READER.OpenSound) else EndInteraction(INTERACTION_BOOK) end end function addon:ScanOneLoreCategory(category) local _, numCollections, _ = GetLoreCategoryInfo(category) self.async:For(1, numCollections):Do(function(collection) self:ScanOneLoreCollection(category, collection) end) end function addon:ScanOneLoreCollection(category, collection) local _, _, _, numBooks, _, _, _ = GetLoreCollectionInfo(category, collection) self.async:For(1, numBooks):Do(function(book) self:ScanOneLoreBook(category, collection, book) end) end function addon:ScanOneLoreBook(category, collection, book) self.lore.scanned = self.lore.scanned + 1 local title, _, known, id = GetLoreBookInfo(category, collection, book) if known then local body = ReadLoreBook(category, collection, book) if not self:HaveSeenBookBefore(id, title, body) then self.lore.added = self.lore.added + 1 end end end function addon:ReportAfterLoreScan() if self.lore.added > 0 then -- ZOS quirk: the number **must** be the third argument. the plural must -- be a substitution of text. msg('added <<2>> <<m:1>> found in your achievements.', 'previously read book', self.lore.added) end if not self.IS_RELEASE_VERSION then local duration = FormatTimeMilliseconds( GetGameTimeMilliseconds() - self.lore.start, TIME_FORMAT_STYLE_DESCRIPTIVE_MINIMAL_SHOW_TENTHS_SECS, TIME_FORMAT_PRECISION_TENTHS_RELEVANT, TIME_FORMAT_DIRECTION_NONE) msg('SyncFromLoreBooks: scan ran for <<1>>', duration) end end function addon:SyncFromLoreBooks() if not self.IS_RELEASE_VERSION then msg('SyncFromLoreBooks: starting lore book scan now') end self.async = LibStub("LibAsync"):Create(self.NAME) self.lore.added = 0 self.lore.scanned = 0 self.lore.start = GetGameTimeMilliseconds() self.async:For(1, GetNumLoreCategories()):Do(function(category) self:ScanOneLoreCategory(category) end) self.async:Then(function() self:ReportAfterLoreScan() end) end function addon:OnAddonLoaded(name) if name ~= addon.NAME then return end EVENT_MANAGER:UnregisterForEvent(addon.NAME, EVENT_ADD_ON_LOADED) -- if the second argument, the version, changes then the data is wiped and -- replaced with the defaults. self.seen = ZO_SavedVars:NewAccountWide("SlippyCheezeReadItOnceData", 1) -- replace the original event handler with ours; sadly, we don't have -- access to the original implementation to do anything nicer. :/ LORE_READER.control:UnregisterForEvent(EVENT_SHOW_BOOK) LORE_READER.control:RegisterForEvent(EVENT_SHOW_BOOK, function(...) self:OnShowBookOverride(...) end) -- and once we actually log in, scan the collections for missing records in -- our data on what we have seen, since this is the only in-game history we -- can use... -- -- When the player logs in, we delay for ten seconds, then perform -- the scan. That gives the system a chance to settle down from all the -- other addons that want to do something at login as well... local function SyncFromLoreBooksShim(...) EVENT_MANAGER:UnregisterForEvent(addon.NAME, EVENT_PLAYER_ACTIVATED) zo_callLater(function() addon:SyncFromLoreBooks() end, 10000 --[[ 10s an ms --]]) end EVENT_MANAGER:RegisterForEvent(addon.NAME, EVENT_PLAYER_ACTIVATED, SyncFromLoreBooksShim) end -- bootstrapping EVENT_MANAGER:RegisterForEvent(addon.NAME, EVENT_ADD_ON_LOADED, function(_, name) addon:OnAddonLoaded(name) end)