This page looks best with JavaScript enabled

Non-unique item names

 ·  ☕ 15 min read

This post describes how I updated our item code on Leaguepedia to support a case where an item name no longer refers to a unique item. (Items here refer to items in the game League of Legends.) This situation posed a problem because our scoreboards reference items by name, and we needed some solution to have unique inputs refer to unique outputs.

The problem we encountered was that a few items - Trinity Force, Duskblade of Draktharr, Locket of the Iron Solari, and Stealth Ward - were re-released or changed so substantially in patch 10.23, the season 11 preseason patch, that we needed to create physically separate pages on the wiki for the two versions of the items before and after this patch update. As a result, any mention of one of these items in a pre-patch scoreboard (i.e. any of the scoreboards from the past 10 years) would then link to the wrong location unless we did something about it.

(Note: Unless otherwise stated, all code in this post was originally written by me for Leaguepedia and is licensed under CC BY-SA 3.0.)

Solution 1: Change all the old names

The original solution was to just change all of the names in every single old scoreboard to use “Legacy” versions of the items. At first, we didn’t realize that there was a naming conflict with Stealth Ward, and so we only updated three items:

  • Trinity Force -> Trinity Force (Legacy)
  • Duskblade of Draktharr -> Duskblade of Draktharr (Legacy)
  • Locket of the Iron Solari -> Locket of the Iron Solari (Legacy)

We ran a brief Python script written by one of my admins to convert all instances:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from river_mwclient.esports_client import EsportsClient
from river_mwclient.auth_credentials import AuthCredentials
from river_mwclient.template_modifier import TemplateModifierBase

credentials = AuthCredentials(user_file="me")
site = EsportsClient('lol', credentials=credentials)
summary = "Bot Edit (Python) - Changing Items To Legacy"

template_names = ["MatchRecapS8/Player", "Scoreboard/Player", "il"]

items = {"Trinity Force (Item)": "Trinity Force (Legacy)",
         "Trinity Force": "Trinity Force (Legacy)",
         "Duskblade of Draktharr (Item)": "Duskblade of Draktharr (Legacy)",
         "Duskblade of Draktharr": "Duskblade of Draktharr (Legacy)",
         "Locket of the Iron Solari (Item)": "Locket of the Iron Solari (Legacy)",
         "Locket of the Iron Solari": "Locket of the Iron Solari (Legacy)"}


class TemplateModifier(TemplateModifierBase):
    def update_template(self, template):
        for param in template.params:
            for item in items:
                if param.value.lower().strip() == item.lower():
                    template.add(param.name, items[item])
        return


for template_name in template_names:
    TemplateModifier(site, template_name, summary=summary).run()

This script iterates through all parameters of the template in question, sees if they’re set to any of the items in question, and if so then updates them. (Note that I may have made a couple changes to this at runtime but I lost the most recent version of the code.)

There were a couple problems with this approach:

  1. If one of these three items (four, including the Stealth Ward) is changed again in the future, our naming scheme leaves much to be desired - this is only good for a one-time name swap. Now, this is perhaps ok considering we’re currently eleven years into League of Legends’ existence, and this is the first time we’ve had this problem arise; however, I’d rather avoid this problem completely.
  2. This script took five days to run. Admittedly some of the slowness was because it was the first large-scale script I was running on Fandom’s new Unified Community Platform (UCP) and I had to write an entire new error-handling framework in river_mwclient to deal with the platform differences compared to Hydra, but even without the delays caused by irrecoverable timeouts as I was developing my new error handling, it would have still been multiple days to run. I wanted a solution that didn’t require any input changing in previous scoreboards.
  3. I ran this script once the new patch was on live servers, but there could still be some games still played on the older patch afterwards (since the tournament realm lags behind live play), needing significant manual effort to track and update these items. Also, games from a year after the fact could be edited into the system later - these would need to have the correct Legacy versions of items entered. There has to be some kind of automated patch-item association at some level to at least errorcheck this, and removing manual work altogether would be ideal.

Solution 2: Create a go-between in Lua that understands patch

While item by itself is no longer well-defined, of course there still is a pair of inputs that together are: item plus patch. Therefore, an alternative solution to the problem is to update my item processor so that it’s capable of reading both a patch and an item name from the scoreboard. Scoreboards already have patches, so it’s just a matter of changing scoreboard module code, creating some new framework to translate patch and non-unique item into unique item, and loading the necessary data into this framework.

My item framework (Item / ItemList) is part of the same set of code as my CompoundRole code that I previously wrote about - notably, this framework has no way of dealing with two inputs (though it does have an opts parameter in the init constructor - see Module:EntityAbstract). Therefore I decided to create a new go-between module which I called, extremely creatively, ItemPatch, backed by data in a module called ItemPatchLookup.

An entry in ItemPatchLookup looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	['Stealth Ward'] = {
		{
			versionStart = { season = 0, patch = 0 },
			versionEnd = { season = 10, patch = 22 },
			name = 'Stealth Ward (1.0.0.52 - 5.22)',
		},
		{
			versionStart = { season = 10, patch = 23 },
			versionEnd = nil,
			name = 'Stealth Ward',
		},
	},

There’s a list of versions to iterate through, each one consisting of a start “timestamp” and an end “timestamp” along with a name. The name is the unique key to return to the caller to send to the Item framework; the “timestamps” govern the range of patches in which this name is valid. Patches are formatted like 9.22 or 10.23 so to see if a patch lies within versionStart and versionEnd I just split on the literal . character and see if it’s within this range (inclusive) - though actually the easier, and equivalent, task is to see if it’s not outside of this range (exclusive).

ItemPatch accepts an item and a patchString and looks up the item in its data module. If it finds nothing, great, there’s no “ambiguity.” Otherwise it looks up in between the versions as described above and returns the name it finds. Here’s the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
local util_args = require('Module:ArgsUtil')
local util_cargo = require("Module:CargoUtil")
local util_html = require("Module:HtmlUtil")
local util_map = require('Module:MapUtil')
local util_table = require("Module:TableUtil")
local util_text = require("Module:TextUtil")
local util_vars = require("Module:VarsUtil")
local i18n = require("Module:I18nUtil")
local lookup = mw.loadData('Module:ItemPatchLookup')
local h = {}
local p = {}

function p.test(args)
	local args = util_args.merge()
	return p.get(args.item, args.patch)
end

function p.get(item, patchString)
	local versionHistory = lookup[item]
	if not versionHistory then return item end
	season, patch = patchString:match('(%d+)%.(%d+)')
	season = tonumber(season)
	patch = tonumber(patch)
	if not season or not patch then
		error('Invalid patch for ItemPatchLookup')
	end
	return h.getCurrentName(season, patch, versionHistory)
end

function h.getCurrentName(season, patch, versionHistory)
	for _, version in ipairs(versionHistory) do
		if h.isCurrentVersion(season, patch, version) then
			return version.name
		end
	end
end

function h.isCurrentVersion(season, patch, version)
	-- strict inequality because both of the boundaries in the json are inclusive
	
	-- if we're before the version in question
	local versionStart = version.versionStart
	if versionStart.season > season then return false end
	if versionStart.season == season and versionStart.patch > patch then return false end
		
	-- if we're after the version in question
	local versionEnd = version.versionEnd
	if versionEnd and versionEnd.season < season then return false end
	if versionEnd and versionEnd.season == season and versionEnd.patch < patch then return false end
	
	-- otherwise we're good
	return true
end

return p

And the full text of Module:ItemPatchLookup before (spoiler!) I bailed on this approach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
return {
	['Stealth Ward'] = {
		{
			versionStart = { season = 0, patch = 0 },
			versionEnd = { season = 10, patch = 22 },
			name = 'Stealth Ward (1.0.0.52 - 5.22)',
		},
		{
			versionStart = { season = 10, patch = 23 },
			versionEnd = nil,
			name = 'Stealth Ward',
		},
	},
	['Trinity Force'] = {
		{
			versionStart = { season = 0, patch = 0 },
			versionEnd = { season = 10, patch = 22 },
			name = 'Trinity Force (Legacy)',
		},
		{
			versionStart = { season = 10, patch = 23 },
			versionEnd = nil,
			name = 'Trinity Force',
		},
	},
}

Almost immediately after writing this I realized this was not what I wanted. What I actually wanted was to be able to send a patch as part of the opts param directly into Item or ItemList and have it “just know” what to do - i.e. move all of the code from ItemPatchLookup directly into Module:Item.

Solution 3: Build a patch lookup directly into my item framework

So let’s build a patch lookup directly into my item framework. But could I actually do this? The format of my variables modules has never been anything like this before - it’s always just been:

1
2
3
    ["input1"] = "last input",
    -- snip
    ["last input"] = { param = "value", param2 = "value2" },

Or to give a concrete example from Itemnames:

1
2
3
4
5
6
7
8
	["stinger"] = "stinger (item)",
	["stinger (item)"] = { key = "Stinger", link = "Stinger (Item)", display = "Stinger" },
	
	["7010"] = "vespertide",
	["vespertide"] = { key = "Vespertide", link = "Vespertide", display = "Vespertide" },	
	
	["warding totem (trinket)"] = "warding totem",
	["warding totem"] = { key = "Warding Totem", link = "Warding Totem", display = "Warding Totem" },

Here I am just providing key-value pairs of display variables associated to the items. I wasn’t sure how tightly my code was coupled towards this extremely rigid structure that up until now every single one of my lookup modules shares.

Turns out, not at all coupled - I wrote extremely flexible code that actually didn’t care in the slightest how the vars table was formatted. I could do whatever I wanted with that format. So I did!

First, I’ll show you the new format of Module:Itemnames, when there’s patch-specific versions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
	["stealth ward"] = {
		hasPatchSpecificHandling = true,
		{
			versionStart = { season = 0, patch = 0 },
			versionEnd = { season = 10, patch = 22 },
			vars = { key = "Stealth Ward (1.0.0.52 - 5.22)", link = "Stealth Ward (1.0.0.52 - 5.22)", display = "Stealth Ward" },
		},
		{
			versionStart = { season = 10, patch = 23 },
			versionEnd = nil,
			vars = { key = "Stealth Ward", link = "Stealth Ward", display = "Stealth Ward" },
		},
	},

Note the hasPatchSpecificHandling flag - I could probably have made do without and instead done some ifs to detect format, but this feels a lot cleaner, plus it clues the reader into wtf the point of all of these tables is, so that self-documentation is pretty great.

Now let’s look at Module:Item. Mostly I just copied the contents of what was previously PatchItem in, but I also added a bit of extra functionality:

  • If patch is not specified, default to returning the vars of the last interval, since this code is now mandatory to run every time an item is displayed in any context anywhere on the wiki; while scoreboards will (almost) always have patch (though see next section), the rest of the wiki won’t
  • Since we’re built directly into Itemnames, now we have to return a full set of vars, not just a name
  • And also we just modify the class object instead of returning a value

Before the new code was added, here’s how the full code of Module:Item looked (note Module:ItemList was not modified at all):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
local util_vars = require("Module:VarsUtil")

local p = require('Module:EntityAbstract'):finalExtends()
local h = {}

p.objectType = 'Item'
p.imagelength = 'key'
p.defaultlength = 'key'
p.defaultLink = 'link'
p.imageDisplayLength = 'display'
p.filePattern = 'ItemSquare%s.png'

function p:init(str)
	self:super('init', str, 'Item')
	if self.unknown then
		self.vars = {
			link = str,
			key = str,
			display = str,
		}
	end
end

function p:name(opts)
	if not opts then opts = {} end
	return opts.display or self:get(opts.len or self.defaultlength)
end

return p

And after:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
local util_vars = require("Module:VarsUtil")

local p = require('Module:EntityAbstract'):finalExtends()
local h = {}

p.objectType = 'Item'
p.imagelength = 'key'
p.defaultlength = 'key'
p.defaultLink = 'link'
p.imageDisplayLength = 'display'
p.filePattern = 'ItemSquare%s.png'

function p:init(str, opts)
	if not opts then opts = {} end
	self:super('init', str, 'Item')
	if self.unknown then
		self.vars = {
			link = str,
			key = str,
			display = str,
		}
	end
	if self.vars and self.vars.hasPatchSpecificHandling then
		self:setCurrentVars(opts.patch)
	end
end

function p:name(opts)
	if not opts then opts = {} end
	return opts.display or self:get(opts.len or self.defaultlength)
end

-- deal with patch-specific handling in init
function p:setCurrentVars(patchString)
	local versionHistory = self.vars
	-- if we don't provide a patch string then just return
	-- the vars of the last thing in version history
	if not patchString then
		self.vars = self:getMostRecentVersionVars()
		return
	end
	
	season, patch = patchString:match('(%d+)%.(%d+)')
	season = tonumber(season)
	patch = tonumber(patch)
	if not season or not patch then
		error('Invalid patch for item patch lookup')
	end
	self.vars = h.getCurrentVarsFromVerHistory(season, patch, versionHistory)
end

function p:getMostRecentVersionVars()
	-- we don't have # because we used loadData and we only shallow cloned
	local ret
	for _, v in ipairs(self.vars) do
		ret = v.vars
	end
	return ret
end

function h.getCurrentVarsFromVerHistory(season, patch, versionHistory)
	for _, version in ipairs(versionHistory) do
		if h.isCurrentVersion(season, patch, version) then
			return version.vars
		end
	end
end

function h.isCurrentVersion(season, patch, version)
	-- strict inequality because both of the boundaries in the json are inclusive
	
	-- if we're before the version in question
	local versionStart = version.versionStart
	if versionStart.season > season then return false end
	if versionStart.season == season and versionStart.patch > patch then return false end
		
	-- if we're after the version in question
	local versionEnd = version.versionEnd
	if versionEnd and versionEnd.season < season then return false end
	if versionEnd and versionEnd.season == season and versionEnd.patch < patch then return false end
	
	-- otherwise we're good
	return true
end

return p

What I skipped

There’s a few additional things I skipped over that I want to come back to - first, note that I also changed our naming convention along the way, Stealth Ward has specific patch numbers in the name, not just Legacy, because I wanted something that will scale even if we have to move pages again later. The other three items are also renamed similarly in their “Legacy” versions.

Another slight complication is that I slightly lied when I said what the format of patches are. Actually if you look at our patch list navbox you can see that prior to season 3, the format varies widely. However, two points make this okay:

  1. There are almost no scoreboards in the system prior to season 3
  2. I added support to scoreboards for a parameter patch_sort that we can set to a fake number that obeys the desired formatting, for example, 1.032 or 0.2521 or even -1.0619 as necessary. It’s the order that matters here, not the actual number!

I do still need to run a Python script to rename all of the items back to Trinity Force, Duskblade of Draktharr, and Locket of the Iron Solari, and yes, that will take 3 days. But better this one-time thing than having an open-ended number of times to run this script in the future.

The automatic “show last version if patch isn’t specified” may be a pretty big detriment to error-checking missing patches, and I may need to add a flag in opts to turn this behavior off, and enable this flag from Module:Scoreboard. That said it might be better to have a check in Module:Scoreboard itself that one of patch or patch_sort is defined, so I might leave it as-is.

Finally, Scoreboards aren’t the only place I need patches sent to items - this has to happen in match histories too! So I will need to add the patch_sort param to the ScoreboardGames Cargo table for sure, though after that it’s just a one-line change in MatchHistoryPlayer to send patch to ItemList - which was the entire goal of this approach. (Update - actually, the MatchHistoryPlayer edit isn’t needed because the unique version of the item is stored, though I did add the patch_sort parameter to Cargo for errorchecking purposes.) So while this project is still a bit away from done, it’s definitely on the right track, with the hard part over.

Conclusion

My EntityAbstract code continues to be pretty cool, and I’m really happy with the solution that I arrived at here. It was not the easiest problem to solve - I spent a while refusing to write any code for this to make sure that once I did write code, it would be the “right” solution, and even still, I changed approach almost immediately after writing anything. But that made it seem like a nice blog post to conclude the year on! Even if this isn’t quite as applicable as a gadget, hopefully you found this interesting to read. Happy 2021!

Share on

river
WRITTEN BY
River
River is a developer most at home in MediaWiki and known for building Leaguepedia. She likes cats.


What's on this Page