vrtc / flowerpicker (public) (License: GPLv3) (since 2019-11-07) (hash sha1)
Flowerpicker is a GUI extension, that is an add-on, for World of Warcraft game client. It is under development and not yet ready for usage by players.

/Flowerpicker.lua (cce23a4c95fafca3241715e597c1204f23968422) (37315 bytes) (mode 100644) (type blob)

--[[--
flowerpicker.
"Flowerpicker" is a graphical user interface extension, that is an add-on,
for legacy World of Warcraft game client. The purpose of Flowerpicker add-on
is to detect player operations on game items and game money.
@script flowerpicker
]]

--[[--
flowerpicker.util.
Shared snippets, specifically for date manipulation and value validation.
@section util
]]

--[[--
Compute the difference in seconds between local time and UTC.
Modified to rely on WoW wrappers of Lua os.* functions.
```
  timezone = get_timezone()
```
@author Eric Feliksik
@see http://lua-users.org/wiki/TimeZone
@function get_timezone
@return number timezone number
]]
local function get_timezone()
  local now = time()
  return difftime(now, time(date("!*t", now)))
end

--[[--
Return a timezone string in ISO 8601:2000 standard form (+hhmm or -hhmm).
Modified to rely on WoW wrappers of Lua os.* functions.
```
  tzoffset = get_tzoffset(timezone)
```
@author Eric Feliksik
@see http://lua-users.org/wiki/TimeZone
@function get_tzoffset
@param timezone number timezone number returned by `get_timezone`
@return string timezone string
]]
local function get_tzoffset(timezone)
  local h, m = math.modf(timezone / 3600)
  return string.format("%+.4d", 100 * h + 60 * m)
end

--[[ debugging
for _, tz in ipairs(arg) do
  if tz == '-' then
    tz = timezone
  else
    tz = 0 + tz
  end
  print(tz, get_tzoffset(tz))
end
--]]

--[[--
Return the timezone offset in seconds, as it was on the time given by ts.
@author Eric Feliksik
@see http://lua-users.org/wiki/TimeZone
@function get_timezone_offset
@param ts
@return number
]]
local function get_timezone_offset(ts)
  local utcdate   = date("!*t", ts)
  local localdate = date("*t", ts)
  localdate.isdst = false --[[ this is the trick ]]--
  return difftime(time(localdate), time(utcdate))
end
--[[ Mute luacheck warning of unusued function. ]]--
assert (get_timezone_offset ~= nil)

--[[--
Maps given value to a short integer if possible.
Given a number, then returns a short integer in [-32768, 32767].
Otherwise returns `nil`.
@function sanitiseShort
@param supposedNumber a value that should be a short integer
@return nil or short integer
]]
local function sanitiseShort(supposedNumber)
  local sane
  if nil == supposedNumber then
    sane = nil
  elseif 'number' == type(supposedNumber) then
    sane = math.min(math.max(-32768, math.ceil(supposedNumber)), 32767)
  else
    sane = nil
  end
  return sane
end

--[[--
Maps given value to an integer if possible.
Given a number, then returns an integer in [-2147483648, 2147483647].
Otherwise returns `nil`.
@function sanitiseInteger
@param supposedNumber a value that should be an integer
@return nil or integer
]]
local function sanitiseInteger(supposedNumber)
  local sane
  if nil == supposedNumber then
    sane = nil
  elseif 'number' == type(supposedNumber) then
    sane = math.min(math.max(-2147483648, math.ceil(supposedNumber)), 2147483647)
  else
    sane = nil
  end
  return sane
end

--[[--
Maps given value to a valid timestamp with timezone if possible.
Given a string, trim whitespace and truncate to hold exactly a
timestamp with timezone offset and return it.
Otherwise returns `nil`.
Timestamp format:
```
  date('%Y-%m-%d %H:%M:%S', time()) .. get_tzoffset(get_timezone())
```
For example "2019-11-12 13:44:45+0200".
Note that "%z" usage is omited, and a custom snippet is used,
to ensure portability of the code.
@function sanitiseTimestamp
@param supposedTimestamp a value that should be timestamp string
@return nil or timestamp with timezone offset string
]]
local function sanitiseTimestamp(supposedTimestamp)
  local sane
  if nil == supposedTimestamp then
    sane = nil
  elseif 'string' == type(supposedTimestamp) then
    sane = string.sub(strtrim(supposedTimestamp), 1, 24)
  else
    sane = nil
  end
  return sane
end

--[[--
Maps given value to a predictable short string if possible.
Given a string, then trims whitespace and truncates value to be as short
as a reasonable name and not a description of any kind.
Otherwise returns `nil`.
@function sanitiseAnyName
@param supposedName value that should be a short string
@return short string or nil
]]
local function sanitiseAnyName(supposedName)
  local sane
  if nil == supposedName then
    sane = nil
  elseif 'string' == type(supposedName) then
    sane = string.sub(strtrim(supposedName), 1, 256)
  else
    sane = nil
  end
  return sane
end

--[[--
Short string checker, that is used in assertion statements.
@function validateAnyName
@param supposedName a value that should be a short string
@param lengthMin min permissible length of a string
@param lengthMax max permissible length of a string
@return given a valid supposedName, returns `true` and `nil`;
otherwise returns `false` and a string that describes the error
]]
local function validateAnyName(supposedName, lengthMin, lengthMax)
  if nil == lengthMin then
    lengthMin = 2
  elseif 'number' ~= type(lengthMin) then
    lengthMin = 2
  elseif lengthMin < 0 then
    lengthMin = 2
  elseif lengthMin > 256 then
    lengthMin = 256
  end
  if nil == lengthMax then
    lengthMax = 32
  elseif 'number' ~= type(lengthMin) then
    lengthMax = 32
  elseif lengthMax < 0 then
    lengthMax = 32
  elseif lengthMax > 256 then
    lengthMax = 256
  end
  assert (lengthMin <= lengthMax, 'Illegal argument.')
  local isValid = true
  local errorMsg = nil

  isValid = supposedName ~= nil and isValid
  if not isValid then
    errorMsg = 'Null pointer.'
    return isValid, errorMsg
  end

  isValid = 'string' == type(supposedName) and isValid
  if not isValid then
    errorMsg = 'Not a string.'
    return isValid, errorMsg
  end

  local len = string.len(supposedName)
  isValid = (len >= lengthMin and len <= lengthMax) and isValid
  if not isValid then
    errorMsg = string.format('String length %d not in [%d, %d].', len, lengthMin, lengthMax)
    return isValid, errorMsg
  end

  return isValid, errorMsg
end

--[[--
Timestamp with timezone offset string checker, that is used in assertions.
Timestamp format:
```
  date('%Y-%m-%d %H:%M:%S', time()) .. get_tzoffset(get_timezone())
```
For example "2019-11-12 13:44:45+0200".
Note that "%z" usage is omited, and a custom snippet is used,
to ensure portability of the code.
@function validateTimestamp
@param t a value that is demanded to be a timestamp with timezone string
@return `true` and `nil` if given value is valid;
otherwise `false` and error description string
]]
local function validateTimestamp(t)
  local isValid
  local errorMsg

  isValid, errorMsg = validateAnyName(t, 24, 24)
  if not isValid then
    return isValid, errorMsg
  end

  --[[
  -- os.date('%Y-%m-%d %H:%M:%S%z', os.time())
  -- ex, 2019-11-05 14:28:30+0200
  ]]--
  isValid = t == string.match(t, '%d%d%d%d--%d%d--%d%d %d%d:%d%d:%d%d++%d%d%d%d') and isValid
  if not isValid then
    errorMsg = 'Is not a timestamp.'
    return isValid, errorMsg
  end

  return isValid, errorMsg
end

--[[--
A number checker that ensures that the given value is strictly positive.
@function validatePositiveAndNonZero
@param n a value that is demanded to be positive and non-zero number
@return `true` and `nil` if given value is valid;
otherwise `false` and error description string
]]
local function validatePositiveAndNonZero(n)
  local isValid = true
  local errorMsg = nil

  isValid = n ~= nil and isValid
  if not isValid then
    errorMsg = 'Null pointer.'
    return isValid, errorMsg
  end

  isValid = 'number' == type(n) and isValid
  if not isValid then
    errorMsg = 'Not a number.'
    return isValid, errorMsg
  end

  isValid = n > 0 and isValid
  if not isValid then
    errorMsg = 'Is negative or zero.'
    return isValid, errorMsg
  end

  return isValid, errorMsg
end

--[[--
Checker that ensures that given element is a member of a given table.
The table size is limited to at most 1024 elements.
@function validateEnum
@param element value to check against the table
@param permissibleValueSet table to check if it contains the element
@return `true` and `nil` if given value is valid;
otherwise `false` and error description string
]]
local function validateEnum(element, permissibleValueSet)
  assert (nil ~= permissibleValueSet)
  assert ('table' == type(permissibleValueSet))
  local p = #permissibleValueSet
  if nil == p then
    p = 0
  elseif 'number' ~= type(p) then
    p = 0
  end
  local isValid = false
  local i = 1
  local j = math.min(math.max(0, math.floor(p)), 1024)
  while (i <= j and not isValid) do
    isValid = (element == permissibleValueSet[i]) or isValid
    i = i + 1
  end
  return isValid, 'Illegal value in an enumeration.'
end

--[[--
flowerpicker.base.
Business logic of the add-on, agnostic of the WoW API.
@section base
]]

--[[--
Constant that returns a table that lists permissible event type designations.
@function getPermissibleHarvestTypeSet
@return table that is a set of short names
]]
local function getPermissibleHarvestTypeSet()
  return {
    'COOKING',
    'FISHING',
    'HERBALISM',
    'LOOTCORPSE',
    'MINING',
    'OPENING'
  }
end

--[[--
Checks if given name is that of any the spells that player is expected to use.
This function is necessary to handle WoW events and map them to meaningful
item operation that the player might be concerned about.
Examples of spell names that should produce positive result:
"Fishing", "Herb Gathering".
@see handleLootSlotCleared
@see main
@function isPermissibleSpellToProduceLoot
@param spellName localised spell name
@return boolean
]]
local function isPermissibleSpellToProduceLoot(spellName)
  local n = sanitiseAnyName(spellName)
  if nil == n then
    return false
  end
  assert (validateAnyName(n))
  local result = false

  if 'Fishing' == n then
    result = true
  elseif 'Herb Gathering' == n then
    result = true
  end

  assert (result ~= nil)
  assert ('boolean' == type(result))
  return result
end

--[[--
Create a table that is an event and is to be later registered or discarded.
@see getPermissibleHarvestTypeSet
@see persistEventHarvest
@see createEventHarvestWithDefaults
@see registerLootGainFromCorpse
@function createEventHarvest
@param realmName short name of the game realm hosted by the game server
@param harvesterName name of the player character of interest
@param zoneName localised game zone name
@param subzoneName localised game sub-zone name
@param harvestTimestamp event occurrence timestamp with timezone string
@param harvestTypeDesignation enumerated string
@param sourceName localised name of the item producer, related to event type
@param harvestedItemName localised produced item name
@param harvestedItemQuantity a short integer that is produced item quantity
@return table indexed by number
]]
local function createEventHarvest(realmName, harvesterName, zoneName, subzoneName, harvestTimestamp,
    harvestTypeDesignation, sourceName, harvestedItemName, harvestedItemQuantity)
  local r = sanitiseAnyName(realmName)
  local c = sanitiseAnyName(harvesterName)
  local z = sanitiseAnyName(zoneName)
  local sz = sanitiseAnyName(subzoneName)
  local t = sanitiseTimestamp(harvestTimestamp)
  local d = sanitiseAnyName(harvestTypeDesignation)
  local s = sanitiseAnyName(sourceName)
  local i = sanitiseAnyName(harvestedItemName)
  local q = sanitiseShort(harvestedItemQuantity)
  assert (validateAnyName(r))
  assert (validateAnyName(c))
  assert (validateAnyName(z))
  assert (validateAnyName(sz))
  assert (validateTimestamp(t))
  assert (validateEnum(d, getPermissibleHarvestTypeSet()))
  assert (validateAnyName(s))
  assert (validateAnyName(i))
  assert (validatePositiveAndNonZero(q))

  local event = {
    r, c, z, sz, t, d, s, i, q
  }

  assert (event ~= nil)
  assert ('table' == type(event))
  return event
end

--[[--
Take given spell name and return according event type designation.
@function mapSpellNameToHarvestTypeDesignation
@see getPermissibleHarvestTypeSet
@param spellName string that is a localised name of a spell that produces loot
@return string that is event type designation
]]
local function mapSpellNameToHarvestTypeDesignation(spellName)
  local s = sanitiseAnyName(spellName)
  assert (validateAnyName(s))
  local t

  if 'Herb Gathering' == s then
    t = 'HERBALISM'
  elseif 'Fishing' == s then
    t = 'FISHING'
  else
    t = string.upper(s)
  end

  assert (validateEnum(t, getPermissibleHarvestTypeSet()))
  return t
end

--[[--
Access item name property of a given event object.
@function getEventItemName
@see createEventHarvest
@param event a table that is given event object
@return string that is localised item name
]]
local function getEventItemName(event)
  assert (event ~= nil)
  assert ('table' == type(event))
  local itemName = sanitiseAnyName(event[8])

  assert (validateAnyName(itemName))
  return itemName
end

--[[--
Access event type designation property of a given event object.
@function getEventTypeDesignation
@see createEventHarvest
@param event a table that is given event object
@return string that is an enumerated string value
]]
local function getEventTypeDesignation(event)
  assert (event ~= nil)
  assert ('table' == type(event))
  local typeDesignation = sanitiseAnyName(event[6])

  assert (validateEnum(typeDesignation, getPermissibleHarvestTypeSet()))
  return typeDesignation
end

--[[--
Access item quantity property of a given event object.
@function getEventItemQuantity
@see createEventHarvest
@param event a table that is a given event object
@return positive short integer that is item quantity
]]
local function getEventItemQuantity(event)
  assert (event ~= nil)
  assert ('table' == type(event))
  local itemQuantity = sanitiseShort(event[9])

  assert (validatePositiveAndNonZero(itemQuantity))
  return itemQuantity
end

--[[--
Event registry accessor.
@function query
@param dao a table that is the entire event registry to query
@return table that is the result set of the query
]]
local function query(dao)
  assert (dao ~= nil)
  assert ('table' == type(dao))

  local i = 0
  local j = math.min(math.max(0, #dao), 2147483647)
  local e
  local resultTable = {}
  local r
  local eventType
  local itemName
  local itemQuantity
  while (i < j) do
    i = i + 1
    e = dao[i]
    eventType = getEventTypeDesignation(e)
    itemName = getEventItemName(e)
    itemQuantity = getEventItemQuantity(e)
    local k = 0
    r = nil
    while (k < #resultTable) do
      k = k + 1
      r = resultTable[k]
      if eventType == r[1] and itemName == r[2] then
        r[3] = r[3] + itemQuantity
        break
      else
        r = nil
      end
    end
    if nil == r and #resultTable < 16 then
      r = {eventType, itemName, itemQuantity}
      table.insert(resultTable, r)
    end
  end

  assert (resultTable ~= nil)
  assert ('table' == type(resultTable))
  return resultTable
end

--[[--
flowerpicker.wow.
WoW event callbacks and GUI render that use the base module to implement the business logic.
@section wow
]]

--[[--
Empties the given table that is loot cache without changing the reference.
@function clearLootCache
@see updateLootCache
@param lootCache table that is loot cache
@return void
]]
local function clearLootCache(lootCache)
  assert (lootCache ~= nil)
  assert ('table' == type(lootCache))
  for k = 1, #lootCache do
    lootCache[k] = nil
  end
end

--[[--
Take the contents of currently opened loot window and cache them for later use.
The contents of loot cache are made available via a context object that is
`FlowerpickerAddOnFrame.swap.lootCache` variable.
The loot cache is dependend upon by every event handler to produce meaningful
add-on events to register and then query.
The values must be cached to preserve certain values that are otherwise not
exposed by the WoW API at some game states.
For example, to check if a looted item is money or not after
it was looted by the player.
Another example is to remember the properties of discovered items when
a loot window is opened. Otherwise, the properties like item name and quantity,
are not communicated when an item is looted by the player.
@function updateLootCache
@see main
@param lootCache table indexed by number that is loot cache
@return void
]]
local function updateLootCache(lootCache)
  assert (lootCache ~= nil)
  assert ('table' == type(lootCache))
  clearLootCache(lootCache)
  local n = GetNumLootItems()
  if nil == n then
    n = 0
  elseif 'number' ~= type(n) then
    n = 0
  end
  local i = 1
  local j = math.min(math.max(0, math.floor(n)), 1024)
  local itemInfo
  while (i <= j) do
    itemInfo = {GetLootSlotInfo(i)}
    table.insert(itemInfo, 1 == LootSlotIsItem(i))
    table.insert(itemInfo, 1 == LootSlotIsCoin(i))
    lootCache[i] = itemInfo
    i = i + 1
  end
end

--[[--
Debug function for loot cache.
@function describeLootCache
@see updateLootCache
@param lootCache a table that is loot cache
@return string that is description of loot cache contents
]]
local function describeLootCache(lootCache)
  assert (lootCache ~= nil)
  assert ('table' == type(lootCache))
  local d = ''
  for i = 1, #lootCache do
    local itemInfo = lootCache[i]
    if itemInfo ~= nil then
      d = d .. '{'
      for j = 1, #itemInfo do
        d = d .. tostring(itemInfo[j]) .. ', '
      end
      d = d .. "}\n"
    end
  end
  return d
end

--[[--
Access a single item from loot cache.
@function getCachedItemInfo
@see updateLootCache
@see handleLootSlotCleared
@param lootCache a table that is loot cache
@param slotId index of the item to access
@return table that is item info indexed by number
]]
local function getCachedItemInfo(lootCache, slotId)
  assert (lootCache ~= nil)
  assert ('table' == type(lootCache))
  assert (validatePositiveAndNonZero(slotId))

  local itemInfo = lootCache[slotId]
  assert (itemInfo ~= nil)
  assert ('table' == type(itemInfo))
  return itemInfo
end

--[[--
Access property of a specific cached loot item that indicates if it's money.
@function isCachedItemCoin
@see updateLootCache
@see handleLootSlotCleared
@see handlePlayerMoney
@param lootCache a table that is loot cache
@param slotId positive short integer that is index of an item in loot cache
@return boolean that is true if the item is money or coin
]]
local function isCachedItemCoin(lootCache, slotId)
  local itemInfo = getCachedItemInfo(lootCache, slotId)
  assert (itemInfo ~= nil)
  assert ('table' == type(itemInfo))
  return true == itemInfo[6]
end

--[[--
Employ WoW SavedVariables mechanism to persist given value.
@function persistEventHarvest
@see createEventHarvest
@see registerLootGainFromCorpse
@see registerMoneyGainFromCorpse
@see registerLootGainFromResourceNode
@param event table that is a meaningful event
@return void
]]
local function persistEventHarvest(event)
  assert (event ~= nil)
  assert ('table' == type(event))
  local dao = FlowerpickerSavedVariables
  assert (dao ~= nil)
  assert ('table' == type(dao))
  table.insert(dao, event)
end

--[[--
A utility function to increase readability of code that registers events.
"realmName", "harvesterName", "zoneName", "subzoneName", "harvestTimestamp"
parameters of `createEventHarvest` function are given predictable
default values. The remaining parameters, like event type, source name,
item name and item quantity, are still expected to be given values explicitly
by the user.
@see createEventHarvest
@see registerLootGainFromCorpse
@see registerMoneyGainFromCorpse
@see registerLootGainFromResourceNode
@function createEventHarvestWithDefaults
@return table that is an event
]]
local function createEventHarvestWithDefaults(eventTypeDesignation, sourceName, itemName, itemQuantity)
  --[[ begin defaults ]]--
  local r = sanitiseAnyName(GetRealmName())
  assert (validateAnyName(r))

  local p = sanitiseAnyName(UnitName('player'))
  assert (validateAnyName(p))

  local t = date('%Y-%m-%d %H:%M:%S', time()) .. get_tzoffset(get_timezone())
  assert (validateTimestamp(t))

  local z = sanitiseAnyName(GetZoneText())
  if nil == z or string.len(z) <= 0 then
    z = 'Unknown Zone'
  end
  assert (validateAnyName(z))

  local sz = sanitiseAnyName(GetSubZoneText())
  if nil == sz or string.len(sz) <= 0 then
    sz = 'Unknown Subzone'
  end
  assert (validateAnyName(sz))
  --[[ end defaults ]]--

  --[[ begin explicit ]]--
  local d = sanitiseAnyName(eventTypeDesignation)
  assert (validateEnum(d, getPermissibleHarvestTypeSet()))

  local s = sanitiseAnyName(sourceName)
  assert (validateAnyName(s))

  local i = sanitiseAnyName(itemName)
  assert (validateAnyName(i))

  local q = sanitiseShort(itemQuantity)
  assert (validatePositiveAndNonZero(q))
  --[[ end explicit ]]--

  local e = createEventHarvest(r, p, z, sz, t, d, s, i, q)
  assert (e ~= nil)
  return e
end

--[[--
Create and then persist event of looting items and not money from a corpse.
@function registerLootGainFromCorpse
@see createEventHarvestWithDefaults
@see persistEventHarvest
@see handleLootSlotCleared
@param itemName localised name of the looted item
@param itemQuantity positive short integer that is looted item quantity
@param corpseName localised name of the defeated enemy that was looted
@return void
]]
local function registerLootGainFromCorpse(itemName, itemQuantity, corpseName)
  local n = sanitiseAnyName(itemName)
  assert (validateAnyName(n))
  local s = sanitiseAnyName(corpseName)
  assert (validateAnyName(s))
  local q = sanitiseShort(itemQuantity)
  assert (validatePositiveAndNonZero(q))

  local e = createEventHarvestWithDefaults('LOOTCORPSE', s, n, q)
  persistEventHarvest(e)
end

--[[--
Create and then persist event of looting money and not items from a corpse.
@function registerMoneyGainFromCorpse
@see createEventHarvestWithDefaults
@see persistEventHarvest
@see handlePlayerMoney
@param moneyAmount positive integer that is quantity of copper coins looted
@param corpseName localised name of the defeated enemy that was looted
@return void
]]
local function registerMoneyGainFromCorpse(moneyAmount, corpseName)
  local m = sanitiseInteger(moneyAmount)
  assert (validatePositiveAndNonZero(m))
  local s = sanitiseAnyName(corpseName)
  assert (validateAnyName(s))

  local e = createEventHarvestWithDefaults('LOOTCORPSE', s, 'Money', m)
  persistEventHarvest(e)
end

--[[--
Create and then persist event of mining, gathering herbs or fishing.
@function registerLootGainFromResourceNode
@see createEventHarvestWithDefaults
@see persistEventHarvest
@see handleLootSlotCleared
@param itemName localised name of item gathered
@param itemQuantity positive short integer that is quantity of item gathered
@param resourceNodeName localised name of the harvested interactable object
@param harvestTypeDesignation event type designation
@return void
]]
local function registerLootGainFromResourceNode(itemName, itemQuantity, resourceNodeName, harvestTypeDesignation)
  local e = createEventHarvestWithDefaults(harvestTypeDesignation, resourceNodeName, itemName, itemQuantity)
  persistEventHarvest(e)
end

--[[--
Process 'LOOT_SLOT_CLEARED' event produced by the game client.
@see main
@see registerLootGainFromResourceNode
@see registerLootGainFromCorpse
@function handleLootSlotCleared
@param lootCache table that is a list of items in the game loot frame
@param slotId positive integer that is index of looted item in the loot cache
@param someSpellName conditional localised name of a spell used to produce loot
@param someCorpseName conditional localised name of a defeated and looted enemy
@param someVictimName conditional localised name of some unpredicted target
@return void
]]
local function handleLootSlotCleared(lootCache, slotId, someSpellName, someCorpseName, someVictimName)
  assert (lootCache ~= nil)
  assert ('table' == type(lootCache))
  assert (slotId ~= nil)
  assert ('number' == type(slotId))

  local sp = sanitiseAnyName(someSpellName)
  local cr = sanitiseAnyName(someCorpseName)

  if isCachedItemCoin(lootCache, slotId) then
    --[[ See `handlePlayerMoney` function. ]]--
    return
  elseif isPermissibleSpellToProduceLoot(sp) then
    local lootedItem = getCachedItemInfo(lootCache, slotId)
    assert (lootedItem ~= nil)
    assert ('table' == type(lootedItem))

    local i = sanitiseAnyName(lootedItem[2])
    assert (validateAnyName(i))
    local q = sanitiseShort(lootedItem[3])
    assert (validatePositiveAndNonZero(q))

    local d = mapSpellNameToHarvestTypeDesignation(sp)
    assert (validateAnyName(d))
    assert (validateEnum(d, getPermissibleHarvestTypeSet()))
    local s = sanitiseAnyName(someVictimName)
    if nil == s or string.len(s) < 1 then
      s = GetSubZoneText()
    end
    assert (validateAnyName(s))

    registerLootGainFromResourceNode(i, q, s, d)
  elseif true == validateAnyName(cr) then
    local lootedItem = getCachedItemInfo(lootCache, slotId)
    assert (lootedItem ~= nil)
    assert ('table' == type(lootedItem))

    local i = sanitiseAnyName(lootedItem[2])
    assert (validateAnyName(i))
    local q = sanitiseShort(lootedItem[3])
    assert (validatePositiveAndNonZero(q))

    registerLootGainFromCorpse(i, q, cr)
  else
    error('Failed to process loot for an unknown reason.')
  end
end

--[[--
Process 'PLAYER_MONEY' event produced by the game client.
All money amount is a positive or negative integer value that is the quantity
of copper coins, and not gold coins, unless otherwise explicitly stated.
This function __does not__ update the cached value that represents
the current amount owned by the player. That is handled by `main` function.
This is done so that the cache is updated even after handler function fails for
any reason.
@function handlePlayerMoney
@see main
@see registerMoneyGainFromCorpse
@param playerMoneyBefore positive integer that is the previous amount of money
@param playerMoneyAfter positive integer that is the current amount of money
@param playerMoneyDiff integer that is the amount of money gained or lost
@param corpseName conditional localised name of the looted enemy
@param gossiperName conditional name of non-player character interacted
@param merchantName conditional localised name of non-player merchant traded
@return void
]]
local function handlePlayerMoney(playerMoneyBefore, playerMoneyAfter, playerMoneyDiff,
    corpseName, gossiperName, merchantName)
  local isMoneyGained = playerMoneyAfter > playerMoneyBefore and playerMoneyDiff > 0
  local isMoneyLost = playerMoneyBefore > playerMoneyAfter and playerMoneyDiff < 0

  if gossiperName ~= nil then
    print('Gossip with ' .. gossiperName .. 'resulted in a money operation.')
  elseif merchantName ~= nil then
    print('A money operation with merchant ' .. merchantName .. ' occurred.')
  elseif isMoneyGained and not isMoneyLost and corpseName ~= nil then
    local m = sanitiseInteger(playerMoneyDiff)
    assert (validatePositiveAndNonZero(m))
    local cr = sanitiseAnyName(corpseName)
    assert (validateAnyName(cr))
    registerMoneyGainFromCorpse(m, cr)
  else
    print('Unaccounted money operation encountered.')
  end
end

--[[--
Primary WoW event handler.
This function is hooked to the root add-on frame and routes all events
that the add-on receives from the game client.
There is an important implicit parameter to this function.
That is `FlowerpickerAddOnFrame.swap` global variable that is a table.
This value represents a context object, and must not be accessed or mutated
by the user explicitly.
The user may employ function objects made available publicly via the
`FlowerpickerAddOnFrame.api` global variable that is table.
@function main
@see handleLootSlotCleared
@see handlePlayerMoney
@param self frame that the function is hooked to, likely FlowerpickerAddOnFrame
@param event string that designates WoW event type that is received
@return void
]]
local function main(self, event, arg1, arg2, arg3, arg4)
  assert (self ~= nil)
  assert ('table' == type(self))
  assert (self.api ~= nil)
  assert ('table' == type(self.api))
  assert (event ~= nil)
  assert ('string' == type(event))

  if nil == self.swap then
    self.swap = {}
  elseif 'table' ~= type(self.swap) then
    self.swap = {}
  end

  if 'UNIT_SPELLCAST_SUCCEEDED' == event then
    local unitId = sanitiseAnyName(arg1)
    if 'player' == unitId then
      local spellName = sanitiseAnyName(arg2)
      self.swap.lastSpellCastName = spellName
    end
  elseif 'UNIT_SPELLCAST_SENT' == event then
    assert (arg3 ~= nil)
    self.swap.lastSpellTargetName = sanitiseAnyName(arg4)
  elseif 'LOOT_OPENED' == event then
    updateLootCache(FlowerpickerLootCache)
    if (1 == UnitIsDead('target')) then
      self.swap.lastLootedCorpseName = sanitiseAnyName(UnitName('target'))
    end
    self.swap.isLooting = true
  elseif 'LOOT_SLOT_CLEARED' == event then
    local slotId = sanitiseShort(arg1)
    assert (slotId ~= nil)
    assert ('number' == type(slotId))
    local lootCache = FlowerpickerLootCache
    assert (lootCache ~= nil)
    assert ('table' == type(lootCache))
    local lastSpellCastName = sanitiseAnyName(self.swap.lastSpellCastName)
    local corpseName = sanitiseAnyName(self.swap.lastLootedCorpseName)
    local nodeName = sanitiseAnyName(self.swap.lastSpellTargetName)
    self.swap.lootSlotId = slotId
    handleLootSlotCleared(lootCache, slotId, lastSpellCastName, corpseName, nodeName)
  elseif 'LOOT_CLOSED' == event then
    clearLootCache(FlowerpickerLootCache)
    self.swap.lootSlotId = nil
    self.swap.isLooting = false
  elseif 'GOSSIP_SHOW' == event then
    self.swap.lastGossiperName = sanitiseAnyName(UnitName('target'))
    self.swap.isGossiping = true
  elseif 'GOSSIP_CLOSED' == event then
    self.swap.isGossiping = false
  elseif 'PLAYER_MONEY' == event then
    local playerMoneyAfter = GetMoney()
    assert (validatePositiveAndNonZero(playerMoneyAfter))

    local playerMoneyBefore = sanitiseInteger(self.swap.playerMoney)
    assert (playerMoneyBefore >= 0)

    local playerMoneyDiff = playerMoneyAfter - playerMoneyBefore

    self.swap.playerMoney = playerMoneyAfter

    local corpseName = sanitiseAnyName(self.swap.lastLootedCorpseName)

    local gossiperName
    if true == self.swap.isGossiping then
      gossiperName = sanitiseAnyName(self.swap.lastGossiperName)
    else
      gossiperName = nil
    end

    local merchantName
    if true == self.swap.isShopping then
      merchantName = sanitiseAnyName(self.swap.lastMerchantName)
    else
      merchantName = nil
    end

    handlePlayerMoney(playerMoneyBefore, playerMoneyAfter, playerMoneyDiff, corpseName, gossiperName, merchantName)
  else
    local lootCache = FlowerpickerLootCache
    error('Unknown event encountered and ignored "' .. event .. '".'
        .. 'Loot cache: ' .. describeLootCache(lootCache))
  end
end

--[[--
Query event registry and display the results in a report for the player.
The result of the function is a side effect that updates the state of the
graphical user interface that is a report.
@function reportRefresh
@see query
@return void
]]
local function reportRefresh()
  --[[ Lookup global table instead of reference frame table.
  -- Frame table can be changed by the user easily.
  -- Declared global cannot be nullified by the user maybe??]]--
  local reportEntryTableSize = 16
  local i = 0
  local e
  local et
  local dao = FlowerpickerSavedVariables
  local resultTable = query(dao)
  local r
  while (i < reportEntryTableSize) do
    i = i + 1
    e = _G[string.format('FlowerpickerEntry%02d', i)]
    assert (e ~= nil)
    assert ('table' == type(e))
    et = _G[e:GetName() .. 'Text']
    assert (et ~= nil)
    assert ('table' == type(et))
    r = resultTable[i]
    if 'Money' == r[2] then
      et:SetText(r[1] .. ': Gold x ' .. (r[3] / 10000))
    else
      et:SetText(r[1] .. ': ' .. r[2] .. ' x ' .. r[3])
    end
  end
end

--[[--
Describe the add-on's graphical user interface.
This function instantiates frames that comprise the graphical user interface
of the add-on, specifically report that displays user queries against the
event registry.
@function initGUI
@see init
@param addonFrame table that is game frame to attach the GUI frame to
@return void
]]
local function initGUI(addonFrame)
  assert (nil ~= addonFrame)
  assert ('table' == type(addonFrame))
  addonFrame:Show()

  local reportFrame = CreateFrame('FRAME', 'FlowerpickerReportFrame', addonFrame)
  reportFrame:SetPoint('CENTER', UIParent, 'CENTER', 0, 0)
  reportFrame:SetSize(768, 512)
  reportFrame:SetBackdrop({bgFile = "Interface/Tooltips/UI-Tooltip-Background",
                                            edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
                                            tile = true, tileSize = 16, edgeSize = 16,
                                            insets = { left = 4, right = 4, top = 4, bottom = 4 }});
  reportFrame:SetBackdropColor(0, 0, 0, 1);

  local minimiseBtn = CreateFrame('BUTTON', 'FlowerpickerButtonMinimise', addonFrame, 'UIPanelButtonTemplate')
  minimiseBtn:SetPoint('TOPRIGHT', reportFrame, 'TOPRIGHT', 0, 0)
  minimiseBtn:SetSize(256, 32)
  minimiseBtn:SetText('Toggle Flowerpicker')
  minimiseBtn:SetScript('OnClick', function()
    local f = FlowerpickerReportFrame
    assert (f ~= nil)
    assert ('table' == type(f))
    if (1 == f:IsShown()) then
      f:Hide()
    else
      f:Show()
      reportRefresh()
    end
  end)
  minimiseBtn:Show()

  local nextBtn = CreateFrame('BUTTON', 'FlowerpickerButtonNext', reportFrame, 'UIPanelButtonTemplate')
  nextBtn:SetPoint('TOPRIGHT', reportFrame, 'TOPRIGHT', 0, -32*1)
  nextBtn:SetSize(256, 32)
  nextBtn:SetText('Next')
  nextBtn:Show()

  local prevBtn = CreateFrame('BUTTON', 'FlowerpickerButtonPrevious', reportFrame, 'UIPanelButtonTemplate')
  prevBtn:SetPoint('TOPRIGHT', reportFrame, 'TOPRIGHT', 0, -32*2)
  prevBtn:SetSize(256, 32)
  prevBtn:SetText('Previous')
  prevBtn:Show()

  reportFrame.entryTable = {}
  local maxEntryQuantityPerPage = 16
  local offseth
  local en
  local ew = 512
  local eh = 32
  local i = 0
  local e
  local et
  while (i < maxEntryQuantityPerPage) do
    offseth = -eh*i
    i = i + 1
    en = string.format('FlowerpickerEntry%02d', i)
    e = CreateFrame('FRAME', en, reportFrame)
    e:SetSize(ew, eh)
    e:SetPoint('TOPLEFT', reportFrame, 'TOPLEFT', 0, offseth)
    e:SetBackdrop({bgFile = "Interface/Tooltips/UI-Tooltip-Background",
                                            edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
                                            tile = true, tileSize = 16, edgeSize = 16,
                                            insets = { left = 4, right = 4, top = 4, bottom = 4 }});
    e:SetBackdropColor(0, 0, 0, 1);

    et = e:CreateFontString(e:GetName() .. 'Text', 'OVERLAY')
    et:SetFont("Interface\\AddOns\\flowerpicker\\DejaVuSansMono.ttf", 14)
    --[[ Mandatory to SetAllPoints. Otherwise, the text will be missing. ]]--
    et:SetAllPoints()
    e.text = et

    assert (e ~= nil)
    et:SetText(e:GetName())
    reportFrame.entryTable[i] = e
  end

  reportFrame:Show()
end

--[[--
Prepare the add-on for operation.
Note that under normal execution `init` function is executed after 'ADDON_LOADED' event is fired.
@function init
@return void
]]
local function init()
  local addonFrame = FlowerpickerAddOnFrame
  assert (nil ~= addonFrame)
  assert ('table' == type(addonFrame))

  if nil == FlowerpickerSavedVariables or 'table' ~= type(FlowerpickerSavedVariables) then
    FlowerpickerSavedVariables = {}
  end
  local dao = FlowerpickerSavedVariables
  assert (nil ~= dao)
  assert ('table' == type(dao))

  if nil == FlowerpickerLootCache or 'table' ~= type(FlowerpickerLootCache) then
    FlowerpickerLootCache = {}
  end
  local lootCache = FlowerpickerLootCache
  assert (nil ~= lootCache)
  assert ('table' == type(lootCache))

  addonFrame:UnregisterEvent('ADDON_LOADED')
  addonFrame:RegisterEvent('UNIT_SPELLCAST_SUCCEEDED')
  addonFrame:RegisterEvent('UNIT_SPELLCAST_SENT')
  addonFrame:RegisterEvent('LOOT_OPENED')
  addonFrame:RegisterEvent('LOOT_SLOT_CLEARED')
  addonFrame:RegisterEvent('LOOT_CLOSED')
  addonFrame:RegisterEvent('GOSSIP_SHOW')
  addonFrame:RegisterEvent('GOSSIP_CLOSED')
  addonFrame:RegisterEvent('PLAYER_MONEY')
  addonFrame:SetScript('OnEvent', main)

  local api = {
    ['registerLootGainFromCorpse'] = registerLootGainFromCorpse,
    ['registerLootGainFromResourceNode'] = registerLootGainFromResourceNode,
    ['registerMoneyGainFromCorpse'] = registerMoneyGainFromCorpse
  }
  addonFrame.api = api

  local swap = {
    ['isGossiping'] = false,
    ['isLooting'] = false,
    ['isShopping'] = false,
    ['lastLootedCorpseName'] = nil,
    ['lastMerchantName'] = nil,
    ['lastSpellCastName'] = nil,
    ['lootCache'] = lootCache,
    ['playerMoney'] = sanitiseInteger(GetMoney())
  }
  addonFrame.swap = swap

  MerchantFrame:HookScript('OnShow', function()
    local f = FlowerpickerAddOnFrame
    assert (f ~= nil)
    f.swap.isShopping = true
    if (1 ~= UnitIsDead('target') and true ~= UnitPlayerControlled('target')) then
      f.swap.lastMerchantName = sanitiseAnyName(UnitName('target'))
    end
  end)

  MerchantFrame:HookScript('OnHide', function()
    local f = FlowerpickerAddOnFrame
    assert (f ~= nil)
    f.swap.isShopping = false
  end)

  initGUI(addonFrame)
  reportRefresh()
end

local frame = CreateFrame('FRAME', 'FlowerpickerAddOnFrame', UIParent)
frame:RegisterEvent('ADDON_LOADED')
frame:SetScript('OnEvent', init)


Mode Type Size Ref File
100644 blob 25 f9e4d8b7b114aa3c01824317ea9b0e4937815dbf .gitignore
100644 blob 999 ca05b1ef59e0f30b748f559191a39cf19e11e073 .luacheckrc
100644 blob 340712 f5786022f18216b4c59c6fb0c634b52c8b6e7990 DejaVuSansMono.ttf
100644 blob 37315 cce23a4c95fafca3241715e597c1204f23968422 Flowerpicker.lua
100644 blob 1203 a336ff5b94a703e049f8d074bce20b1669e518f5 README.md
100644 blob 681 9aeed48afd2c7981234d8376b362e69369c7216a build.gradle
040000 tree - 96233b34ccba706a9f89dca87a9282a3cd836e0a doc
100644 blob 199 c4cfefaa714660c35c6e537e75f87f2c59992649 flowerpicker.toc
Hints:
Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://rocketgit.com/user/vrtc/flowerpicker

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@ssh.rocketgit.com/user/vrtc/flowerpicker

Clone this repository using git:
git clone git://git.rocketgit.com/user/vrtc/flowerpicker

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a merge request:
... clone the repository ...
... make some changes and some commits ...
git push origin main