diff --git a/spec/System/TestTreeTab_spec.lua b/spec/System/TestTreeTab_spec.lua index e9418b8921b..d8b7ae3340f 100644 --- a/spec/System/TestTreeTab_spec.lua +++ b/spec/System/TestTreeTab_spec.lua @@ -2,6 +2,14 @@ describe("TreeTab", function() local originalClusterNodeMap local originalMasteryEffects + local function findMasteryNode() + for _, node in pairs(build.spec.nodes) do + if node.type == "Mastery" and node.masteryEffects and #node.masteryEffects > 0 then + return node + end + end + end + before_each(function() newBuild() originalClusterNodeMap = build.spec.tree.clusterNodeMap @@ -13,6 +21,37 @@ describe("TreeTab", function() build.spec.tree.masteryEffects = originalMasteryEffects end) + it("restores a Runegraft after rebuilding mastery options", function() + local node = assert(findMasteryNode()) + local override + for _, tattooNode in pairs(build.spec.tree.tattoo.nodes) do + if tattooNode.overrideType == "AlternateMastery" then + override = copyTable(tattooNode, true) + break + end + end + assert(override) + override.id = node.id + build.spec.hashOverrides[node.id] = override + build.spec:ReplaceNode(node, override) + + build.spec:DeallocSingleNode(node) + build.spec:BuildAllDependsAndPaths() + + assert.is_false(node.allMasteryOptions) + assert.are.equal("AlternateMastery", node.overrideType) + end) + + it("clears the selected mastery reminder after deallocation", function() + local node = assert(findMasteryNode()) + node.reminderText = { "Tip: Right click to select a different effect" } + + build.spec:DeallocSingleNode(node) + build.spec:BuildAllDependsAndPaths() + + assert.is_nil(node.reminderText) + end) + it("adds separate power report entries for mastery effects", function() local treeTab = build.treeTab local parentNode = { id = 2 } diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0985a2bf0db..e2cf83373c7 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4164,7 +4164,6 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte else spec.jewels[compareSlot.nodeId] = nil end - local ok, err = xpcall(function() spec:BuildAllDependsAndPaths() end, debug.traceback) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 1a31f414fd4..ad7dfbc7924 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -36,6 +36,7 @@ function PassiveSpecClass:Init(treeVersion, convert) self.tree = main:LoadTree(treeVersion) self.ignoredNodes = { } self.ignoreAllocatingSubgraph = false + self.checkNodeLinks = false local previousTreeNodes = { } if convert then previousTreeNodes = self.build.spec.nodes @@ -865,23 +866,27 @@ function PassiveSpecClass:FindStartFromNode(node, visited, noAscend) node.visited = true t_insert(visited, node) -- For each node which is connected to this one, check if... + local nodeAscendancy = node.ascendancyName for _, other in ipairs(node.linked) do -- Either: -- - the other node is a start node, or -- - there is a path to a start node through the other node which didn't pass through any nodes which have already been visited - local startIndex = #visited + 1 - if other.alloc and - (other.type == "ClassStart" or other.type == "AscendClassStart" or - (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend)) - ) then - if node.ascendancyName and not other.ascendancyName then - -- Pathing out of Ascendant, un-visit the outside nodes - for i = startIndex, #visited do - visited[i].visited = false - visited[i] = nil + local startIndex = nodeAscendancy and #visited + 1 + if other.alloc then + local otherType = other.type + if + (otherType == "ClassStart" or otherType == "AscendClassStart" or + (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend)) + ) then + if nodeAscendancy and not other.ascendancyName then + -- Pathing out of Ascendant, un-visit the outside nodes + for i = startIndex, #visited do + visited[i].visited = false + visited[i] = nil + end + elseif not noAscend or otherType ~= "AscendClassStart" then + return true end - elseif not noAscend or other.type ~= "AscendClassStart" then - return true end end end @@ -909,57 +914,6 @@ function PassiveSpecClass:GetSocketedJewel(nodeId) return self:GetJewel(itemId) end --- Perform a breadth-first search of the tree, starting from this node, and determine if it is the closest node to any other nodes -function PassiveSpecClass:BuildPathFromNode(root) - root.pathDist = 0 - root.path = { } - local queue = { root } - local o, i = 1, 2 -- Out, in - while o < i do - -- Nodes are processed in a queue, until there are no nodes left - -- All nodes that are 1 node away from the root will be processed first, then all nodes that are 2 nodes away, etc - local node = queue[o] - o = o + 1 - local curDist = node.pathDist - -- Iterate through all nodes that are connected to this one - for index, other in ipairs(node.linked) do - -- Cluster subgraph rebuilds can replace node objects while retaining IDs. - -- Normalize stale link references to the canonical node object. - local canonicalNode = other and other.id and self.nodes[other.id] - if not canonicalNode then - other = nil - elseif canonicalNode ~= other then - node.linked[index] = canonicalNode - other = canonicalNode - end - if other then - -- Paths must obey these rules: - -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) - -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree - -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree - -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas - -- 3. They must not pass away from mastery nodes - local otherPathDist = other.pathDist or 1000 - if node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and otherPathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) then - -- The shortest path to the other node is through the current node - other.pathDist = curDist - if not other.alloc then - other.pathDist = other.pathDist + 1 - end - other.path = wipeTable(other.path) - other.path[1] = other - for i, n in ipairs(node.path) do - other.path[i+1] = n - end - -- Add the other node to the end of the queue - queue[i] = other - i = i + 1 - end - end - end - end -end - -- Determine this node's distance from the class' start -- Only allocated nodes can be traversed function PassiveSpecClass:SetNodeDistanceToClassStart(root) @@ -1064,23 +1018,40 @@ function PassiveSpecClass:BuildSplitPersonalityPath() self.splitPersonalityPath = splitPersonalityPath end +-- cache the unallocated mastery option data as it doesn't change based on the build +-- adds stat descriptions and other node data for unallocated mastery nodes function PassiveSpecClass:AddMasteryEffectOptionsToNode(node) - node.sd = {} - if node.masteryEffects ~= nil and #node.masteryEffects > 0 then - for _, effect in ipairs(node.masteryEffects) do - effect = self.tree.masteryEffects[effect.effect] - local startIndex = #node.sd + 1 - for _, sd in ipairs(effect.sd) do - t_insert(node.sd, sd) + local treeNode = self.tree.nodes[node.id] + local cacheNode = treeNode and treeNode.masteryCache + if not cacheNode then + cacheNode = { id = node.id, sd = {} } + if node.masteryEffects ~= nil and #node.masteryEffects > 0 then + for _, effect in ipairs(node.masteryEffects) do + effect = self.tree.masteryEffects[effect.effect] + for _, sd in ipairs(effect.sd) do + t_insert(cacheNode.sd, sd) + end + self.tree:ProcessStats(cacheNode, 1) end - self.tree:ProcessStats(node, startIndex) + else + self.tree:ProcessStats(cacheNode) + end + if treeNode then + treeNode.masteryCache = cacheNode + end + end + -- Cached options do not include the reminder from a previously selected effect + node.reminderText = nil + for k, v in pairs(cacheNode) do + if k == "modList" then + node.modList = new("ModList"):ModList() + node.modList:AddList(v) + else + node[k] = v end - else - self.tree:ProcessStats(node) end node.allMasteryOptions = true end - function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) local result = { } if self.jewels[node.id] and self.jewels[node.id] > 0 then @@ -1147,62 +1118,99 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end end end + + -- gather list of radius jewels + local radiusJewels = {} + for nodeId, itemId in pairs(self.jewels) do + local item = self.build.itemsTab.items[itemId] + if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then + local socket = self.nodes[nodeId] + t_insert(radiusJewels, { + socket = socket, + item = item, + }) + end + end -- Check all nodes for other nodes which depend on them (i.e. are only connected to the tree through that node) for id, node in pairs(self.nodes) do node.depends = wipeTable(node.depends) - node.intuitiveLeapLikesAffecting = { } + node.intuitiveLeapLikesAffecting = wipeTable(node.intuitiveLeapLikesAffecting) node.conqueredBy = nil + if self.checkNodeLinks then + -- Cluster subgraph rebuilds can leave links to replaced node objects, + -- so normalize them to the canonical nodes before rebuilding paths. + local linked = node.linked + for index = #linked, 1, -1 do + local other = linked[index] + local canonicalNode = other and other.id and self.nodes[other.id] + if not canonicalNode then + t_remove(linked, index) + elseif canonicalNode ~= other then + linked[index] = canonicalNode + end + end + end -- ignore cluster jewel nodes that don't have an id in the tree - if self.tree.nodes[id] then + local treeNode = self.tree.nodes[id] + -- skip updating unallocated masteries which don't have a runegraft + local isUnallocatedMastery = node.allMasteryOptions and node.type == "Mastery" and not self.hashOverrides[id] + if treeNode and not isUnallocatedMastery then self:ReplaceNode(node,self.tree.nodes[id]) end node.conqueredBy = abyssConquests[id] - if node.type ~= "ClassStart" and node.type ~= "Socket" and not node.ascendancyName then - for nodeId, itemId in pairs(self.jewels) do - local item = self.build.itemsTab.items[itemId] - if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then - local radiusIndex = item.jewelRadiusIndex - if self.nodes[nodeId].nodesInRadius and self.nodes[nodeId].nodesInRadius[radiusIndex][node.id] then - if itemId ~= 0 then - if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then - -- This node depends on Intuitive Leap-like behaviour - -- This flag: - -- 1. Prevents generation of paths from this node unless it's also connected to the start - -- 2. Prevents allocation of path nodes when this node is being allocated - t_insert(node.intuitiveLeapLikesAffecting, self.nodes[nodeId]) - end - if item.jewelData.conqueredBy then - local radiusJewelType = timelessJewelTypeByConqueror[item.jewelData.conqueredBy.conqueror.type] - if not radiusJewelType or radiusJewelType < 7 then - node.conqueredBy = item.jewelData.conqueredBy - end + if #radiusJewels > 0 and node.type ~= "ClassStart" and node.type ~= "Socket" and not node.ascendancyName then + for _, radiusJewel in ipairs(radiusJewels) do + local item = radiusJewel.item + local socket = radiusJewel.socket + local radiusIndex = item.jewelRadiusIndex + local nodesInRadius = radiusIndex and socket.nodesInRadius and socket.nodesInRadius[radiusIndex] + if nodesInRadius and nodesInRadius[node.id] then + if item.id ~= 0 then + if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then + -- This node depends on Intuitive Leap-like behaviour + -- This flag: + -- 1. Prevents generation of paths from this node unless it's also connected to the start + -- 2. Prevents allocation of path nodes when this node is being allocated + t_insert(node.intuitiveLeapLikesAffecting, socket) + end + if item.jewelData.conqueredBy then + local radiusJewelType = timelessJewelTypeByConqueror[item.jewelData.conqueredBy.conqueror.type] + if not radiusJewelType or radiusJewelType < 7 then + node.conqueredBy = item.jewelData.conqueredBy end end end + end - if item.jewelData and item.jewelData.impossibleEscapeKeystone then - for keyName, keyNode in pairs(self.tree.keystoneMap) do - if item.jewelData.impossibleEscapeKeystones[keyName] and keyNode.nodesInRadius then - if keyNode.nodesInRadius[radiusIndex][node.id] then - t_insert(node.intuitiveLeapLikesAffecting, self.nodes[nodeId]) - end - end + local impossibleEscapeKeystones = item.jewelData and item.jewelData.impossibleEscapeKeystones + if impossibleEscapeKeystones then + for keyName in pairs(impossibleEscapeKeystones) do + local keyNode = self.tree.keystoneMap[keyName] + local inRadius = keyNode and keyNode.nodesInRadius and keyNode.nodesInRadius[radiusIndex] + if inRadius and inRadius[node.id] then + t_insert(node.intuitiveLeapLikesAffecting, socket) end end end end + end if node.alloc then node.depends[1] = node -- All nodes depend on themselves end end - + self.checkNodeLinks = false for id, node in pairs(self.nodes) do -- If node is tattooed, replace it if self.hashOverrides[node.id] then self:ReplaceNode(node, self.hashOverrides[node.id]) + -- Runegrafts use mastery nodes, but represent one modifier rather than + -- the usual list of mastery options + if node.overrideType == "AlternateMastery" then + node.allMasteryOptions = false + end end -- If node is conquered, replace it or add mods @@ -1236,7 +1244,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() return statToFix -- if it doesn't need to be changed end - if jewelType >= 7 then + if jewelType >= 7 and node.type ~= "Mastery" then for _, component in ipairs(conqueredBy.modification) do local changedNode, replacesNode = data.resolveAbyssJewelComponent(component, self.tree.legion) if changedNode then @@ -1400,6 +1408,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.allocatedMasteryTypes = { } self.allocatedMasteryTypeCount = 0 self.allocatedTattooTypes = { } + local masteryReminderText = { "Tip: Right click to select a different effect" } for id, node in pairs(self.nodes) do if self.ignoredNodes[id] and self.allocNodes[id] then self.nodes[id].alloc = false @@ -1416,7 +1425,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() node.sd = effect.sd end node.allMasteryOptions = false - node.reminderText = { "Tip: Right click to select a different effect" } + node.reminderText = masteryReminderText self.tree:ProcessStats(node) self.allocatedMasteryCount = self.allocatedMasteryCount + 1 if not self.allocatedMasteryTypes[self.allocNodes[id].name] then @@ -1435,7 +1444,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.allocNodes[id] = nil self.masterySelections[id] = nil end - elseif node.type == "Mastery" then + elseif node.type == "Mastery" and not node.allMasteryOptions and node.overrideType ~= "AlternateMastery" then self:AddMasteryEffectOptionsToNode(node) elseif node.type == "Notable" and node.alloc then self.allocatedNotableCount = self.allocatedNotableCount + 1 @@ -1609,22 +1618,73 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end -- Reset and rebuild all node paths - for id, node in pairs(self.nodes) do + for _, node in pairs(self.nodes) do node.pathDist = (node.alloc and #node.intuitiveLeapLikesAffecting == 0) and 0 or 1000 node.path = nil if node.isJewelSocket or node.expansionJewel then node.distanceToClassStart = 0 end end - for id, node in pairs(self.allocNodes) do + local rootList = {} + for _, node in pairs(self.allocNodes) do if #node.intuitiveLeapLikesAffecting == 0 or node.connectedToStart then - self:BuildPathFromNode(node) - if node.isJewelSocket or node.expansionJewel then - self:SetNodeDistanceToClassStart(node) + t_insert(rootList, node) + end + end + + -- Use a multi-source 0-1 BFS to find the closest allocated node. Allocated + -- nodes have zero weight, while each unallocated node costs one passive point. + local queue = { } + for _, node in ipairs(rootList) do + node.pathDist = 0 + node.path = wipeTable(node.path) + t_insert(queue, node) + end + local queueStart = 1 + local queueLength = #queue + while queueStart <= queueLength do + local node = queue[queueStart] + queueStart = queueStart + 1 + local linked = node.linked + local nodeDist = node.pathDist + local nodePath = node.path + for i = 1, #linked do + local other = linked[i] + local weight = other.alloc and 0 or 1 + local distViaNode = nodeDist + weight + -- Paths cannot pass through start nodes, cross ascendancies, or move + -- away from masteries. Ascendant paths may leave at distance zero. + local canTraverse = node.type ~= "Mastery" + and other.type ~= "ClassStart" + and other.type ~= "AscendClassStart" + and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) + if distViaNode < (other.pathDist or math.huge) and canTraverse then + if weight == 0 then + -- Free nodes go to the front so they can shorten paid paths immediately. + queueStart = queueStart - 1 + queue[queueStart] = other + else + queueLength = queueLength + 1 + queue[queueLength] = other + end + + other.pathDist = distViaNode + local path = wipeTable(other.path) + path[1] = other + for pathIndex = 1, #nodePath do + path[pathIndex + 1] = nodePath[pathIndex] + end + other.path = path end end end + for _, node in ipairs(rootList) do + if node.isJewelSocket or node.expansionJewel then + self:SetNodeDistanceToClassStart(node) + end + end + self:BuildSplitPersonalityPath() end @@ -1648,7 +1708,7 @@ function PassiveSpecClass:ReplaceNode(old, newNode) old.icon = newNode.icon old.spriteId = newNode.spriteId old.activeEffectImage = newNode.activeEffectImage - old.reminderText = newNode.reminderText or { } + old.reminderText = newNode.reminderText or wipeTable(old.reminderText) end ---Reconnects altered timeless jewel to class start, for Pure Talent @@ -1740,6 +1800,8 @@ end function PassiveSpecClass:BuildClusterJewelGraphs() local needsLegacyClusterHashConversion = self:BeginLegacyClusterHashConversion() + -- Mark that path building should clear out stale references to cluster nodes + self.checkNodeLinks = true -- Remove old subgraphs for id, subGraph in pairs(self.subGraphs) do for _, node in ipairs(subGraph.nodes) do @@ -2415,13 +2477,12 @@ function PassiveSpecClass:NodeAdditionOrReplacementFromString(node,sd,replacemen end function PassiveSpecClass:NodeInKeystoneRadius(keystoneNames, nodeId, radiusIndex) - for _, node in pairs(self.nodes) do - if node.name and node.type == "Keystone" and keystoneNames[node.name:lower()] then - if (node.nodesInRadius[radiusIndex][nodeId]) then - return true - end + for keystoneName, _ in pairs(keystoneNames) do + local keystoneNode = self.tree.keystoneMap[keystoneName] + local radius = keystoneNode and keystoneNode.nodesInRadius and keystoneNode.nodesInRadius[radiusIndex] + if radius and radius[nodeId] then + return true end end - return false end diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index d514456703e..e9f7d5188b6 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -1581,7 +1581,8 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, returnEarly) end end - if mNode.sd[1] and mNode.allMasteryOptions then + local isRunegraft = mNode.overrideType == "AlternateMastery" + if mNode.sd[1] and mNode.allMasteryOptions and not isRunegraft then tooltip:AddSeparator(14) tooltip:AddLine(14, "^7Available Mastery node options are:") tooltip:AddLine(6, "") @@ -1605,7 +1606,7 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, returnEarly) end -- This stanza actives for both Mastery and non Mastery tooltips. Proof: add '"Blah "..' to addModInfoToTooltip - if mNode.sd[1] and not mNode.allMasteryOptions then + if mNode.sd[1] and (not mNode.allMasteryOptions or isRunegraft) then tooltip:AddLine(16, "") for i, line in ipairs(mNode.sd) do addModInfoToTooltip(mNode, i, masteryColor..line) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 8a41ff82eb2..06f66f38df7 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -352,6 +352,7 @@ function TreeTabClass:RemoveTattooFromNode(node) self.build.spec.tree.nodes[node.id].isTattoo = false self.build.spec.hashOverrides[node.id] = nil self.build.spec:ReplaceNode(node, self.build.spec.tree.nodes[node.id]) + node.allMasteryOptions = false self.build.spec:BuildAllDependsAndPaths() end @@ -886,6 +887,9 @@ function TreeTabClass:ModifyNodePopup(selectedNode) newTattooNode.id = selectedNode.id self.build.spec.hashOverrides[selectedNode.id] = newTattooNode self.build.spec:ReplaceNode(selectedNode, newTattooNode) + if selectedNode.type == "Mastery" then + selectedNode.allMasteryOptions = false + end self.build.spec:BuildAllDependsAndPaths() end diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index ee2c682f7e9..06710a4aa7a 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -114,21 +114,22 @@ function calcs.initModDB(env, modDB) modDB.conditions["Effective"] = env.mode_effective end --- Recycle a modlist so that we do not allocate many tables for each node. -local function resetModList(list) - for i = #list, 1, -1 do - list[i] = nil - end - list.multipliers = wipeTable(list.multipliers) - list.conditions = wipeTable(list.conditions) - list.actor = wipeTable(list.actor) - list.parent = false - return list -end - ---@param reuse table|nil A ModList to recycle instead of allocating. Only safe when the caller discards the result. function calcs.buildModListForNode(env, node, reuse) - local modList = reuse and resetModList(reuse) or new("ModList"):ModList() + local modList + if reuse then + -- Reset the scratch list so non-MAIN calculations can reuse it for each node + modList = reuse + for i = #modList, 1, -1 do + modList[i] = nil + end + modList.multipliers = wipeTable(modList.multipliers) + modList.conditions = wipeTable(modList.conditions) + modList.actor = wipeTable(modList.actor) + modList.parent = false + else + modList = new("ModList"):ModList() + end if node.type == "Keystone" then modList:AddMod(node.keystoneMod) else diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index d9f71152821..b71053f5f24 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -76,25 +76,6 @@ local function getClass(className) return class end --- wrap constructor to check that the constructors for all parent and superparent classes have been called -local function wrapConstructor(class, className, originalFunc) - return function(self, ...) - local ret = originalFunc(self, ...) - if class._parents then - for parent in pairs(class._superParents) do - if parent[parent._className] and not self._parentInit[parent] then - error("Parent class '" .. - parent._className .. "' of class '" .. className .. "' must be initialised") - end - end - end - if not ret then - error(string.format("Class %s constructor did not return a value", className)) - end - return ret - end -end - ---@generic T ---@param className `T` ---@param ... string parent class names @@ -113,7 +94,7 @@ function newClass(className, ...) class._metaList = {} class._className = className local numVarArg = select("#", ...) - local parentIndex + local classMeta = { } if numVarArg > 0 then -- Build list of parent classes class._parents = { } @@ -124,7 +105,7 @@ function newClass(className, ...) class._superParents = { } addSuperParents(class, class) -- Set up inheritance - function parentIndex(self, key) + classMeta.__index = function(self, key) for _, parent in ipairs(class._parents) do local val = parent[key] if val ~= nil then @@ -134,16 +115,29 @@ function newClass(className, ...) end end end - setmetatable(class, { - __index = parentIndex, - __newindex = function(self, k, v) - if k == className then - -- Check that the constructors for all parent and superparent classes have been called - v = wrapConstructor(class, className, v) + classMeta.__newindex = function(self, k, v) + if k == className then + local constructor = v + -- Check that the constructors for all parent and superparent classes have been called + v = function(self, ...) + local ret = constructor(self, ...) + if class._parents then + for parent in pairs(class._superParents) do + if parent[parent._className] and not self._parentInit[parent] then + error("Parent class '" .. + parent._className .. "' of class '" .. className .. "' must be initialised") + end + end + end + if not ret then + error(string.format("Class %s constructor did not return a value", className)) + end + return ret end - rawset(self, k, v) end - }) + rawset(self, k, v) + end + setmetatable(class, classMeta) class._unconstructedMeta = { __index = function(obj, key) if key == className then @@ -157,46 +151,6 @@ function newClass(className, ...) } return class end - --- avoid rebuilding metatables constantly. this is done by caching class-parent pair metatables -local function getMeta(class, parent) - local metaList = rawget(class, "_metaList") - local meta = metaList[parent] - if not meta then - local parentName = parent._className - meta = { - __index = function(proxy, key) - local object = rawget(proxy, "_object") - local v = rawget(object, key) - if v ~= nil then - return v - else - return parent[key] - end - end, - __newindex = function(proxy, k, v) - local object = rawget(proxy, "_object") - object[k] = v - end, - __call = function(proxy, self, ...) - local object = rawget(proxy, "_object") - if not parent[parentName] then - error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has no constructor") - end - if object._parentInit[parent] then - error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has already been initialised") - end - if self ~= object then - error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parentName, class._className, parentName, parentName)) - end - parent[parent._className](self, ...) - object._parentInit[parent] = true - end, - } - metaList[parent] = meta - end - return meta -end ---@generic T ---@param className `T` ---@param extraArg nil Never pass extra parameters. Defined purely to guard against old syntax. @@ -215,8 +169,45 @@ function new(className, extraArg) if class._parents then -- Add parent and superparent class proxies object._parentInit = { } + local metaList = rawget(class, "_metaList") for parent in pairs(class._superParents) do - object[parent._className] = setmetatable({ _object = object }, getMeta(class, parent)) + local meta = metaList[parent] + if not meta then + local proxyParent = parent + local parentName = parent._className + -- Cache one proxy metatable for each class-parent pair instead of rebuilding it for every object + meta = { + __index = function(proxy, key) + local proxyObject = rawget(proxy, "_object") + local value = rawget(proxyObject, key) + if value ~= nil then + return value + else + return proxyParent[key] + end + end, + __newindex = function(proxy, k, v) + local proxyObject = rawget(proxy, "_object") + proxyObject[k] = v + end, + __call = function(proxy, self, ...) + local proxyObject = rawget(proxy, "_object") + if not proxyParent[parentName] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has no constructor") + end + if proxyObject._parentInit[proxyParent] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has already been initialised") + end + if self ~= proxyObject then + error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parentName, class._className, parentName, parentName)) + end + proxyParent[parentName](self, ...) + proxyObject._parentInit[proxyParent] = true + end, + } + metaList[parent] = meta + end + object[parent._className] = setmetatable({ _object = object }, meta) end end return object @@ -551,7 +542,10 @@ function specCopy(env) return modDB, enemyDB, minionDB end --- Wipe all keys from the table and return it, or return a new table if no table provided +-- Wipe all keys from the table and return it, or return a new table if no table +-- provided. This is useful to avoid alllocations in hot paths if a table can be reused. Using LuaJIT's `table.clear()` is another alternative to this, but this performs similarly on small tables, or tables which are often already empty. +---@param tbl table? +---@return table tbl function wipeTable(tbl) if not tbl then return { }