Targeted Tables

The Problem

While I was hanging out on Discord the other day helping folks with Mudlet, someone asked me a question that comes up with some regularity in one form or another. They wanted to define a table of strings which included information from a variable. The first most obvious form of this would be

myActions = {
  attack = "kill " .. myTarget,
  heal = "cast 'healing word' " .. myTarget
}

The problem with this is that it will use the value of myTarget at the time of definition. But your target might change, and do you really want to redefine the table every time you change targets? I mean sure that would solve the problem, but There Has To Be A Better Way. And there is!

The Journey

There are a few ways to solve this which aren’t just redefining the table every time. One of the first is you could define the elements as functions themselves.

myActions = {
  attack = function() return "kill " .. myTarget end,
  heal = function() return "cast 'healing word' " .. myTarget end,
}

But this is a lot of typing any time you want to set a new value in the table, and it’s also kind of ugly honestly. Another option is to use a function to wrap your use of the items in the table.

myActions = {
  attack = "kill %s",
  heal = "cast 'healing word' %s"
}
function getAction(name)
  local action = myActions[name]
  if action then
    -- in case myTarget is nil, we add the 'or "DEFAULT"'
    -- this keeps string.format from erroring
    return string.format(action, myTarget or "DEFAULT")
  end
end

But then you have to explicitly and consciously use a function call every time you want to get a value from the table. Which is more typing and repeating yourself than I like.

That being said, both of the above solutions are viable and work just fine, and there’s no reason to move away from them if you’re happy with them. But if we spend a little time examining what happens when you get or set a value in a table, there are some tricks which will let us hide these details away.

So, what happens when you look up a value in a table? It looks up the key and returns the value stored there, or nil if no value is stored there, right? Well, mostly right. It does in fact look up the key and if there is something stored there it returns it. But if there isn’t something stored there, it looks to our table’s metatable. Specifically, it will look for the metatable’s __index function, passing in the original table we’re checking a value in, and the key we’re looking for. Similarly, when you set a value in a table it will look for the metatable’s __newindex function and call that to set the values. So using these two facts we can customize how a table behaves when you interact with it to store or retrieve data. So let’s build it piece by piece.

The Table

Now that we’re going to be manipulating all of the entries on entry and lookup, we can’t store the values directly at their associated keys, as it would just return those values and put us right back at the original problem. We can solve this by using a subtable to actually hold all the entries before formatting. I’ll also go ahead and put one entry in it, by way of example. We are largely using the second solution above, we’re just going to use metatables to have Lua do it for us automagically.

-- a top level table to keep everything in, for tidiness in the global space.
demonnic = demonnic or {}
-- and I'll go ahead and define the 'target' variable I'll be using
demonnic.target = demonnic.target or "DEFAULT"

-- and the table which we will use to store and retrieve the targeted actions
demonnic.targetedActions = demonnic.targetedActions or {
  -- the entries table is where the base entry, before manipulation, will be stored.
  -- Here I go ahead and preload it with one item I know I will always want in it
  entries = {
    attack = "kill %s"
  }
}

The Metatable

Now let’s define the metatable which will govern our table’s actions. The code is heavily commented rather than breaking it into multiple blocks.

-- use a local table as we don't really need to
-- access it outside of defining and assigning it
local meta = {}

-- let's go ahead and assign the metatable 
-- to my targeted actions table while we're here
setmetatable(demonnic.targetedActions, meta)

-- now, let's define how an item is looked up.
-- 'which' is the table being looked in
-- 'what' is the key being looked for
function meta.__index(which, what)
  local entry = which.entries[what]
  if entry then
    -- even though I definitely define demonnic.target above, I still tend to leave
    -- safeguards like this in place. Just In Case.
    return string.format(entry, demonnic.target or "DEFAULT")
  end
  -- the below isn't strictly necessary, but I like to be explicit
  return nil
end

-- We will also need to define how we store values for the table
-- so they go to the `entries` sub table for storage
-- 'which' is the table you're trying to assign data to
-- 'what' is the key being assigned to
-- 'becomes' is the value being assigned
function meta.__newindex(which, what, becomes)
  -- we only want to accept strings or nil (to clear an entry) so first we check the type
  local becomesType = type(becomes)
  if becomesType == "nil" then
    -- time to clear the entry out if it exists
    which.entries[what] = nil
    return
  end
  if becomesType ~= "string" then
    -- we don't want non strings, it'll break the string.format call in '__index'
    local msg = "You must provide a string and a string only for values to demonnic.targetedActions"
    -- print the msg, with stack trace, to the error console
    printDebug(msg, true)
    -- and return nil. The msg isn't actually passed along in this case 
    -- but I include it out of habit and it hurts nothing
    return nil, msg
  end
  -- uncomment the following if you want to also ensure 
  -- entries include a '%s' to substitute the target in for.
  -- if '%s' is not included with the string the target won't
  -- get pushed in, but it doesn't error so that's why I commented it
  --[[
  if not becomes:find("%%s") then
    local msg = "You must provide a %s in the string to replace with the target"
    printDebug(msg, true)
    return nil, msg
  end
  --]]

  -- and finally we set the value now that it's passed the checks.
  which.entries[what] = becomes
end

Usage

That’s about it. Now you would use the table as normal for getting and receiving values, but it’ll have the target swapped for you. For example:

demonnic.target = "Bob"
-- the following will send "kill Bob"
send(demonnic.targetedActions.attack)

-- adding a new action
demonnic.targetedActions.heal = "cast 'healing word' %s"

-- and now it will send "cast 'healing word' Bob"
send(demonnic.targetedActions.heal)

demonnic.target = "Linda"
-- and now it will send "case 'healing word' Linda"
send(demonnic.targetedActions.heal)

The Destination

That’s all well and good and works if you only have one such table, but what if you wanted more than one? Maybe you want one table of targeted cures and one of targeted attack moves. Maybe they should track two separate targets. I’m not your dad, I don’t tell you how to live your life.

But I will tell you how to make a function which can return a ‘targeted’ table for you to use, so you can reuse the code without repeating it. With the hopes that you will be able to apply it in future scripts, obviously =)

The following code can also be viewed as a Gist

-- this function allows us to take a name of a variable as a string for checking 
-- For instance, if you use getValue("demonnic.target") it will return 
-- the value at demonnic.target, or nil and an error.
local function getValue(location)
  local ok, err = pcall(loadstring("return " .. tostring(location)))
  if ok then return err end
  return nil, err
end

local meta = {}
-- return an entry from the table with the target swapped out for %s token
-- @param which the table being looked in
-- @param what the key being looked for
function meta.__index(which, what)
  local entry = which.entries[what]
  if not entry then
    return nil
  end
  local target = getValue(which.target)
  return string.format(entry, target or "DEFAULT")
end

--- create an entry in the targeted table
-- @param which the table to add the entry to
-- @param what the key to use for the entry
-- @param becomes the value to set the entry to
function meta.__newindex(which, what, becomes)
  local becomesType = type(becomes)
  if becomesType == "nil" then
    which.entries[what] = becomes
    return
  end
  if becomesType ~= "string" then
    local msg = "You must provide a string and a string only for values to a targeted table"
    printDebug(msg, true)
    return nil, msg
  end
  -- uncomment the following if you want to also ensure
  -- entries include a '%s' to substitute the target in for.
  -- if '%s' is not included with the string the target won't
  -- get pushed in, but it doesn't error so that's why I commented it
  --[[
  if not becomes:find("%%s") then
    local msg = "You must provide a %s in the string to replace with the target"
    printDebug(msg, true)
    return nil, msg
  end
  --]]
  which.entries[what] = becomes
end

function createTargetedTable(target, entries)
  -- use an empty table if no entries provided
  entries = entries or {}
  -- do some type checking
  entriesType = type(entries)
  targetType = type(target)
  if entriesType ~= "table" then
    return nil, "createTargetedTable(target, entries): optional parameter entries as nil or table expected, got " .. entriesType
  end
  if targetType ~= "string" then
    return nil, "createTargetedTable(target, entries): target variable name as string expected, got " .. targetType
  end
  local new = {
    entries = entries,
    target = target
  }
  setmetatable(new, meta)
  return new
end

In action

And here’s a photo of it in action, captured from REPLet