diff --git a/spec/System/TestItemVariants_spec.lua b/spec/System/TestItemVariants_spec.lua index 0e849fb05d..c41bc97f4c 100644 --- a/spec/System/TestItemVariants_spec.lua +++ b/spec/System/TestItemVariants_spec.lua @@ -445,4 +445,160 @@ describe("Versioned item variants", function() assert.equals(10, restored.baseModList:Sum("BASE", nil, "Life")) end) end) + + describe("Base Variant selection (separate from mod variants)", function() + it("requires both base and ordinary mod variant selections to match", function() + local item = new("Item"):Item([[ + Rarity: Unique + Combined Tag Test + {base:1}Gold Ring + {base:2}Iron Ring + Base Variant: Gold + Base Variant: Iron + Selected Base Variant: 1 + Variant: Life + Variant: Mana + Selected Variant: 2 + Implicits: 0 + {base:1}{variant:1}+10 to maximum Life + {base:1}{variant:2}+20 to maximum Mana + ]]) + assert.equals(0, item.baseModList:Sum("BASE", nil, "Life")) + assert.equals(20, item.baseModList:Sum("BASE", nil, "Mana")) + + item.variant = 1 + item:BuildAndParseRaw() + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + assert.equals(0, item.baseModList:Sum("BASE", nil, "Mana")) + + item.selectedBase = 2 + item:BuildAndParseRaw() + assert.equals(0, item.baseModList:Sum("BASE", nil, "Life")) + assert.equals(0, item.baseModList:Sum("BASE", nil, "Mana")) + end) + + it("selects a base independently from an independent variant", function() + local item = new("Item"):Item([[ + Rarity: Unique + Base Variant Independent Test + {base:1}Gold Ring + {base:2}Iron Ring + Base Variant: Gold + Base Variant: Iron + Selected Base Variant: 2 + Variant: Life + Variant: Mana + Selected Variant: 2 + Implicits: 0 + {variant:1}+10 to maximum Life + {variant:2}+20 to maximum Mana + ]]) + assert.equals("Iron Ring", item.baseName) + assert.equals(0, item.baseModList:Sum("BASE", nil, "Life")) + assert.equals(20, item.baseModList:Sum("BASE", nil, "Mana")) + + -- Changing the mod variant does not affect the selected base + item.variant = 1 + item:BuildAndParseRaw() + assert.equals("Iron Ring", item.baseName) + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + + -- Changing the base variant does not affect the selected mod variant + item.selectedBase = 1 + item:BuildAndParseRaw() + assert.equals("Gold Ring", item.baseName) + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + + assert.matches("Base Variant: Gold", item.raw, 1, true) + assert.matches("Base Variant: Iron", item.raw, 1, true) + assert.matches("Selected Base Variant: 1", item.raw, 1, true) + assert.matches("{base:1}Gold Ring", item.raw, 1, true) + assert.matches("{base:2}Iron Ring", item.raw, 1, true) + + local restored = new("Item"):Item(item.raw) + assert.equals("Gold Ring", restored.baseName) + assert.equals(1, restored.variant) + end) + + it("selects a base independently from the selected version", function() + local item = new("Item"):Item([[ + Rarity: Unique + Versioned Base Variant Test + {version:1}{base:1}Gold Ring + {version:1}{base:2}Iron Ring + {version:2}Ruby Ring + Version: Legacy + Version: Current + Selected Version: 1 + Base Variant: Gold + Base Variant: Iron + Selected Base Variant: 2 + Implicits: 0 + {version:1}+10 to maximum Life + {version:2}+20 to maximum Life + ]]) + assert.equals("Iron Ring", item.baseName) + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + + -- Changing the base variant does not affect the selected version's mods + item.selectedBase = 1 + item:BuildAndParseRaw() + assert.equals("Gold Ring", item.baseName) + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + + -- A version without per-base lines falls back to its one base regardless of the base variant selection + item.selectedVersion = 2 + item:BuildAndParseRaw() + assert.equals("Ruby Ring", item.baseName) + assert.equals(20, item.baseModList:Sum("BASE", nil, "Life")) + + item.selectedVersion = 1 + item:BuildAndParseRaw() + assert.matches("{base:1}{version:1}Gold Ring", item.raw, 1, true) + assert.matches("Selected Base Variant: 1", item.raw, 1, true) + + local restored = new("Item"):Item(item.raw) + assert.equals("Gold Ring", restored.baseName) + assert.equals(1, restored.selectedVersion) + end) + + it("selects a base independently from grouped variants", function() + local item = new("Item"):Item([[ + Rarity: Unique + Grouped Base Variant Test + {base:1}Gold Ring + {base:2}Iron Ring + Base Variant: Gold + Base Variant: Iron + Selected Base Variant: 2 + Variant: Life + Variant: Mana + Implicits: 0 + {variant:1}{group:1}+10 to maximum Life + {variant:2}{group:1}+20 to maximum Mana + ]]) + assert.equals("Iron Ring", item.baseName) + assert.same({ 1 }, item.variantGroupSelections) + assert.equals(10, item.baseModList:Sum("BASE", nil, "Life")) + + -- Changing the variant group selection does not affect the selected base + item.variantGroupSelections[1] = 2 + item:BuildAndParseRaw() + assert.equals("Iron Ring", item.baseName) + assert.equals(20, item.baseModList:Sum("BASE", nil, "Mana")) + + -- Changing the base variant does not affect the variant group selection + item.selectedBase = 1 + item:BuildAndParseRaw() + assert.equals("Gold Ring", item.baseName) + assert.equals(20, item.baseModList:Sum("BASE", nil, "Mana")) + + assert.matches("{base:1}Gold Ring", item.raw, 1, true) + assert.matches("Selected Base Variant: 1", item.raw, 1, true) + + local restored = new("Item"):Item(item.raw) + assert.equals("Gold Ring", restored.baseName) + assert.same({ 2 }, restored.variantGroupSelections) + end) + end) end) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index d36d34eb65..3720f43208 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -350,9 +350,11 @@ end local variantSelectionSpecNames = { ["Version"] = true, ["Variant"] = true, + ["Base Variant"] = true, ["Selected Version"] = true, ["Selected Variant Group"] = true, ["Selected Variant"] = true, + ["Selected Base Variant"] = true, } function ItemClass:HasVariantGroups() @@ -450,6 +452,7 @@ function ItemClass:GetUniqueDBItem() return dbItem end end +---@alias ItemRarity "NORMAL"|"MAGIC"|"RARE"|"UNIQUE"|"RELIC" ---@class ModLine A modifier line on an item. An in-game mod can translate to multiple ModLines. ---@field modList Mod[] ---@field line string The actual text for the line. This might describe a range of values, in which case applyRange() can be used with this and the range value to get a ranged line. @@ -462,6 +465,7 @@ end ---@field versionList table? ---@field variantGroupList table? ---@field modId string? +---@field rarity ItemRarity Defaults to unique, if not given in item string or constructor. local getRangedModList -- Parse raw item data and extract item name, base type, quality, and modifiers @@ -566,6 +570,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local implicitLines = 0 local skippedRuneLines = 0 self.variantList = nil + self.baseList = nil self.versionList = nil -- group ID -> variant ID -> eligible version IDs; version 0 means every version. ---@type table>> @@ -580,6 +585,10 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if specName == "Version" then self.versionList = self.versionList or { } t_insert(self.versionList, specVal) + elseif specName == "Base Variant" then + self.baseList = self.baseList or {} + self.selectedBase = self.selectedBase or 1 + t_insert(self.baseList, specVal) elseif specName == "Variant" then self.variantList = self.variantList or { } -- This has to be kept for backwards compatibility @@ -594,18 +603,22 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end elseif specName == "Selected Variant" then self.variant = specToNumber(specVal) + elseif specName == "Selected Base Variant" then + self.selectedBase = specToNumber(specVal) end end local variantSpec = rawLine:match("{variant:([^}]*)}") local versionSpec = rawLine:match("{version:([^}]*)}") local groupSpec = rawLine:match("{group:([^}]*)}") - if variantSpec or versionSpec or groupSpec then + local baseSpec = rawLine:match("{base:([^}]*)}") + if variantSpec or versionSpec or groupSpec or baseSpec then local selectionTags = { line = rawLine, variantList = variantSpec and parseIdSpec(variantSpec) or nil, versionList = versionSpec and parseIdSpec(versionSpec) or nil, variantGroupList = groupSpec and parseIdSpec(groupSpec, true) or nil, + baseList = baseSpec and parseIdSpec(baseSpec) or nil, } selectionTagsByLine[lineIndex] = selectionTags end @@ -879,6 +892,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.variantAlt4 = specToNumber(specVal) elseif specName == "Selected Alt Variant Five" then self.variantAlt5 = specToNumber(specVal) + elseif specName == "Selected Base Variant" then + self.selectedBase = specToNumber(specVal) elseif specName == "Allow Duplicate Variants" then self.allowDuplicateVariants = specVal == "true" elseif specName == "Has Variants" or specName == "Selected Variants" then @@ -968,6 +983,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) modLine.variantList = selectionTags and selectionTags.variantList or parseIdSpec(val) elseif k == "version" then modLine.versionList = selectionTags and selectionTags.versionList or parseIdSpec(val) + elseif k == "base" then + modLine.baseVariantList = selectionTags and selectionTags.baseList or parseIdSpec(val) elseif k == "group" then modLine.variantGroupList = selectionTags and selectionTags.variantGroupList or parseIdSpec(val, true) elseif k == "tags" then @@ -1054,21 +1071,23 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) baseName = "Two-Toned Boots (Armour/Energy Shield)" end local base = data.itemBases[baseName] - if baseName:find("Runeforged") or baseName:find("Runemastered") then - self.runicItem = true - end if base then -- Items with variants can have multiple bases self.baseLines[baseName] = { line = baseName, variantList = modLine.variantList, + baseVariantList = modLine.baseVariantList, versionList = modLine.versionList, variantGroupList = modLine.variantGroupList, } -- Set the actual base if variant matches or doesn't have variants local usesVersionedOrGroupedVariants = self:UsesVersionedOrGroupedVariants() local baseMatches = usesVersionedOrGroupedVariants and self:CheckModLineVariant(modLine) - or (not usesVersionedOrGroupedVariants and (not self.variant or not modLine.variantList or modLine.variantList[self.variant])) + or (not usesVersionedOrGroupedVariants and + ((not self.variant or not modLine.variantList or modLine.variantList[self.variant]) + and (not self.selectedBase or not modLine.baseVariantList or modLine.baseVariantList[self.selectedBase]) + ) + ) if baseMatches then self.baseName = baseName if not (self.rarity == "NORMAL" or self.rarity == "MAGIC") then @@ -1356,6 +1375,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) ::continue:: l = l + 1 end + if self.baseName and (self.baseName:find("Runeforged") or self.baseName:find("Runemastered")) then + self.runicItem = true + end if self.baseName and self.title then self.name = self.title .. ", " .. self.baseName:gsub(" %(.+%)","") end @@ -1967,6 +1989,9 @@ function ItemClass:BuildRaw() if modLine.versionList then prependToAllLines("{version:" .. makeIdSpec(modLine.versionList) .. "}") end + if modLine.baseVariantList then + prependToAllLines("{base:" .. makeIdSpec(modLine.baseVariantList) .. "}") + end if not hasNewSelection and modLine.modTags and #modLine.modTags > 0 then line = "{tags:" .. table.concat(modLine.modTags, ",") .. "}" .. line end @@ -1980,6 +2005,14 @@ function ItemClass:BuildRaw() t_insert(rawLines, "Selected Version: " .. self.selectedVersion) end end + if self.baseList then + for _, baseName in ipairs(self.baseList) do + t_insert(rawLines, "Base Variant: " .. baseName) + end + if self.selectedBase then + t_insert(rawLines, "Selected Base Variant: " .. self.selectedBase) + end + end if self.variantList then for _, variantName in ipairs(self.variantList) do t_insert(rawLines, "Variant: " .. variantName) @@ -1998,7 +2031,7 @@ function ItemClass:BuildRaw() end for _, baseLine in pairs(self.baseLines or { }) do - if baseLine.variantList or baseLine.versionList or baseLine.variantGroupList then + if baseLine.variantList or baseLine.versionList or baseLine.variantGroupList or baseLine.baseVariantList then writeModLine(baseLine) end end @@ -2028,7 +2061,7 @@ function ItemClass:BuildRaw() end if not self.variantList then for _, baseLine in pairs(self.baseLines or { }) do - if baseLine.versionList or baseLine.variantGroupList then + if baseLine.versionList or baseLine.variantGroupList or baseLine.baseVariantList then writeModLine(baseLine) end end @@ -2291,6 +2324,9 @@ function ItemClass:CheckModLineVariant(modLine) if modLine.versionList and (not self.selectedVersion or not modLine.versionList[self.selectedVersion]) then return false end + if modLine.baseVariantList and (not self.selectedBase or not modLine.baseVariantList[self.selectedBase]) then + return false + end if modLine.variantGroupList then if not modLine.variantList then return false @@ -2308,6 +2344,9 @@ function ItemClass:CheckModLineVariant(modLine) end return not modLine.variantList end + if self.baseList and modLine.baseVariantList and not modLine.baseVariantList[self.selectedBase] then + return false + end return not modLine.variantList or modLine.variantList[self.variant] or (self.hasAltVariant and modLine.variantList[self.variantAlt]) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index d6fe6a271f..2839daa225 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -491,6 +491,9 @@ holding Shift will put it in the second.]]) end if self.displayItem:UsesVersionedOrGroupedVariants() then local rows = self.displayItem.versionList and #self.displayItem.versionList > 1 and 1 or 0 + if self.displayItem.baseList and #self.displayItem.baseList then + rows += 1 + end if self.displayItem:HasIndependentVariants() then rows = rows + (#self.displayItem.variantList > 1 and 1 or 0) else @@ -504,15 +507,16 @@ holding Shift will put it in the second.]]) end return rows > 0 and rows * 24 + 4 or 0 end - if not self.controls.displayItemVariant:IsShown() then + if not self.controls.displayItemVariant:IsShown() and not self.controls.displayItemBaseVariant:IsShown() then return 0 end return (28 + - (self.displayItem.hasAltVariant and 24 or 0) + - (self.displayItem.hasAltVariant2 and 24 or 0) + - (self.displayItem.hasAltVariant3 and 24 or 0) + - (self.displayItem.hasAltVariant4 and 24 or 0) + - (self.displayItem.hasAltVariant5 and 24 or 0)) + (self.displayItem.baseList and 24 or 0) + + (self.displayItem.hasAltVariant and 24 or 0) + + (self.displayItem.hasAltVariant2 and 24 or 0) + + (self.displayItem.hasAltVariant3 and 24 or 0) + + (self.displayItem.hasAltVariant4 and 24 or 0) + + (self.displayItem.hasAltVariant5 and 24 or 0)) end}) self.controls.displayItemVersion = new("DropDownControl"):DropDownControl({ "TOPLEFT", self.controls.displayItemSectionVariant, "TOPLEFT" }, { 0, 0, 300, 20 }, nil, function(index, value) self.displayItem.selectedVersion = index @@ -528,11 +532,30 @@ holding Shift will put it in the second.]]) return self.displayItem and self.displayItem:UsesVersionedOrGroupedVariants() and self.displayItem.versionList and #self.displayItem.versionList > 1 end + self.controls.displayItemBaseVariant = new("DropDownControl"):DropDownControl({ "TOPLEFT", self.controls.displayItemSectionVariant, "TOPLEFT" }, { 0, 0, 300, 20 }, nil, function(index, value) + self.displayItem.selectedBase = index + self.displayItem:NormaliseVariantSelections() + self.displayItem:BuildAndParseRaw() + self:UpdateDisplayItemVariantControls() + self:UpdateRuneControls() + self:UpdateDisplayItemTooltip() + self:UpdateDisplayItemRangeLines() + end) + self.controls.displayItemBaseVariant.y = function() + return self.controls.displayItemVersion:IsShown() and 24 or 0 + end + self.controls.displayItemBaseVariant.maxDroppedWidth = 1000 + self.controls.displayItemBaseVariant.shown = function() + return self.displayItem.baseList and #self.displayItem.baseList > 1 + end self.controls.displayItemVariant = new("DropDownControl"):DropDownControl({ "TOPLEFT", self.controls.displayItemSectionVariant, "TOPLEFT" }, { 0, 0, 300, 20 }, nil, function(index, value) self:SelectDisplayItemVariant(index, value, "variant", self.controls.displayItemVariant) end) self.controls.displayItemVariant.y = function() - return self.controls.displayItemVersion:IsShown() and 24 or 0 + local y = 0 + y += self.controls.displayItemBaseVariant:IsShown() and 24 or 0 + y += self.controls.displayItemVersion:IsShown() and 24 or 0 + return y end self.controls.displayItemVariant.maxDroppedWidth = 1000 self.controls.displayItemVariant.shown = function() @@ -2064,6 +2087,7 @@ function ItemsTabClass:UpdateDisplayItemVariantControls() end -- Sets the display item to the given item +---@param item Item function ItemsTabClass:SetDisplayItem(item) self.displayItem = item if item then @@ -2079,6 +2103,9 @@ function ItemsTabClass:SetDisplayItem(item) self.controls.displayItemVariant.selIndex = item.variant self.controls.displayItemVariant:CheckDroppedWidth(true) end + self.controls.displayItemBaseVariant.list = item.baseList or {} + self.controls.displayItemBaseVariant.selIndex = item.selectedBase or 1 + self.controls.displayItemBaseVariant:CheckDroppedWidth(true) if not usesVersionedOrGroupedVariants and item.hasAltVariant then self.controls.displayItemAltVariant.list = item.variantList self.controls.displayItemAltVariant.selIndex = item.variantAlt diff --git a/src/Modules/Main.lua b/src/Modules/Main.lua index 4b664c4868..69c4eec866 100644 --- a/src/Modules/Main.lua +++ b/src/Modules/Main.lua @@ -155,8 +155,36 @@ function main:Init() local function loadItemDBs() for type, typeList in pairsYield(data.uniques) do for _, raw in pairs(typeList) do - newItem = new("Item"):Item(raw, "UNIQUE", true) + local newItem = new("Item"):Item(raw, "UNIQUE", true) if newItem.base then + local baseBase = newItem.baseName + -- uniques with base variants are skipped as they can be handled manually + local hasBaseVariants = newItem.baseLines and not not next(newItem.baseLines) + if newItem.rarity == "UNIQUE" and not hasBaseVariants then + -- look for alternate runeforging bases + local bases = { { variantName = "Regular Base", baseName = baseBase } } + if data.itemBases["Runeforged " .. baseBase] then + table.insert(bases, { variantName = "Runeforged", baseName = "Runeforged " .. baseBase }) + end + if data.itemBases["Runemastered " .. baseBase] then + table.insert(bases, { variantName = "Runemastered", baseName = "Runemastered " .. baseBase }) + end + if #bases > 1 then + newItem.baseList = newItem.baseList ?? {} + local baseLines = {} + -- Add variants for each base + for _, base in ipairs(bases) do + local baseVariantList = { [#newItem.baseList + 1] = true, } + baseLines[base.baseName] = { line = base.baseName, baseVariantList = baseVariantList } + table.insert(newItem.baseList, base.variantName) + end + newItem.baseLines = baseLines + -- default to the original base + newItem.selectedBase = 1 + + newItem:BuildAndParseRaw() + end + end self.uniqueDB.list[newItem.name] = newItem elseif launch.devMode then ConPrintf("Unique DB unrecognised item of type '%s':\n%s", type, raw) @@ -168,7 +196,7 @@ function main:Init() ConPrintf("Uniques loaded") for _, raw in pairsYield(data.rares) do - newItem = new("Item"):Item(raw, "RARE", true) + local newItem = new("Item"):Item(raw, "RARE", true) if newItem.base then if newItem.crafted then if newItem.base.implicit and #newItem.implicitModLines == 0 then