initial commit

This commit is contained in:
Gitea
2020-11-13 14:13:12 -05:00
commit 05df49ff60
368 changed files with 128754 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Auctions = TSM.Accounting:NewPackage("Auctions")
local Database = TSM.Include("Util.Database")
local CSV = TSM.Include("Util.CSV")
local String = TSM.Include("Util.String")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local CustomPrice = TSM.Include("Service.CustomPrice")
local private = {
db = nil,
numExpiresQuery = nil,
dataChanged = false,
statsQuery = nil,
statsTemp = {},
}
local COMBINE_TIME_THRESHOLD = 300 -- group expenses within 5 minutes together
local REMOVE_OLD_THRESHOLD = 180 * 24 * 60 * 60 -- remove records over 6 months old
local SECONDS_PER_DAY = 24 * 60 * 60
local CSV_KEYS = { "itemString", "stackSize", "quantity", "player", "time" }
-- ============================================================================
-- Module Functions
-- ============================================================================
function Auctions.OnInitialize()
private.db = Database.NewSchema("ACCOUNTING_AUCTIONS")
:AddStringField("baseItemString")
:AddStringField("type")
:AddStringField("itemString")
:AddNumberField("stackSize")
:AddNumberField("quantity")
:AddStringField("player")
:AddNumberField("time")
:AddNumberField("saveTime")
:AddIndex("baseItemString")
:AddIndex("time")
:Commit()
private.numExpiresQuery = private.db:NewQuery()
:Select("quantity")
:Equal("type", "expire")
:Equal("baseItemString", Database.BoundQueryParam())
:GreaterThanOrEqual("time", Database.BoundQueryParam())
private.statsQuery = private.db:NewQuery()
:Select("type", "quantity")
:Equal("baseItemString", Database.BoundQueryParam())
:GreaterThanOrEqual("time", Database.BoundQueryParam())
private.db:BulkInsertStart()
private.LoadData("cancel", TSM.db.realm.internalData.csvCancelled, TSM.db.realm.internalData.saveTimeCancels)
private.LoadData("expire", TSM.db.realm.internalData.csvExpired, TSM.db.realm.internalData.saveTimeExpires)
private.db:BulkInsertEnd()
CustomPrice.OnSourceChange("NumExpires")
end
function Auctions.OnDisable()
if not private.dataChanged then
-- nothing changed, so no need to save
return
end
local cancelSaveTimes, expireSaveTimes = {}, {}
local cancelEncodeContext = CSV.EncodeStart(CSV_KEYS)
local expireEncodeContext = CSV.EncodeStart(CSV_KEYS)
-- order by time to speed up loading
local query = private.db:NewQuery()
:Select("type", "itemString", "stackSize", "quantity", "player", "time", "saveTime")
:OrderBy("time", true)
for _, recordType, itemString, stackSize, quantity, player, timestamp, saveTime in query:Iterator() do
local saveTimes, encodeContext = nil, nil
if recordType == "cancel" then
saveTimes = cancelSaveTimes
encodeContext = cancelEncodeContext
elseif recordType == "expire" then
saveTimes = expireSaveTimes
encodeContext = expireEncodeContext
else
error("Invalid recordType: "..tostring(recordType))
end
-- add the save time
tinsert(saveTimes, saveTime ~= 0 and saveTime or time())
-- add to our list of CSV lines
CSV.EncodeAddRowDataRaw(encodeContext, itemString, stackSize, quantity, player, timestamp)
end
query:Release()
TSM.db.realm.internalData.csvCancelled = CSV.EncodeEnd(cancelEncodeContext)
TSM.db.realm.internalData.saveTimeCancels = table.concat(cancelSaveTimes, ",")
TSM.db.realm.internalData.csvExpired = CSV.EncodeEnd(expireEncodeContext)
TSM.db.realm.internalData.saveTimeExpires = table.concat(expireSaveTimes, ",")
end
function Auctions.InsertCancel(itemString, stackSize, timestamp)
private.InsertRecord("cancel", itemString, stackSize, timestamp)
end
function Auctions.InsertExpire(itemString, stackSize, timestamp)
private.InsertRecord("expire", itemString, stackSize, timestamp)
end
function Auctions.GetStats(itemString, minTime)
private.statsQuery:BindParams(ItemString.GetBase(itemString), minTime or 0)
wipe(private.statsTemp)
private.statsQuery:GroupedSum("type", "quantity", private.statsTemp)
local cancel = private.statsTemp.cancel or 0
local expire = private.statsTemp.expire or 0
local total = cancel + expire
return cancel, expire, total
end
function Auctions.GetNumExpires(itemString, minTime)
private.numExpiresQuery:BindParams(ItemString.GetBase(itemString), minTime or 0)
local num = 0
for _, quantity in private.numExpiresQuery:Iterator() do
num = num + quantity
end
return num
end
function Auctions.GetNumExpiresSinceSale(itemString)
return Auctions.GetNumExpires(itemString, TSM.Accounting.Transactions.GetLastSaleTime(itemString))
end
function Auctions.CreateQuery()
return private.db:NewQuery()
end
function Auctions.RemoveOldData(days)
private.dataChanged = true
private.db:SetQueryUpdatesPaused(true)
local numRecords = private.db:NewQuery()
:LessThan("time", time() - days * SECONDS_PER_DAY)
:DeleteAndRelease()
private.db:SetQueryUpdatesPaused(false)
CustomPrice.OnSourceChange("NumExpires")
return numRecords
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LoadData(recordType, csvRecords, csvSaveTimes)
local saveTimes = String.SafeSplit(csvSaveTimes, ",")
if not saveTimes then
return
end
local decodeContext = CSV.DecodeStart(csvRecords, CSV_KEYS)
if not decodeContext then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
return
end
local removeTime = time() - REMOVE_OLD_THRESHOLD
local index = 1
local prevTimestamp = 0
for itemString, stackSize, quantity, player, timestamp in CSV.DecodeIterator(decodeContext) do
itemString = ItemString.Get(itemString)
local baseItemString = ItemString.GetBaseFast(itemString)
local saveTime = tonumber(saveTimes[index])
stackSize = tonumber(stackSize)
quantity = tonumber(quantity)
timestamp = tonumber(timestamp)
if itemString and baseItemString and stackSize and quantity and timestamp and saveTime and timestamp > removeTime then
local newTimestamp = floor(timestamp)
if newTimestamp ~= timestamp then
-- make sure all timestamps are stored as integers
private.dataChanged = true
timestamp = newTimestamp
end
if timestamp < prevTimestamp then
-- not ordered by timestamp
private.dataChanged = true
end
prevTimestamp = timestamp
private.db:BulkInsertNewRowFast8(baseItemString, recordType, itemString, stackSize, quantity, player, timestamp, saveTime)
else
private.dataChanged = true
end
index = index + 1
end
if not CSV.DecodeEnd(decodeContext) then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
end
CustomPrice.OnSourceChange("NumExpires")
end
function private.InsertRecord(recordType, itemString, stackSize, timestamp)
private.dataChanged = true
assert(itemString and stackSize and stackSize > 0 and timestamp)
timestamp = floor(timestamp)
local baseItemString = ItemString.GetBase(itemString)
local matchingRow = private.db:NewQuery()
:Equal("type", recordType)
:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
:Equal("stackSize", stackSize)
:Equal("player", UnitName("player"))
:GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD)
:LessThan("time", timestamp + COMBINE_TIME_THRESHOLD)
:Equal("saveTime", 0)
:GetFirstResultAndRelease()
if matchingRow then
matchingRow:SetField("quantity", matchingRow:GetField("quantity") + stackSize)
matchingRow:Update()
matchingRow:Release()
else
private.db:NewRow()
:SetField("baseItemString", baseItemString)
:SetField("type", recordType)
:SetField("itemString", itemString)
:SetField("stackSize", stackSize)
:SetField("quantity", stackSize)
:SetField("player", UnitName("player"))
:SetField("time", timestamp)
:SetField("saveTime", 0)
:Create()
end
if recordType == "expire" then
CustomPrice.OnSourceChange("NumExpires", itemString)
end
end

View File

@@ -0,0 +1,54 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Accounting = TSM:NewPackage("Accounting")
local Math = TSM.Include("Util.Math")
local private = {
characterGuildTemp = {},
}
local SECONDS_PER_DAY = 24 * 60 * 60
-- ============================================================================
-- Module Functions
-- ============================================================================
function Accounting.GetSummaryQuery(timeFilterStart, timeFilterEnd, ignoredCharacters)
local query = TSM.Accounting.Transactions.CreateQuery()
:Select("type", "itemString", "price", "quantity", "time")
if timeFilterStart then
query:GreaterThan("time", timeFilterStart)
end
if timeFilterEnd then
query:LessThan("time", timeFilterEnd)
end
if ignoredCharacters then
wipe(private.characterGuildTemp)
for characterGuild in pairs(ignoredCharacters) do
local character, realm = strmatch(characterGuild, "^(.+) %- .+ %- (.+)$")
if character and realm == GetRealmName() then
private.characterGuildTemp[character] = true
end
end
query:NotInTable("player", private.characterGuildTemp)
end
return query
end
function Accounting.GetSaleRate(itemString)
-- since auction data only goes back 180 days, limit the sales to that same time range
local _, totalSaleNum = TSM.Accounting.Transactions.GetSaleStats(itemString, 180 * SECONDS_PER_DAY)
if not totalSaleNum then
return nil
end
local _, _, totalFailed = TSM.Accounting.Auctions.GetStats(itemString)
if not totalFailed then
return nil
end
return Math.Round(totalSaleNum / (totalSaleNum + totalFailed), 0.01)
end

View File

@@ -0,0 +1,56 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Garrison = TSM.Accounting:NewPackage("Garrison")
local Event = TSM.Include("Util.Event")
local private = {}
local GOLD_TRAIT_ID = 256 -- traitId for the treasure hunter trait which increases gold from missions
-- ============================================================================
-- Module Functions
-- ============================================================================
function Garrison.OnInitialize()
if not TSM.IsWowClassic() then
Event.Register("GARRISON_MISSION_COMPLETE_RESPONSE", private.MissionComplete)
end
end
-- ============================================================================
-- Misson Reward Tracking
-- ============================================================================
function private.MissionComplete(_, missionId)
local moneyAward = 0
local info = C_Garrison.GetBasicMissionInfo(missionId)
if not info then
return
end
local rewards = info.rewards or info.overMaxRewards
for _, reward in pairs(rewards) do
if reward.title == GARRISON_REWARD_MONEY and reward.currencyID == 0 then
moneyAward = moneyAward + reward.quantity
end
end
if moneyAward > 0 then
-- check for followers which give bonus gold
local multiplier = 1
for _, followerId in ipairs(info.followers) do
for _, trait in ipairs(C_Garrison.GetFollowerAbilities(followerId)) do
if trait.id == GOLD_TRAIT_ID then
multiplier = multiplier + 1
end
end
end
moneyAward = moneyAward * multiplier
TSM.Accounting.Money.InsertGarrisonIncome(moneyAward)
end
end

View File

@@ -0,0 +1,291 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local GoldTracker = TSM.Accounting:NewPackage("GoldTracker")
local Event = TSM.Include("Util.Event")
local Delay = TSM.Include("Util.Delay")
local CSV = TSM.Include("Util.CSV")
local Math = TSM.Include("Util.Math")
local Log = TSM.Include("Util.Log")
local Table = TSM.Include("Util.Table")
local TempTable = TSM.Include("Util.TempTable")
local Settings = TSM.Include("Service.Settings")
local PlayerInfo = TSM.Include("Service.PlayerInfo")
local private = {
truncateGoldLog = {},
characterGoldLog = {},
guildGoldLog = {},
currentCharacterKey = nil,
playerLogCount = 0,
searchValueTemp = {},
}
local CSV_KEYS = { "minute", "copper" }
local CHARACTER_KEY_SEP = " - "
local SECONDS_PER_MIN = 60
local SECONDS_PER_DAY = SECONDS_PER_MIN * 60 * 24
local MAX_COPPER_VALUE = 10 * 1000 * 1000 * COPPER_PER_GOLD - 1
local ERRONEOUS_ZERO_THRESHOLD = 5 * 1000 * COPPER_PER_GOLD
-- ============================================================================
-- Module Functions
-- ============================================================================
function GoldTracker.OnInitialize()
if not TSM.IsWowClassic() then
Event.Register("GUILDBANKFRAME_OPENED", private.GuildLogGold)
Event.Register("GUILDBANK_UPDATE_MONEY", private.GuildLogGold)
end
Event.Register("PLAYER_MONEY", private.PlayerLogGold)
-- get a list of known characters / guilds
local validCharacterGuilds = TempTable.Acquire()
for _, character in Settings.CharacterByFactionrealmIterator() do
validCharacterGuilds[character..CHARACTER_KEY_SEP..UnitFactionGroup("player")..CHARACTER_KEY_SEP..GetRealmName()] = true
local guild = TSM.db.factionrealm.internalData.characterGuilds[character]
if guild then
validCharacterGuilds[guild] = true
end
end
-- load the gold log data
for realm in TSM.db:GetConnectedRealmIterator("realm") do
for factionrealm in TSM.db:FactionrealmByRealmIterator(realm) do
for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do
local data = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character, factionrealm), "internalData", "goldLog")
if data then
local lastUpdate = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character, factionrealm), "internalData", "goldLogLastUpdate") or 0
local characterKey = character..CHARACTER_KEY_SEP..factionrealm
private.LoadCharacterGoldLog(characterKey, data, validCharacterGuilds, lastUpdate)
end
end
local guildData = TSM.db:Get("factionrealm", factionrealm, "internalData", "guildGoldLog")
if guildData then
for guild, data in pairs(guildData) do
local entries = {}
local decodeContext = CSV.DecodeStart(data, CSV_KEYS)
if decodeContext then
for minute, copper in CSV.DecodeIterator(decodeContext) do
tinsert(entries, { minute = tonumber(minute), copper = tonumber(copper) })
end
CSV.DecodeEnd(decodeContext)
end
private.guildGoldLog[guild] = entries
local lastEntryTime = #entries > 0 and entries[#entries].minute * SECONDS_PER_MIN or math.huge
local lastUpdate = TSM.db:Get("factionrealm", factionrealm, "internalData", "guildGoldLogLastUpdate")
if not validCharacterGuilds[guild] and max(lastEntryTime, lastUpdate and lastUpdate[guild] or 0) < time() - 30 * SECONDS_PER_DAY then
-- this guild may not be valid and the last entry is over 30 days old, so truncate the data
private.truncateGoldLog[guild] = lastEntryTime
end
end
end
end
end
TempTable.Release(validCharacterGuilds)
private.currentCharacterKey = UnitName("player")..CHARACTER_KEY_SEP..UnitFactionGroup("player")..CHARACTER_KEY_SEP..GetRealmName()
assert(private.characterGoldLog[private.currentCharacterKey])
end
function GoldTracker.OnEnable()
-- Log the current player gold (need to wait for OnEnable, otherwise GetMoney() returns 0 when first logging in)
private.PlayerLogGold()
end
function GoldTracker.OnDisable()
private.PlayerLogGold()
TSM.db.sync.internalData.goldLog = CSV.Encode(CSV_KEYS, private.characterGoldLog[private.currentCharacterKey])
TSM.db.sync.internalData.goldLogLastUpdate = private.characterGoldLog[private.currentCharacterKey].lastUpdate
local guild = PlayerInfo.GetPlayerGuild(UnitName("player"))
if guild and private.guildGoldLog[guild] then
TSM.db.factionrealm.internalData.guildGoldLog[guild] = CSV.Encode(CSV_KEYS, private.guildGoldLog[guild])
TSM.db.factionrealm.internalData.guildGoldLogLastUpdate[guild] = private.guildGoldLog[guild].lastUpdate
end
end
function GoldTracker.CharacterGuildIterator()
return private.CharacterGuildIteratorHelper
end
function GoldTracker.GetGoldAtTime(timestamp, ignoredCharactersGuilds)
local value = 0
for character, logEntries in pairs(private.characterGoldLog) do
if #logEntries > 0 and not ignoredCharactersGuilds[character] and (private.truncateGoldLog[character] or math.huge) > timestamp then
value = value + private.GetValueAtTime(logEntries, timestamp)
end
end
for guild, logEntries in pairs(private.guildGoldLog) do
if #logEntries > 0 and not ignoredCharactersGuilds[guild] and (private.truncateGoldLog[guild] or math.huge) > timestamp then
value = value + private.GetValueAtTime(logEntries, timestamp)
end
end
return value
end
function GoldTracker.GetGraphTimeRange(ignoredCharactersGuilds)
local minTime = Math.Floor(time(), SECONDS_PER_MIN)
for character, logEntries in pairs(private.characterGoldLog) do
if #logEntries > 0 and not ignoredCharactersGuilds[character] then
minTime = min(minTime, logEntries[1].minute * SECONDS_PER_MIN)
end
end
for guild, logEntries in pairs(private.guildGoldLog) do
if #logEntries > 0 and not ignoredCharactersGuilds[guild] then
minTime = min(minTime, logEntries[1].minute * SECONDS_PER_MIN)
end
end
return minTime, Math.Floor(time(), SECONDS_PER_MIN), SECONDS_PER_MIN
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LoadCharacterGoldLog(characterKey, data, validCharacterGuilds, lastUpdate)
assert(not private.characterGoldLog[characterKey])
local decodeContext = CSV.DecodeStart(data, CSV_KEYS)
if not decodeContext then
Log.Err("Failed to decode (%s, %d)", characterKey, #data)
private.characterGoldLog[characterKey] = {}
return
end
local entries = {}
for minute, copper in CSV.DecodeIterator(decodeContext) do
tinsert(entries, { minute = tonumber(minute), copper = tonumber(copper) })
end
CSV.DecodeEnd(decodeContext)
-- clean up any erroneous 0 entries, entries which are too high, and duplicate entries
local didChange = true
while didChange do
didChange = false
for i = #entries - 1, 2, -1 do
local prevValue = entries[i-1].copper
local value = entries[i].copper
local nextValue = entries[i+1].copper
if prevValue > ERRONEOUS_ZERO_THRESHOLD and value == 0 and nextValue > ERRONEOUS_ZERO_THRESHOLD then
-- this is likely an erroneous 0 value
didChange = true
tremove(entries, i)
end
end
for i = #entries, 2, -1 do
local prevValue = entries[i-1].copper
local value = entries[i].copper
if prevValue == value or value > MAX_COPPER_VALUE then
-- this is either a duplicate or invalid value
didChange = true
tremove(entries, i)
end
end
end
private.characterGoldLog[characterKey] = entries
local lastEntryTime = #entries > 0 and entries[#entries].minute * SECONDS_PER_MIN or math.huge
if not validCharacterGuilds[characterKey] and max(lastEntryTime, lastUpdate) < time() - 30 * SECONDS_PER_DAY then
-- this character may not be valid and the last entry is over 30 days old, so truncate the data
private.truncateGoldLog[characterKey] = lastEntryTime
end
end
function private.UpdateGoldLog(goldLog, copper)
copper = Math.Round(copper, COPPER_PER_GOLD * (TSM.IsWowClassic() and 1 or 1000))
local currentMinute = floor(time() / SECONDS_PER_MIN)
local prevRecord = goldLog[#goldLog]
-- store the last update time
goldLog.lastUpdate = time()
if prevRecord and copper == prevRecord.copper then
-- amount of gold hasn't changed, so nothing to do
return
elseif prevRecord and prevRecord.minute == currentMinute then
-- gold has changed and the previous record is for the current minute so just modify it
prevRecord.copper = copper
else
-- amount of gold changed and we're in a new minute, so insert a new record
while prevRecord and prevRecord.minute > currentMinute - 1 do
-- their clock may have changed - just delete everything that's too recent
tremove(goldLog)
prevRecord = goldLog[#goldLog]
end
tinsert(goldLog, {
minute = currentMinute,
copper = copper
})
end
end
function private.GuildLogGold()
local guildName = GetGuildInfo("player")
local isGuildLeader = IsGuildLeader()
if guildName and not isGuildLeader then
-- check if our alt is the guild leader
for i = 1, GetNumGuildMembers() do
local name, _, rankIndex = GetGuildRosterInfo(i)
if name and rankIndex == 0 and PlayerInfo.IsPlayer(gsub(name, "%-", " - "), true) then
isGuildLeader = true
end
end
end
if guildName and isGuildLeader then
if not private.guildGoldLog[guildName] then
private.guildGoldLog[guildName] = {}
end
private.UpdateGoldLog(private.guildGoldLog[guildName], GetGuildBankMoney())
end
end
function private.PlayerLogGold()
-- GetMoney sometimes returns 0 for a while after login, so keep trying for 30 seconds before recording a 0
local money = GetMoney()
if money == 0 and private.playerLogCount < 30 then
private.playerLogCount = private.playerLogCount + 1
Delay.AfterTime(1, private.PlayerLogGold)
return
end
private.playerLogCount = 0
private.UpdateGoldLog(private.characterGoldLog[private.currentCharacterKey], money)
TSM.db.sync.internalData.money = money
end
function private.GetValueAtTime(logEntries, timestamp)
local minute = floor(timestamp / SECONDS_PER_MIN)
if logEntries[1].minute > minute then
-- timestamp is before we had any data
return 0
end
private.searchValueTemp.minute = minute
local index, insertIndex = Table.BinarySearch(logEntries, private.searchValueTemp, private.GetEntryMinute)
-- if we didn't find an exact match, the index is the previous one (compared to the insert index)
-- as that point's gold value is true up until the next point
index = index or (insertIndex - 1)
return logEntries[index].copper
end
function private.GetEntryMinute(entry)
return entry.minute
end
function private.CharacterGuildIteratorHelper(_, lastKey)
local result, isGuild = nil, nil
if not lastKey or private.characterGoldLog[lastKey] then
result = next(private.characterGoldLog, lastKey)
isGuild = false
if not result then
lastKey = nil
end
end
if not result then
result = next(private.guildGoldLog, lastKey)
isGuild = result and true or false
end
return result, isGuild
end

View File

@@ -0,0 +1,387 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Mail = TSM.Accounting:NewPackage("Mail")
local Event = TSM.Include("Util.Event")
local Delay = TSM.Include("Util.Delay")
local String = TSM.Include("Util.String")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local InventoryInfo = TSM.Include("Service.InventoryInfo")
local AuctionTracking = TSM.Include("Service.AuctionTracking")
local Inventory = TSM.Include("Service.Inventory")
local private = {
hooks = {},
}
local SECONDS_PER_DAY = 24 * 60 * 60
local EXPIRED_MATCH_TEXT = AUCTION_EXPIRED_MAIL_SUBJECT:gsub("%%s", "")
local CANCELLED_MATCH_TEXT = AUCTION_REMOVED_MAIL_SUBJECT:gsub("%%s", "")
local OUTBID_MATCH_TEXT = AUCTION_OUTBID_MAIL_SUBJECT:gsub("%%s", "(.+)")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Mail.OnInitialize()
Event.Register("MAIL_SHOW", function() Delay.AfterTime("ACCOUNTING_GET_SELLERS", 0.1, private.RequestSellerInfo, 0.1) end)
Event.Register("MAIL_CLOSED", function() Delay.Cancel("ACCOUNTING_GET_SELLERS") end)
-- hook certain mail functions
private.hooks.TakeInboxItem = TakeInboxItem
TakeInboxItem = function(...)
Mail:ScanCollectedMail("TakeInboxItem", 1, ...)
end
private.hooks.TakeInboxMoney = TakeInboxMoney
TakeInboxMoney = function(...)
Mail:ScanCollectedMail("TakeInboxMoney", 1, ...)
end
private.hooks.AutoLootMailItem = AutoLootMailItem
AutoLootMailItem = function(...)
Mail:ScanCollectedMail("AutoLootMailItem", 1, ...)
end
private.hooks.SendMail = SendMail
SendMail = private.CheckSendMail
end
-- ============================================================================
-- Inbox Functions
-- ============================================================================
function private.RequestSellerInfo()
local isDone = true
for i = 1, GetInboxNumItems() do
local invoiceType, _, seller = GetInboxInvoiceInfo(i)
if invoiceType and seller == "" then
isDone = false
end
end
if isDone and GetInboxNumItems() > 0 then
Delay.Cancel("ACCOUNTING_GET_SELLERS")
end
end
function private.CanLootMailIndex(index, copper)
local currentMoney = GetMoney()
assert(currentMoney <= MAXIMUM_BID_PRICE)
-- check if this would put them over the gold cap
if currentMoney + copper > MAXIMUM_BID_PRICE then return end
local _, _, _, _, _, _, _, itemCount = GetInboxHeaderInfo(index)
if not itemCount or itemCount == 0 then return true end
for j = 1, ATTACHMENTS_MAX_RECEIVE do
-- TODO: prevent items that you can't loot because of internal mail error
if CalculateTotalNumberOfFreeBagSlots() <= 0 then
return
end
local link = GetInboxItemLink(index, j)
local itemString = ItemString.Get(link)
local _, _, _, count = GetInboxItem(index, j)
local quantity = count or 0
local maxUnique = private.GetInboxMaxUnique(index, j)
-- dont record unique items that we can't loot
local playerQty = Inventory.GetBagQuantity(itemString) + Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString)
if maxUnique > 0 and maxUnique < playerQty + quantity then
return
end
if itemString then
for bag = 0, NUM_BAG_SLOTS do
if InventoryInfo.ItemWillGoInBag(link, bag) then
for slot = 1, GetContainerNumSlots(bag) do
local iString = ItemString.Get(GetContainerItemLink(bag, slot))
if iString == itemString then
local _, stackSize = GetContainerItemInfo(bag, slot)
local maxStackSize = ItemInfo.GetMaxStack(itemString) or 1
if (maxStackSize - stackSize) >= quantity then
return true
end
elseif not iString then
return true
end
end
end
end
end
end
end
function private.GetInboxMaxUnique(index, num)
if not num then
num = 1
end
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
local _, speciesId = TSMScanTooltip:SetInboxItem(index, num)
if (speciesId or 0) > 0 then
return 0
else
for id = 2, TSMScanTooltip:NumLines() do
local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id])
if text then
if text == ITEM_UNIQUE then
return 1
else
local match = text and strmatch(text, "^"..ITEM_UNIQUE.." %((%d+)%)$")
if match then
return tonumber(match)
end
end
end
end
end
return 0
end
function private.GetTooltipText(text)
local textStr = strtrim(text and text:GetText() or "")
if textStr == "" then return end
return textStr
end
-- scans the mail that the player just attempted to collected (Pre-Hook)
function Mail:ScanCollectedMail(oFunc, attempt, index, subIndex)
local invoiceType, itemName, buyer, bid, _, _, ahcut, _, _, _, quantity = GetInboxInvoiceInfo(index)
buyer = buyer or (invoiceType == "buyer" and AUCTION_HOUSE_MAIL_MULTIPLE_SELLERS or AUCTION_HOUSE_MAIL_MULTIPLE_BUYERS)
local _, stationeryIcon, sender, subject, money, codAmount, daysLeft = GetInboxHeaderInfo(index)
if not subject then return end
if attempt > 2 then
if buyer == "" then
buyer = "?"
elseif sender == "" then
sender = "?"
end
end
local success = false
if invoiceType == "seller" and buyer and buyer ~= "" then -- AH Sales
local saleTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY)
local itemString = ItemInfo.ItemNameToItemString(itemName)
if not itemString or itemString == ItemString.GetUnknown() then
itemString = AuctionTracking.GetSaleHintItemString(itemName, quantity, bid)
end
if private.CanLootMailIndex(index, (bid - ahcut)) then
if itemString then
local copper = floor((bid - ahcut) / quantity + 0.5)
TSM.Accounting.Transactions.InsertAuctionSale(itemString, quantity, copper, buyer, saleTime)
end
success = true
end
elseif invoiceType == "buyer" and buyer and buyer ~= "" then -- AH Buys
local copper = floor(bid / quantity + 0.5)
if not TSM.IsWowClassic() then
if subIndex then
quantity = select(4, GetInboxItem(index, subIndex))
else
quantity = 0
for i = 1, ATTACHMENTS_MAX do
quantity = quantity + (select(4, GetInboxItem(index, i)) or 0)
end
end
end
local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1)
local itemString = ItemString.Get(link)
if itemString and private.CanLootMailIndex(index, 0) then
local buyTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY)
TSM.Accounting.Transactions.InsertAuctionBuy(itemString, quantity, copper, buyer, buyTime)
success = true
end
elseif codAmount > 0 then -- COD Buys (only if all attachments are same item)
local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1)
local itemString = ItemString.Get(link)
if itemString and sender then
local name = ItemInfo.GetName(link)
local total = 0
local stacks = 0
local ignore = false
for i = 1, ATTACHMENTS_MAX_RECEIVE do
local nameCheck, _, _, count = GetInboxItem(index, i)
if nameCheck and count then
if nameCheck == name then
total = total + count
stacks = stacks + 1
else
ignore = true
end
end
end
if total ~= 0 and not ignore and private.CanLootMailIndex(index, codAmount) then
local copper = floor(codAmount / total + 0.5)
local buyTime = (time() + (daysLeft - 3) * SECONDS_PER_DAY)
local maxStack = ItemInfo.GetMaxStack(link)
for _ = 1, stacks do
local stackSize = (total >= maxStack) and maxStack or total
TSM.Accounting.Transactions.InsertCODBuy(itemString, stackSize, copper, sender, buyTime)
total = total - stackSize
if total <= 0 then
break
end
end
end
success = true
end
elseif money > 0 and invoiceType ~= "seller" and not strfind(subject, OUTBID_MATCH_TEXT) then
local str = nil
if GetLocale() == "deDE" then
str = gsub(subject, gsub(COD_PAYMENT, String.Escape("%1$s"), ""), "")
else
str = gsub(subject, gsub(COD_PAYMENT, String.Escape("%s"), ""), "")
end
local saleTime = (time() + (daysLeft - 31) * SECONDS_PER_DAY)
if sender and private.CanLootMailIndex(index, money) then
if str and strfind(str, "TSM$") then -- payment for a COD the player sent
local codName = strtrim(strmatch(str, "([^%(]+)"))
local qty = strmatch(str, "%(([0-9]+)%)")
qty = tonumber(qty)
local itemString = ItemInfo.ItemNameToItemString(codName)
if itemString then
local copper = floor(money / qty + 0.5)
local maxStack = ItemInfo.GetMaxStack(itemString) or 1
local stacks = ceil(qty / maxStack)
for _ = 1, stacks do
local stackSize = (qty >= maxStack) and maxStack or qty
TSM.Accounting.Transactions.InsertCODSale(itemString, stackSize, copper, sender, saleTime)
qty = qty - stackSize
if qty <= 0 then
break
end
end
end
else -- record a money transfer
TSM.Accounting.Money.InsertMoneyTransferIncome(money, sender, saleTime)
end
success = true
end
elseif strfind(subject, EXPIRED_MATCH_TEXT) then -- expired auction
local expiredTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY)
local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1)
local _, _, _, count = GetInboxItem(index, subIndex or 1)
if TSM.IsWowClassic() then
quantity = count or 0
else
if subIndex then
quantity = select(4, GetInboxItem(index, subIndex))
else
quantity = 0
for i = 1, ATTACHMENTS_MAX do
quantity = quantity + (select(4, GetInboxItem(index, i)) or 0)
end
end
end
local itemString = ItemString.Get(link)
if private.CanLootMailIndex(index, 0) and itemString and quantity then
TSM.Accounting.Auctions.InsertExpire(itemString, quantity, expiredTime)
success = true
end
elseif strfind(subject, CANCELLED_MATCH_TEXT) then -- cancelled auction
local cancelledTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY)
local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1)
local _, _, _, count = GetInboxItem(index, subIndex or 1)
if TSM.IsWowClassic() then
quantity = count or 0
else
if subIndex then
quantity = select(4, GetInboxItem(index, subIndex))
else
quantity = 0
for i = 1, ATTACHMENTS_MAX do
quantity = quantity + (select(4, GetInboxItem(index, i)) or 0)
end
end
end
local itemString = ItemString.Get(link)
if private.CanLootMailIndex(index, 0) and itemString and quantity then
TSM.Accounting.Auctions.InsertCancel(itemString, quantity, cancelledTime)
success = true
end
end
if success then
private.hooks[oFunc](index, subIndex)
elseif (not stationeryIcon or (invoiceType and (not buyer or buyer == ""))) and attempt <= 5 then
Delay.AfterTime("accountingHookDelay", 0.2, function() Mail:ScanCollectedMail(oFunc, attempt + 1, index, subIndex) end)
elseif attempt > 5 then
private.hooks[oFunc](index, subIndex)
else
private.hooks[oFunc](index, subIndex)
end
end
-- ============================================================================
-- Sending Functions
-- ============================================================================
-- scans the mail that the player just attempted to send (Pre-Hook) to see if COD
function private.CheckSendMail(destination, currentSubject, ...)
local codAmount = GetSendMailCOD()
local moneyAmount = GetSendMailMoney()
local mailCost = GetSendMailPrice()
local subject
local total = 0
local ignore = false
if codAmount ~= 0 then
for i = 1, 12 do
local itemName, _, _, count = GetSendMailItem(i)
if itemName and count then
if not subject then
subject = itemName
end
if subject == itemName then
total = total + count
else
ignore = true
end
end
end
else
ignore = true
end
if moneyAmount > 0 then
-- add a record for the money transfer
TSM.Accounting.Money.InsertMoneyTransferExpense(moneyAmount, destination)
mailCost = mailCost - moneyAmount
end
TSM.Accounting.Money.InsertPostageExpense(mailCost, destination)
if not ignore then
private.hooks.SendMail(destination, subject .. " (" .. total .. ") TSM", ...)
else
private.hooks.SendMail(destination, currentSubject, ...)
end
end
function private.GetFirstInboxItemLink(index)
if not TSMAccountingMailTooltip then
CreateFrame("GameTooltip", "TSMAccountingMailTooltip", UIParent, "GameTooltipTemplate")
end
TSMAccountingMailTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMAccountingMailTooltip:ClearLines()
local _, speciesId, level, breedQuality, maxHealth, power, speed = TSMAccountingMailTooltip:SetInboxItem(index)
local link = nil
if (speciesId or 0) > 0 then
link = ItemInfo.GetLink(strjoin(":", "p", speciesId, level, breedQuality, maxHealth, power, speed))
else
link = GetInboxItemLink(index, 1)
end
TSMAccountingMailTooltip:Hide()
return link
end

View File

@@ -0,0 +1,133 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Merchant = TSM.Accounting:NewPackage("Merchant")
local Event = TSM.Include("Util.Event")
local Math = TSM.Include("Util.Math")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local private = {
repairMoney = 0,
couldRepair = nil,
repairCost = 0,
pendingSales = {
itemString = {},
quantity = {},
copper = {},
insertTime = {},
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Merchant.OnInitialize()
Event.Register("MERCHANT_SHOW", private.SetupRepairCost)
Event.Register("BAG_UPDATE_DELAYED", private.OnMerchantUpdate)
Event.Register("UPDATE_INVENTORY_DURABILITY", private.AddRepairCosts)
Event.Register("MERCHANT_CLOSED", private.OnMerchantClosed)
hooksecurefunc("UseContainerItem", private.CheckMerchantSale)
hooksecurefunc("BuyMerchantItem", private.OnMerchantBuy)
hooksecurefunc("BuybackItem", private.OnMerchantBuyback)
end
-- ============================================================================
-- Repair Cost Tracking
-- ============================================================================
function private.SetupRepairCost()
private.repairMoney = GetMoney()
private.couldRepair = CanMerchantRepair()
-- if merchant can repair set up variables so we can track repairs
if private.couldRepair then
private.repairCost = GetRepairAllCost()
end
end
function private.OnMerchantUpdate()
-- Could have bought something before or after repair
private.repairMoney = GetMoney()
-- log any pending sales
for i, insertTime in ipairs(private.pendingSales.insertTime) do
if GetTime() - insertTime < 5 then
TSM.Accounting.Transactions.InsertVendorSale(private.pendingSales.itemString[i], private.pendingSales.quantity[i], private.pendingSales.copper[i])
end
end
wipe(private.pendingSales.itemString)
wipe(private.pendingSales.quantity)
wipe(private.pendingSales.copper)
wipe(private.pendingSales.insertTime)
end
function private.AddRepairCosts()
if private.couldRepair and private.repairCost > 0 then
local cash = GetMoney()
if private.repairMoney > cash then
-- this is probably a repair bill
local cost = private.repairMoney - cash
TSM.Accounting.Money.InsertRepairBillExpense(cost)
-- reset money as this might have been a single item repair
private.repairMoney = cash
-- reset the repair cost for the next repair
private.repairCost = GetRepairAllCost()
end
end
end
function private.OnMerchantClosed()
private.couldRepair = nil
private.repairCost = 0
end
-- ============================================================================
-- Merchant Purchases / Sales Tracking
-- ============================================================================
function private.CheckMerchantSale(bag, slot, onSelf)
-- check if we are trying to sell something to a vendor
if (not MerchantFrame:IsShown() and not TSM.UI.VendoringUI.IsVisible()) or onSelf then
return
end
local itemString = ItemString.Get(GetContainerItemLink(bag, slot))
local _, quantity = GetContainerItemInfo(bag, slot)
local copper = ItemInfo.GetVendorSell(itemString)
if not itemString or not quantity or not copper then
return
end
tinsert(private.pendingSales.itemString, itemString)
tinsert(private.pendingSales.quantity, quantity)
tinsert(private.pendingSales.copper, copper)
tinsert(private.pendingSales.insertTime, GetTime())
end
function private.OnMerchantBuy(index, quantity)
local _, _, price, batchQuantity = GetMerchantItemInfo(index)
local itemString = ItemString.Get(GetMerchantItemLink(index))
if not itemString or not price or price <= 0 then
return
end
quantity = quantity or batchQuantity
local copper = Math.Round(price / batchQuantity)
TSM.Accounting.Transactions.InsertVendorBuy(itemString, quantity, copper)
end
function private.OnMerchantBuyback(index)
local _, _, price, quantity = GetBuybackItemInfo(index)
local itemString = ItemString.Get(GetBuybackItemLink(index))
if not itemString or not price or price <= 0 then
return
end
local copper = Math.Round(price / quantity)
TSM.Accounting.Transactions.InsertVendorBuy(itemString, quantity, copper)
end

View File

@@ -0,0 +1,171 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Money = TSM.Accounting:NewPackage("Money")
local Database = TSM.Include("Util.Database")
local CSV = TSM.Include("Util.CSV")
local Log = TSM.Include("Util.Log")
local private = {
db = nil,
dataChanged = false,
}
local CSV_KEYS = { "type", "amount", "otherPlayer", "player", "time" }
local COMBINE_TIME_THRESHOLD = 300 -- group expenses within 5 minutes together
local SECONDS_PER_DAY = 24 * 60 * 60
-- ============================================================================
-- Module Functions
-- ============================================================================
function Money.OnInitialize()
private.db = Database.NewSchema("ACCOUNTING_MONEY")
:AddStringField("recordType")
:AddStringField("type")
:AddNumberField("amount")
:AddStringField("otherPlayer")
:AddStringField("player")
:AddNumberField("time")
:AddIndex("recordType")
:Commit()
private.db:BulkInsertStart()
private.LoadData("expense", TSM.db.realm.internalData.csvExpense)
private.LoadData("income", TSM.db.realm.internalData.csvIncome)
private.db:BulkInsertEnd()
end
function Money.OnDisable()
if not private.dataChanged then
-- nothing changed, so just keep the previous saved values
return
end
TSM.db.realm.internalData.csvExpense = private.SaveData("expense")
TSM.db.realm.internalData.csvIncome = private.SaveData("income")
end
function Money.InsertMoneyTransferExpense(amount, destination)
private.InsertRecord("expense", "Money Transfer", amount, destination, time())
end
function Money.InsertPostageExpense(amount, destination)
private.InsertRecord("expense", "Postage", amount, destination, time())
end
function Money.InsertRepairBillExpense(amount)
private.InsertRecord("expense", "Repair Bill", amount, "Merchant", time())
end
function Money.InsertMoneyTransferIncome(amount, source, timestamp)
private.InsertRecord("income", "Money Transfer", amount, source, timestamp)
end
function Money.InsertGarrisonIncome(amount)
private.InsertRecord("income", "Garrison", amount, "Mission", time())
end
function Money.CreateQuery()
return private.db:NewQuery()
end
function Money.CharacterIterator(recordType)
return private.db:NewQuery()
:Equal("recordType", recordType)
:Distinct("player")
:Select("player")
:IteratorAndRelease()
end
function Money.RemoveOldData(days)
private.dataChanged = true
local query = private.db:NewQuery()
:LessThan("time", time() - days * SECONDS_PER_DAY)
local numRecords = 0
private.db:SetQueryUpdatesPaused(true)
for _, row in query:Iterator() do
private.db:DeleteRow(row)
numRecords = numRecords + 1
end
query:Release()
private.db:SetQueryUpdatesPaused(false)
return numRecords
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LoadData(recordType, csvRecords)
local decodeContext = CSV.DecodeStart(csvRecords, CSV_KEYS)
if not decodeContext then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
return
end
for type, amount, otherPlayer, player, timestamp in CSV.DecodeIterator(decodeContext) do
amount = tonumber(amount)
timestamp = tonumber(timestamp)
if amount and timestamp then
local newTimestamp = floor(timestamp)
if newTimestamp ~= timestamp then
-- make sure all timestamps are stored as integers
timestamp = newTimestamp
private.dataChanged = true
end
private.db:BulkInsertNewRowFast6(recordType, type, amount, otherPlayer, player, timestamp)
else
private.dataChanged = true
end
end
if not CSV.DecodeEnd(decodeContext) then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
end
end
function private.SaveData(recordType)
local query = private.db:NewQuery()
:Equal("recordType", recordType)
local encodeContext = CSV.EncodeStart(CSV_KEYS)
for _, row in query:Iterator() do
CSV.EncodeAddRowData(encodeContext, row)
end
query:Release()
return CSV.EncodeEnd(encodeContext)
end
function private.InsertRecord(recordType, type, amount, otherPlayer, timestamp)
private.dataChanged = true
assert(type and amount and amount > 0 and otherPlayer and timestamp)
timestamp = floor(timestamp)
local matchingRow = private.db:NewQuery()
:Equal("recordType", recordType)
:Equal("type", type)
:Equal("otherPlayer", otherPlayer)
:Equal("player", UnitName("player"))
:GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD)
:LessThan("time", timestamp + COMBINE_TIME_THRESHOLD)
:GetFirstResultAndRelease()
if matchingRow then
matchingRow:SetField("amount", matchingRow:GetField("amount") + amount)
matchingRow:Update()
matchingRow:Release()
else
private.db:NewRow()
:SetField("recordType", recordType)
:SetField("type", type)
:SetField("amount", amount)
:SetField("otherPlayer", otherPlayer)
:SetField("player", UnitName("player"))
:SetField("time", timestamp)
:Create()
end
end

View File

@@ -0,0 +1,287 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AccountingSync = TSM.Accounting:NewPackage("Sync")
local L = TSM.Include("Locale").GetTable()
local Delay = TSM.Include("Util.Delay")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local Theme = TSM.Include("Util.Theme")
local Sync = TSM.Include("Service.Sync")
local private = {
accountLookup = {},
accountStatus = {},
pendingChunks = {},
dataTemp = {},
}
local CHANGE_NOTIFICATION_DELAY = 5
local RETRY_DELAY = 5
-- ============================================================================
-- Module Functions
-- ============================================================================
function AccountingSync.OnInitialize()
Sync.RegisterConnectionChangedCallback(private.ConnectionChangedHandler)
Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_HASH", private.RPCGetPlayerHash)
Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_CHUNKS", private.RPCGetPlayerChunks)
Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_DATA", private.RPCGetData)
Sync.RegisterRPC("ACCOUNTING_CHANGE_NOTIFICATION", private.RPCChangeNotification)
end
function AccountingSync.GetStatus(account)
local status = private.accountStatus[account]
if not status then
return Theme.GetFeedbackColor("RED"):ColorText(L["Not Connected"])
elseif status == "GET_PLAYER_HASH" or status == "GET_PLAYER_CHUNKS" or status == "GET_PLAYER_DATA" or status == "RETRY" then
return Theme.GetFeedbackColor("YELLOW"):ColorText(L["Updating"])
elseif status == "SYNCED" then
return Theme.GetFeedbackColor("GREEN"):ColorText(L["Up to date"])
else
error("Invalid status: "..tostring(status))
end
end
function AccountingSync.OnTransactionsChanged()
Delay.AfterTime("ACCOUNTING_SYNC_CHANGE", CHANGE_NOTIFICATION_DELAY, private.NotifyChange)
end
-- ============================================================================
-- RPC Functions and Result Handlers
-- ============================================================================
function private.GetPlayerHash(player)
local account = private.accountLookup[player]
private.accountStatus[account] = "GET_PLAYER_HASH"
TSM.Accounting.Transactions.PrepareSyncHashes(player)
Sync.CallRPC("ACCOUNTING_GET_PLAYER_HASH", player, private.RPCGetPlayerHashResultHandler)
end
function private.RPCGetPlayerHash()
local player = UnitName("player")
return player, TSM.Accounting.Transactions.GetSyncHash(player)
end
function private.RPCGetPlayerHashResultHandler(player, hash)
local account = player and private.accountLookup[player]
if not account then
-- request timed out, so try again
Log.Warn("Getting player hash timed out")
private.QueueRetriesByStatus("GET_PLAYER_HASH")
return
elseif not hash then
-- the hash isn't ready yet, so try again
Log.Warn("Sync player hash not ready yet")
private.QueueRetryByPlayer(player)
return
end
if private.accountStatus[account] == "RETRY" then
-- There is a race condition where if we tried to issue GET_PLAYER_HASH for two players and one times out,
-- we would also queue a retry for the other one, so handle that here.
private.accountStatus[account] = "GET_PLAYER_HASH"
end
assert(private.accountStatus[account] == "GET_PLAYER_HASH")
local currentHash = TSM.Accounting.Transactions.GetSyncHash(player)
if not currentHash then
-- don't have our hash yet, so try again
Log.Warn("Current player hash not ready yet")
private.QueueRetryByPlayer(player)
return
end
if hash ~= currentHash then
Log.Info("Need updated transactions data from %s (%s, %s)", player, hash, currentHash)
private.GetPlayerChunks(player)
else
Log.Info("Transactions data for %s already up to date (%s, %s)", player, hash, currentHash)
private.accountStatus[account] = "SYNCED"
end
end
function private.GetPlayerChunks(player)
local account = private.accountLookup[player]
private.accountStatus[account] = "GET_PLAYER_CHUNKS"
Sync.CallRPC("ACCOUNTING_GET_PLAYER_CHUNKS", player, private.RPCGetPlayerChunksResultHandler)
end
function private.RPCGetPlayerChunks()
local player = UnitName("player")
return player, TSM.Accounting.Transactions.GetSyncHashByDay(player)
end
function private.RPCGetPlayerChunksResultHandler(player, chunks)
local account = player and private.accountLookup[player]
if not account then
-- request timed out, so try again from the start
Log.Warn("Getting chunks timed out")
private.QueueRetriesByStatus("GET_PLAYER_CHUNKS")
return
elseif not chunks then
-- the hashes have been invalidated, so try again from the start
Log.Warn("Sync player chunks not ready yet")
private.QueueRetryByPlayer(player)
return
end
assert(private.accountStatus[account] == "GET_PLAYER_CHUNKS")
local currentChunks = TSM.Accounting.Transactions.GetSyncHashByDay(player)
if not currentChunks then
-- our hashes have been invalidated, so try again from the start
Log.Warn("Local hashes are invalid")
private.QueueRetryByPlayer(player)
return
end
for day in pairs(currentChunks) do
if not chunks[day] then
-- remove day which no longer exists
TSM.Accounting.Transactions.RemovePlayerDay(player, day)
end
end
-- queue up all the pending chunks
private.pendingChunks[player] = private.pendingChunks[player] or TempTable.Acquire()
wipe(private.pendingChunks[player])
for day, hash in pairs(chunks) do
if currentChunks[day] ~= hash then
tinsert(private.pendingChunks[player], day)
end
end
local requestDay = private.GetNextPendingChunk(player)
if requestDay then
Log.Info("Requesting transactions data (%s, %s, %s, %s)", player, requestDay, tostring(currentChunks[requestDay]), chunks[requestDay])
private.GetPlayerData(player, requestDay)
else
Log.Info("All chunks are up to date (%s)", player)
private.accountStatus[account] = "SYNCED"
end
end
function private.GetPlayerData(player, requestDay)
local account = private.accountLookup[player]
private.accountStatus[account] = "GET_PLAYER_DATA"
Sync.CallRPC("ACCOUNTING_GET_PLAYER_DATA", player, private.RPCGetDataResultHandler, requestDay)
end
function private.RPCGetData(day)
local player = UnitName("player")
wipe(private.dataTemp)
TSM.Accounting.Transactions.GetSyncData(player, day, private.dataTemp)
return player, day, private.dataTemp
end
function private.RPCGetDataResultHandler(player, day, data)
local account = player and private.accountLookup[player]
if not account then
-- request timed out, so try again from the start
Log.Warn("Getting transactions data timed out")
private.QueueRetriesByStatus("GET_PLAYER_DATA")
return
elseif #data % 9 ~= 0 then
-- invalid data - just silently give up
Log.Warn("Got invalid transactions data")
return
end
assert(private.accountStatus[account] == "GET_PLAYER_DATA")
Log.Info("Received transactions data (%s, %s, %s)", player, day, #data)
TSM.Accounting.Transactions.HandleSyncedData(player, day, data)
local requestDay = private.GetNextPendingChunk(player)
if requestDay then
-- request the next chunk
Log.Info("Requesting transactions data (%s, %s)", player, requestDay)
private.GetPlayerData(player, requestDay)
else
-- request chunks again to check for other chunks we need to sync
private.GetPlayerChunks(player)
end
end
function private.RPCChangeNotification(player)
if private.accountStatus[private.accountLookup[player]] == "SYNCED" then
-- request the player hash
Log.Info("Got change notification - requesting player hash")
private.GetPlayerHash(player)
else
Log.Info("Got change notification - dropping (%s)", tostring(private.accountStatus[private.accountLookup[player]]))
end
end
function private.RPCChangeNotificationResultHandler()
-- nop
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ConnectionChangedHandler(account, player, connected)
if connected then
private.accountLookup[player] = account
private.GetPlayerHash(player)
else
private.accountLookup[player] = nil
private.accountStatus[account] = nil
if private.pendingChunks[player] then
TempTable.Release(private.pendingChunks[player])
private.pendingChunks[player] = nil
end
end
end
function private.GetNextPendingChunk(player)
if not private.pendingChunks[player] then
return nil
end
local result = tremove(private.pendingChunks[player])
if not result then
TempTable.Release(private.pendingChunks[player])
private.pendingChunks[player] = nil
end
return result
end
function private.QueueRetriesByStatus(statusFilter)
for player, account in pairs(private.accountLookup) do
if private.accountStatus[account] == statusFilter then
private.QueueRetryByPlayer(player)
end
end
end
function private.QueueRetryByPlayer(player)
local account = private.accountLookup[player]
Log.Info("Retrying (%s, %s, %s)", player, account, private.accountStatus[account])
private.accountStatus[account] = "RETRY"
Delay.AfterTime(RETRY_DELAY, private.RetryGetPlayerHashRPC)
end
function private.RetryGetPlayerHashRPC()
for player, account in pairs(private.accountLookup) do
if private.accountStatus[account] == "RETRY" then
private.GetPlayerHash(player)
end
end
end
function private.NotifyChange()
for player, account in pairs(private.accountLookup) do
if private.accountStatus[account] == "SYNCED" then
-- notify the other account that our data has changed and request the other account's latest hash ourselves
private.GetPlayerHash(player)
Sync.CallRPC("ACCOUNTING_CHANGE_NOTIFICATION", player, private.RPCChangeNotificationResultHandler, UnitName("player"))
end
end
end

View File

@@ -0,0 +1,165 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Trade = TSM.Accounting:NewPackage("Trade")
local L = TSM.Include("Locale").GetTable()
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local Money = TSM.Include("Util.Money")
local ItemString = TSM.Include("Util.ItemString")
local Wow = TSM.Include("Util.Wow")
local ItemInfo = TSM.Include("Service.ItemInfo")
local private = {
tradeInfo = nil,
popupContext = nil,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Trade.OnInitialize()
Event.Register("TRADE_ACCEPT_UPDATE", private.OnAcceptUpdate)
Event.Register("UI_INFO_MESSAGE", private.OnChatMsg)
end
-- ============================================================================
-- Trade Functions
-- ============================================================================
function private.OnAcceptUpdate(_, player, target)
if (player == 1 or target == 1) and not (GetTradePlayerItemLink(7) or GetTradeTargetItemLink(7)) then
-- update tradeInfo
private.tradeInfo = { player = {}, target = {} }
private.tradeInfo.player.money = tonumber(GetPlayerTradeMoney())
private.tradeInfo.target.money = tonumber(GetTargetTradeMoney())
private.tradeInfo.target.name = UnitName("NPC")
for i = 1, 6 do
local targetLink = GetTradeTargetItemLink(i)
local _, _, targetCount = GetTradeTargetItemInfo(i)
if targetLink then
tinsert(private.tradeInfo.target, { itemString = ItemString.Get(targetLink), count = targetCount })
end
local playerLink = GetTradePlayerItemLink(i)
local _, _, playerCount = GetTradePlayerItemInfo(i)
if playerLink then
tinsert(private.tradeInfo.player, { itemString = ItemString.Get(playerLink), count = playerCount })
end
end
else
private.tradeInfo = nil
end
end
function private.OnChatMsg(_, msg)
if not TSM.db.global.accountingOptions.trackTrades then
return
end
if msg == LE_GAME_ERR_TRADE_COMPLETE and private.tradeInfo then
-- trade went through
local tradeType, itemString, count, money = nil, nil, nil, nil
if private.tradeInfo.player.money > 0 and #private.tradeInfo.player == 0 and private.tradeInfo.target.money == 0 and #private.tradeInfo.target > 0 then
-- player bought items
for i = 1, #private.tradeInfo.target do
local data = private.tradeInfo.target[i]
if not itemString then
itemString = data.itemString
count = data.count
elseif itemString == data.itemString then
count = count + data.count
else
return
end
end
tradeType = "buy"
money = private.tradeInfo.player.money
elseif private.tradeInfo.player.money == 0 and #private.tradeInfo.player > 0 and private.tradeInfo.target.money > 0 and #private.tradeInfo.target == 0 then
-- player sold items
for i = 1, #private.tradeInfo.player do
local data = private.tradeInfo.player[i]
if not itemString then
itemString = data.itemString
count = data.count
elseif itemString == data.itemString then
count = count + data.count
else
return
end
end
tradeType = "sale"
money = private.tradeInfo.target.money
end
if not tradeType or not itemString or not count then
return
end
local insertInfo = TempTable.Acquire()
insertInfo.type = tradeType
insertInfo.itemString = itemString
insertInfo.price = money / count
insertInfo.count = count
insertInfo.name = private.tradeInfo.target.name
local gotText, gaveText = nil, nil
if tradeType == "buy" then
gotText = format("%sx%d", ItemInfo.GetLink(itemString), count)
gaveText = Money.ToString(money)
elseif tradeType == "sale" then
gaveText = format("%sx%d", ItemInfo.GetLink(itemString), count)
gotText = Money.ToString(money)
else
error("Invalid tradeType: "..tostring(tradeType))
end
if TSM.db.global.accountingOptions.autoTrackTrades then
private.DoInsert(insertInfo)
TempTable.Release(insertInfo)
else
if private.popupContext then
-- popup already visible so ignore this
TempTable.Release(insertInfo)
return
end
private.popupContext = insertInfo
if not StaticPopupDialogs["TSMAccountingOnTrade"] then
StaticPopupDialogs["TSMAccountingOnTrade"] = {
button1 = YES,
button2 = NO,
timeout = 0,
whileDead = true,
hideOnEscape = true,
OnAccept = function()
private.DoInsert(private.popupContext)
TempTable.Release(private.popupContext)
private.popupContext = nil
end,
OnCancel = function()
TempTable.Release(private.popupContext)
private.popupContext = nil
end,
}
end
StaticPopupDialogs["TSMAccountingOnTrade"].text = format(L["TSM detected that you just traded %s to %s in return for %s. Would you like Accounting to store a record of this trade?"], gaveText, insertInfo.name, gotText)
Wow.ShowStaticPopupDialog("TSMAccountingOnTrade")
end
end
end
function private.DoInsert(info)
if info.type == "sale" then
TSM.Accounting.Transactions.InsertTradeSale(info.itemString, info.count, info.price, info.name)
elseif info.type == "buy" then
TSM.Accounting.Transactions.InsertTradeBuy(info.itemString, info.count, info.price, info.name)
else
error("Unknown type: "..tostring(info.type))
end
end

View File

@@ -0,0 +1,825 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Transactions = TSM.Accounting:NewPackage("Transactions")
local L = TSM.Include("Locale").GetTable()
local Database = TSM.Include("Util.Database")
local TempTable = TSM.Include("Util.TempTable")
local CSV = TSM.Include("Util.CSV")
local Math = TSM.Include("Util.Math")
local String = TSM.Include("Util.String")
local Log = TSM.Include("Util.Log")
local Table = TSM.Include("Util.Table")
local ItemString = TSM.Include("Util.ItemString")
local Theme = TSM.Include("Util.Theme")
local CustomPrice = TSM.Include("Service.CustomPrice")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Inventory = TSM.Include("Service.Inventory")
local Settings = TSM.Include("Service.Settings")
local Threading = TSM.Include("Service.Threading")
local private = {
db = nil,
dbSummary = nil,
dataChanged = false,
baseStatsQuery = nil,
statsQuery = nil,
baseStatsMinTimeQuery = nil,
statsMinTimeQuery = nil,
syncHashesThread = nil,
isSyncHashesThreadRunning = false,
syncHashDayCache = {},
syncHashDayCacheIsInvalid = {},
pendingSyncHashCharacters = {},
}
local OLD_CSV_KEYS = {
sale = { "itemString", "stackSize", "quantity", "price", "buyer", "player", "time", "source" },
buy = { "itemString", "stackSize", "quantity", "price", "seller", "player", "time", "source" },
}
local CSV_KEYS = { "itemString", "stackSize", "quantity", "price", "otherPlayer", "player", "time", "source" }
local COMBINE_TIME_THRESHOLD = 300 -- group transactions within 5 minutes together
local MAX_CSV_RECORDS = 55000 -- the max number of records we can store without WoW corrupting the SV file
local TRIMMED_CSV_RECORDS = 50000 -- how many records to trim to if we're over the limit (so we don't trim every time)
local SECONDS_PER_DAY = 24 * 60 * 60
local SYNC_FIELDS = { "type", "itemString", "stackSize", "quantity", "price", "otherPlayer", "time", "source", "saveTime" }
-- ============================================================================
-- Module Functions
-- ============================================================================
function Transactions.OnInitialize()
if TSM.db.realm.internalData.accountingTrimmed.sales then
Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of sale data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.sales))
TSM.db.realm.internalData.accountingTrimmed.sales = nil
end
if TSM.db.realm.internalData.accountingTrimmed.buys then
Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of purchase data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.buys))
TSM.db.realm.internalData.accountingTrimmed.buys = nil
end
private.db = Database.NewSchema("TRANSACTIONS_LOG")
:AddStringField("baseItemString")
:AddStringField("type")
:AddStringField("itemString")
:AddNumberField("stackSize")
:AddNumberField("quantity")
:AddNumberField("price")
:AddStringField("otherPlayer")
:AddStringField("player")
:AddNumberField("time")
:AddStringField("source")
:AddNumberField("saveTime")
:AddIndex("baseItemString")
:AddIndex("time")
:Commit()
private.db:BulkInsertStart()
private.LoadData("sale", TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales)
private.LoadData("buy", TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys)
private.db:BulkInsertEnd()
private.dbSummary = Database.NewSchema("TRANSACTIONS_SUMMARY")
:AddUniqueStringField("itemString")
:AddNumberField("sold")
:AddNumberField("avgSellPrice")
:AddNumberField("bought")
:AddNumberField("avgBuyPrice")
:AddNumberField("avgProfit")
:AddNumberField("totalProfit")
:AddNumberField("profitPct")
:Commit()
private.baseStatsQuery = private.db:NewQuery()
:Select("quantity", "price")
:Equal("type", Database.BoundQueryParam())
:Equal("baseItemString", Database.BoundQueryParam())
:NotEqual("source", "Vendor")
private.statsQuery = private.db:NewQuery()
:Select("quantity", "price")
:Equal("type", Database.BoundQueryParam())
:Equal("baseItemString", Database.BoundQueryParam())
:Equal("itemString", Database.BoundQueryParam())
:NotEqual("source", "Vendor")
private.baseStatsMinTimeQuery = private.db:NewQuery()
:Select("quantity", "price")
:Equal("type", Database.BoundQueryParam())
:Equal("baseItemString", Database.BoundQueryParam())
:GreaterThanOrEqual("time", Database.BoundQueryParam())
:NotEqual("source", "Vendor")
private.statsMinTimeQuery = private.db:NewQuery()
:Select("quantity", "price")
:Equal("type", Database.BoundQueryParam())
:Equal("baseItemString", Database.BoundQueryParam())
:Equal("itemString", Database.BoundQueryParam())
:GreaterThanOrEqual("time", Database.BoundQueryParam())
:NotEqual("source", "Vendor")
private.syncHashesThread = Threading.New("TRANSACTIONS_SYNC_HASHES", private.SyncHashesThread)
Inventory.RegisterCallback(private.InventoryCallback)
end
function Transactions.OnDisable()
if not private.dataChanged then
-- nothing changed, so just keep the previous saved values
return
end
TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales, TSM.db.realm.internalData.accountingTrimmed.sales = private.SaveData("sale")
TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys, TSM.db.realm.internalData.accountingTrimmed.buys = private.SaveData("buy")
end
function Transactions.InsertAuctionSale(itemString, stackSize, price, buyer, timestamp)
private.InsertRecord("sale", itemString, "Auction", stackSize, price, buyer, timestamp)
end
function Transactions.InsertAuctionBuy(itemString, stackSize, price, seller, timestamp)
private.InsertRecord("buy", itemString, "Auction", stackSize, price, seller, timestamp)
end
function Transactions.InsertCODSale(itemString, stackSize, price, buyer, timestamp)
private.InsertRecord("sale", itemString, "COD", stackSize, price, buyer, timestamp)
end
function Transactions.InsertCODBuy(itemString, stackSize, price, seller, timestamp)
private.InsertRecord("buy", itemString, "COD", stackSize, price, seller, timestamp)
end
function Transactions.InsertTradeSale(itemString, stackSize, price, buyer)
private.InsertRecord("sale", itemString, "Trade", stackSize, price, buyer, time())
end
function Transactions.InsertTradeBuy(itemString, stackSize, price, seller)
private.InsertRecord("buy", itemString, "Trade", stackSize, price, seller, time())
end
function Transactions.InsertVendorSale(itemString, stackSize, price)
private.InsertRecord("sale", itemString, "Vendor", stackSize, price, "Merchant", time())
end
function Transactions.InsertVendorBuy(itemString, stackSize, price)
private.InsertRecord("buy", itemString, "Vendor", stackSize, price, "Merchant", time())
end
function Transactions.CreateQuery()
return private.db:NewQuery()
end
function Transactions.RemoveOldData(days)
private.dataChanged = true
private.db:SetQueryUpdatesPaused(true)
local numRecords = private.db:NewQuery()
:LessThan("time", time() - days * SECONDS_PER_DAY)
:DeleteAndRelease()
private.db:SetQueryUpdatesPaused(false)
private.OnItemRecordsChanged("sale")
private.OnItemRecordsChanged("buy")
TSM.Accounting.Sync.OnTransactionsChanged()
return numRecords
end
function Transactions.GetSaleStats(itemString, minTime)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = nil
if minTime then
if isBaseItemString then
query = private.baseStatsMinTimeQuery:BindParams("sale", baseItemString, minTime)
else
query = private.statsMinTimeQuery:BindParams("sale", baseItemString, itemString, minTime)
end
else
if isBaseItemString then
query = private.baseStatsQuery:BindParams("sale", baseItemString)
else
query = private.statsQuery:BindParams("sale", baseItemString, itemString)
end
end
query:ResetOrderBy()
local totalPrice = query:SumOfProduct("quantity", "price")
local totalNum = query:Sum("quantity")
if not totalNum or totalNum == 0 then
return
end
return totalPrice, totalNum
end
function Transactions.GetBuyStats(itemString, isSmart)
local baseItemString = ItemString.GetBaseFast(itemString)
local isBaseItemString = itemString == baseItemString
local query = nil
if isBaseItemString then
query = private.baseStatsQuery:BindParams("buy", baseItemString)
else
query = private.statsQuery:BindParams("buy", baseItemString, itemString)
end
query:ResetOrderBy()
if isSmart then
local totalQuantity = CustomPrice.GetItemPrice(itemString, "NumInventory") or 0
if totalQuantity == 0 then
return nil, nil
end
query:OrderBy("time", false)
local remainingSmartQuantity = totalQuantity
local priceSum, quantitySum = 0, 0
for _, quantity, price in query:Iterator() do
if remainingSmartQuantity > 0 then
quantity = min(remainingSmartQuantity, quantity)
remainingSmartQuantity = remainingSmartQuantity - quantity
priceSum = priceSum + price * quantity
quantitySum = quantitySum + quantity
end
end
if priceSum == 0 then
return nil, nil
end
return priceSum, quantitySum
else
local quantitySum = query:Sum("quantity")
if not quantitySum then
return nil, nil
end
local priceSum = query:SumOfProduct("quantity", "price")
if priceSum == 0 then
return nil, nil
end
return priceSum, quantitySum
end
end
function Transactions.GetMaxSalePrice(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price")
:Equal("type", "sale")
:NotEqual("source", "Vendor")
:OrderBy("price", false)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetMaxBuyPrice(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price")
:Equal("type", "buy")
:NotEqual("source", "Vendor")
:OrderBy("price", false)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetMinSalePrice(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price")
:Equal("type", "sale")
:NotEqual("source", "Vendor")
:OrderBy("price", true)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetMinBuyPrice(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price")
:Equal("type", "buy")
:NotEqual("source", "Vendor")
:OrderBy("price", true)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetAverageSalePrice(itemString)
local totalPrice, totalNum = Transactions.GetSaleStats(itemString)
if not totalPrice or totalPrice == 0 then
return
end
return Math.Round(totalPrice / totalNum), totalNum
end
function Transactions.GetAverageBuyPrice(itemString, isSmart)
local totalPrice, totalNum = Transactions.GetBuyStats(itemString, isSmart)
return totalPrice and Math.Round(totalPrice / totalNum) or nil
end
function Transactions.GetLastSaleTime(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("time")
:Equal("type", "sale")
:NotEqual("source", "Vendor")
:OrderBy("time", false)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetLastBuyTime(itemString)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery():Select("time")
:Equal("type", "buy")
:NotEqual("source", "Vendor")
:OrderBy("time", false)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
return query:GetFirstResultAndRelease()
end
function Transactions.GetQuantity(itemString, timeFilter, typeFilter)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Equal("type", typeFilter)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
if timeFilter then
query:GreaterThan("time", time() - timeFilter)
end
local sum = query:Sum("quantity") or 0
query:Release()
return sum
end
function Transactions.GetAveragePrice(itemString, timeFilter, typeFilter)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price", "quantity")
:Equal("type", typeFilter)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
if timeFilter then
query:GreaterThan("time", time() - timeFilter)
end
local avgPrice = 0
local totalQuantity = 0
for _, price, quantity in query:IteratorAndRelease() do
avgPrice = avgPrice + price * quantity
totalQuantity = totalQuantity + quantity
end
return Math.Round(avgPrice / totalQuantity)
end
function Transactions.GetTotalPrice(itemString, timeFilter, typeFilter)
local baseItemString = ItemString.GetBase(itemString)
local isBaseItemString = itemString == baseItemString
local query = private.db:NewQuery()
:Select("price", "quantity")
:Equal("type", typeFilter)
if isBaseItemString then
query:Equal("baseItemString", itemString)
else
query:Equal("baseItemString", baseItemString)
:Equal("itemString", itemString)
end
if timeFilter then
query:GreaterThan("time", time() - timeFilter)
end
local sumPrice = query:SumOfProduct("price", "quantity") or 0
query:Release()
return sumPrice
end
function Transactions.CreateSummaryQuery()
return private.dbSummary:NewQuery()
end
function Transactions.UpdateSummaryData(groupFilter, searchFilter, typeFilter, characterFilter, minTime)
local totalSold = TempTable.Acquire()
local totalSellPrice = TempTable.Acquire()
local totalBought = TempTable.Acquire()
local totalBoughtPrice = TempTable.Acquire()
local items = private.db:NewQuery()
:Select("itemString", "price", "quantity", "type")
:LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString")
:InnerJoin(ItemInfo.GetDBForJoin(), "itemString")
if groupFilter then
items:InTable("groupPath", groupFilter)
end
if searchFilter then
items:Matches("name", String.Escape(searchFilter))
end
if typeFilter then
items:InTable("source", typeFilter)
end
if characterFilter then
items:InTable("player", characterFilter)
end
if minTime then
items:GreaterThan("time", minTime)
end
for _, itemString, price, quantity, recordType in items:IteratorAndRelease() do
if not totalSold[itemString] then
totalSold[itemString] = 0
totalSellPrice[itemString] = 0
totalBought[itemString] = 0
totalBoughtPrice[itemString] = 0
end
if recordType == "sale" then
totalSold[itemString] = totalSold[itemString] + quantity
totalSellPrice[itemString] = totalSellPrice[itemString] + price * quantity
elseif recordType == "buy" then
totalBought[itemString] = totalBought[itemString] + quantity
totalBoughtPrice[itemString] = totalBoughtPrice[itemString] + price * quantity
else
error("Invalid recordType: "..tostring(recordType))
end
end
private.dbSummary:TruncateAndBulkInsertStart()
for itemString, sold in pairs(totalSold) do
if sold > 0 and totalBought[itemString] > 0 then
local totalAvgSellPrice = totalSellPrice[itemString] / totalSold[itemString]
local totalAvgBuyPrice = totalBoughtPrice[itemString] / totalBought[itemString]
local profit = totalAvgSellPrice - totalAvgBuyPrice
local totalProfit = profit * min(totalSold[itemString], totalBought[itemString])
local profitPct = Math.Round(profit * 100 / totalAvgBuyPrice)
private.dbSummary:BulkInsertNewRow(itemString, sold, totalAvgSellPrice, totalBought[itemString], totalAvgBuyPrice, profit, totalProfit, profitPct)
end
end
private.dbSummary:BulkInsertEnd()
TempTable.Release(totalSold)
TempTable.Release(totalSellPrice)
TempTable.Release(totalBought)
TempTable.Release(totalBoughtPrice)
end
function Transactions.GetCharacters(characters)
private.db:NewQuery()
:Distinct("player")
:Select("player")
:AsTable(characters)
:Release()
return characters
end
function Transactions.CanDeleteByUUID(uuid)
return Settings.IsCurrentAccountOwner(private.db:GetRowFieldByUUID(uuid, "player"))
end
function Transactions.RemoveRowByUUID(uuid)
local recordType = private.db:GetRowFieldByUUID(uuid, "type")
local itemString = private.db:GetRowFieldByUUID(uuid, "itemString")
local player = private.db:GetRowFieldByUUID(uuid, "player")
private.db:DeleteRowByUUID(uuid)
if private.syncHashDayCache[player] then
private.syncHashDayCacheIsInvalid[player] = true
end
private.dataChanged = true
private.OnItemRecordsChanged(recordType, itemString)
TSM.Accounting.Sync.OnTransactionsChanged()
end
function Transactions.PrepareSyncHashes(player)
tinsert(private.pendingSyncHashCharacters, player)
if not private.isSyncHashesThreadRunning then
private.isSyncHashesThreadRunning = true
Threading.Start(private.syncHashesThread)
end
end
function Transactions.GetSyncHash(player)
local hashesByDay = Transactions.GetSyncHashByDay(player)
if not hashesByDay then
return
end
return Math.CalculateHash(hashesByDay)
end
function Transactions.GetSyncHashByDay(player)
if not private.syncHashDayCache[player] or private.syncHashDayCacheIsInvalid[player] then
return
end
return private.syncHashDayCache[player]
end
function Transactions.GetSyncData(player, day, result)
local query = private.db:NewQuery()
:Equal("player", player)
:GreaterThanOrEqual("time", day * SECONDS_PER_DAY)
:LessThan("time", (day + 1) * SECONDS_PER_DAY)
for _, row in query:Iterator() do
Table.Append(result, row:GetFields(unpack(SYNC_FIELDS)))
end
query:Release()
end
function Transactions.RemovePlayerDay(player, day)
private.dataChanged = true
private.db:SetQueryUpdatesPaused(true)
local query = private.db:NewQuery()
:Equal("player", player)
:GreaterThanOrEqual("time", day * SECONDS_PER_DAY)
:LessThan("time", (day + 1) * SECONDS_PER_DAY)
for _, uuid in query:UUIDIterator() do
private.db:DeleteRowByUUID(uuid)
end
query:Release()
if private.syncHashDayCache[player] then
private.syncHashDayCacheIsInvalid[player] = true
end
private.db:SetQueryUpdatesPaused(false)
private.OnItemRecordsChanged("sale")
private.OnItemRecordsChanged("buy")
end
function Transactions.HandleSyncedData(player, day, data)
assert(#data % 9 == 0)
private.dataChanged = true
private.db:SetQueryUpdatesPaused(true)
-- remove any prior data for the day
local query = private.db:NewQuery()
:Equal("player", player)
:GreaterThanOrEqual("time", day * SECONDS_PER_DAY)
:LessThan("time", (day + 1) * SECONDS_PER_DAY)
for _, uuid in query:UUIDIterator() do
private.db:DeleteRowByUUID(uuid)
end
query:Release()
if private.syncHashDayCache[player] then
private.syncHashDayCacheIsInvalid[player] = true
end
-- insert the new data
private.db:BulkInsertStart()
for i = 1, #data, 9 do
private.BulkInsertNewRowHelper(player, unpack(data, i, i + 8))
end
private.db:BulkInsertEnd()
private.db:SetQueryUpdatesPaused(false)
private.OnItemRecordsChanged("sale")
private.OnItemRecordsChanged("buy")
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LoadData(recordType, csvRecords, csvSaveTimes)
local saveTimes = String.SafeSplit(csvSaveTimes, ",")
local decodeContext = CSV.DecodeStart(csvRecords, OLD_CSV_KEYS[recordType]) or CSV.DecodeStart(csvRecords, CSV_KEYS)
if not decodeContext then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
return
end
local saveTimeIndex = 1
for itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source in CSV.DecodeIterator(decodeContext) do
local saveTime = 0
if saveTimes and source == "Auction" then
saveTime = tonumber(saveTimes[saveTimeIndex])
saveTimeIndex = saveTimeIndex + 1
end
private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime)
end
if not CSV.DecodeEnd(decodeContext) then
Log.Err("Failed to decode %s records", recordType)
private.dataChanged = true
end
private.OnItemRecordsChanged(recordType)
end
function private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime)
itemString = ItemString.Get(itemString)
local baseItemString = ItemString.GetBaseFast(itemString)
stackSize = tonumber(stackSize)
quantity = tonumber(quantity)
price = tonumber(price)
timestamp = tonumber(timestamp)
if itemString and stackSize and quantity and price and otherPlayer and player and timestamp and source then
local newTimestamp = floor(timestamp)
if newTimestamp ~= timestamp then
-- make sure all timestamps are stored as integers
private.dataChanged = true
timestamp = newTimestamp
end
local newPrice = floor(price)
if newPrice ~= price then
-- make sure all prices are stored as integers
private.dataChanged = true
price = newPrice
end
private.db:BulkInsertNewRowFast11(baseItemString, recordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime)
else
private.dataChanged = true
end
end
function private.SaveData(recordType)
local numRecords = private.db:NewQuery()
:Equal("type", recordType)
:CountAndRelease()
if numRecords > MAX_CSV_RECORDS then
local query = private.db:NewQuery()
:Equal("type", recordType)
:OrderBy("time", false)
local count = 0
local saveTimes = {}
local shouldTrim = query:Count() > MAX_CSV_RECORDS
local lastTime = nil
local encodeContext = CSV.EncodeStart(CSV_KEYS)
for _, row in query:Iterator() do
if not shouldTrim or count <= TRIMMED_CSV_RECORDS then
-- add the save time
local saveTime = row:GetField("saveTime")
saveTime = saveTime ~= 0 and saveTime or time()
if row:GetField("source") == "Auction" then
tinsert(saveTimes, saveTime)
end
-- update the time we're trimming to
if shouldTrim then
lastTime = row:GetField("time")
end
-- add to our list of CSV lines
CSV.EncodeAddRowData(encodeContext, row)
end
count = count + 1
end
query:Release()
return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), lastTime
else
local saveTimes = {}
local encodeContext = CSV.EncodeStart(CSV_KEYS)
for _, _, rowRecordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime in private.db:RawIterator() do
if rowRecordType == recordType then
-- add the save time
if source == "Auction" then
tinsert(saveTimes, saveTime ~= 0 and saveTime or time())
end
-- add to our list of CSV lines
CSV.EncodeAddRowDataRaw(encodeContext, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source)
end
end
return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), nil
end
end
function private.InsertRecord(recordType, itemString, source, stackSize, price, otherPlayer, timestamp)
private.dataChanged = true
assert(itemString and source and stackSize and price and otherPlayer and timestamp)
timestamp = floor(timestamp)
local baseItemString = ItemString.GetBase(itemString)
local player = UnitName("player")
local matchingRow = private.db:NewQuery()
:Equal("type", recordType)
:Equal("itemString", itemString)
:Equal("baseItemString", baseItemString)
:Equal("stackSize", stackSize)
:Equal("source", source)
:Equal("price", price)
:Equal("player", player)
:Equal("otherPlayer", otherPlayer)
:GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD)
:LessThan("time", timestamp + COMBINE_TIME_THRESHOLD)
:Equal("saveTime", 0)
:GetFirstResultAndRelease()
if matchingRow then
matchingRow:SetField("quantity", matchingRow:GetField("quantity") + stackSize)
matchingRow:Update()
matchingRow:Release()
else
private.db:NewRow()
:SetField("type", recordType)
:SetField("itemString", itemString)
:SetField("baseItemString", baseItemString)
:SetField("stackSize", stackSize)
:SetField("quantity", stackSize)
:SetField("price", price)
:SetField("otherPlayer", otherPlayer)
:SetField("player", player)
:SetField("time", timestamp)
:SetField("source", source)
:SetField("saveTime", 0)
:Create()
end
if private.syncHashDayCache[player] then
private.syncHashDayCacheIsInvalid[player] = true
end
private.OnItemRecordsChanged(recordType, itemString)
TSM.Accounting.Sync.OnTransactionsChanged()
end
function private.OnItemRecordsChanged(recordType, itemString)
if recordType == "sale" then
CustomPrice.OnSourceChange("AvgSell", itemString)
CustomPrice.OnSourceChange("MaxSell", itemString)
CustomPrice.OnSourceChange("MinSell", itemString)
CustomPrice.OnSourceChange("NumExpires", itemString)
elseif recordType == "buy" then
CustomPrice.OnSourceChange("AvgBuy", itemString)
CustomPrice.OnSourceChange("MaxBuy", itemString)
CustomPrice.OnSourceChange("MinBuy", itemString)
else
error("Invalid recordType: "..tostring(recordType))
end
end
function private.SyncHashesThread(otherPlayer)
private.CalculateSyncHashesThreaded(UnitName("player"))
while #private.pendingSyncHashCharacters > 0 do
local player = tremove(private.pendingSyncHashCharacters, 1)
private.CalculateSyncHashesThreaded(player)
end
private.isSyncHashesThreadRunning = false
end
function private.CalculateSyncHashesThreaded(player)
if private.syncHashDayCache[player] and not private.syncHashDayCacheIsInvalid[player] then
Log.Info("Sync hashes for player (%s) are already up to date", player)
return
end
private.syncHashDayCache[player] = private.syncHashDayCache[player] or {}
local result = private.syncHashDayCache[player]
wipe(result)
private.syncHashDayCacheIsInvalid[player] = true
while true do
local aborted = false
local query = private.db:NewQuery()
:Equal("player", player)
:OrderBy("time", false)
:OrderBy("itemString", true)
Threading.GuardDatabaseQuery(query)
for _, row in query:Iterator(true) do
local rowHash = row:CalculateHash(SYNC_FIELDS)
local day = floor(row:GetField("time") / SECONDS_PER_DAY)
result[day] = Math.CalculateHash(rowHash, result[day])
Threading.Yield()
if query:IsIteratorAborted() then
Log.Warn("Iterator was aborted for player (%s), will retry", player)
aborted = true
end
end
Threading.UnguardDatabaseQuery(query)
query:Release()
if not aborted then
break
end
end
private.syncHashDayCacheIsInvalid[player] = nil
Log.Info("Updated sync hashes for player (%s)", player)
end
function private.InventoryCallback()
CustomPrice.OnSourceChange("SmartAvgBuy")
end