otclient-redemption/modules/game_forge/game_forge.lua
Kizuno18 713ab7a86f
fix(forge): convergence item list not refreshing and negative gold guard (#1675)
Bug Fixes:
- Fixed an issue where item prices could become negative; prices are now clamped to a minimum of zero.

Refactor:
- Internal list updates now modify existing lists in-place (instead of replacing them), improving UI stability and ensuring list views and bindings remain consistent when switching modes or refreshing content.
2026-04-19 21:48:17 -03:00

1254 lines
44 KiB
Lua

ForgeController = Controller:new()
ForgeButton = nil
local cloneValue = Helpers.cloneValue
local replaceTableContents = Helpers.replaceTableContents
local normalizeClassPriceEntries = Helpers.normalizeClassPriceEntries
local normalizeTierPriceEntries = Helpers.normalizeTierPriceEntries
-- Don't cache formatHistoryDate as a local since it may be updated
-- local formatHistoryDate = Helpers.formatHistoryDate
-- Store all callback references to prevent garbage collection
ForgeController.callbacks = {}
ForgeController.showResult = false
ForgeController.showBonus = false
local rightArrow = Helpers.rightArrow
local filledRightArrow = Helpers.filledRightArrow
ForgeController.result = cloneValue(Helpers.baseResult)
ForgeController.baseResult = Helpers.baseResult
function ForgeController:onGameStart()
if g_game.getFeature(GameForgeConvergence) then -- Summer Update 2017
self:updateResourceBalances()
-- Store event callbacks to prevent garbage collection
self.callbacks.onBrowseForgeHistory = onBrowseForgeHistory
self.callbacks.forgeData = forgeData
self.callbacks.onOpenForge = onOpenForge
self.callbacks.forgeResultData = forgeResultData
self.callbacks.onResourcesBalanceChange = function() self:updateResourceBalances() end
self:registerEvents(g_game, {
onBrowseForgeHistory = self.callbacks.onBrowseForgeHistory,
forgeData = self.callbacks.forgeData,
onOpenForge = self.callbacks.onOpenForge,
onResourcesBalanceChange = self.callbacks.onResourcesBalanceChange,
forgeResultData = self.callbacks.forgeResultData
})
if not ForgeButton then
ForgeButton = modules.game_mainpanel.addToggleButton('ForgeButton', tr('Open Exaltation Forge'),
'/images/options/button-exaltation-forge.png', function()
self:toggle()
end)
end
self.currentTab = 'conversion'
else
-- Store unload callback
self.callbacks.unloadModule = function()
g_modules.getModule("game_forge"):unload()
end
scheduleEvent(self.callbacks.unloadModule, 100)
end
end
local function resetInfo()
ForgeController.transfer.exaltedCoresLabel = "???"
ForgeController.transfer.selected = cloneValue(ForgeController.baseSelected)
ForgeController.transfer.selectedTarget = cloneValue(ForgeController.baseSelected)
ForgeController.rawPrice = 0
ForgeController.formattedPrice = "???"
ForgeController.transfer.canTransfer = false
ForgeController.transfer.isConvergence = false
ForgeController.transfer.title = "Transfer Requirements"
ForgeController.transfer.dustLabel = ForgeController.transfer.dust
replaceTableContents(ForgeController.transfer.currentList, ForgeController.transfer.items)
ForgeController.fusion.title = "Further Items Needed For Fusion"
ForgeController.fusion.selected = cloneValue(ForgeController.baseSelected)
ForgeController.fusion.selectedTarget = cloneValue(ForgeController.baseSelected)
ForgeController.fusion.isConvergence = false
ForgeController.fusion.canTransfer = false
ForgeController.fusion.canFusion = false
ForgeController.fusion.chanceImprovedChecked = false
ForgeController.fusion.reduceTierLossChecked = false
ForgeController.fusion.dustLabel = ForgeController.fusion.dust
replaceTableContents(ForgeController.fusion.currentList, ForgeController.fusion.items)
ForgeController.showResult = false
ForgeController.showBonus = false
ForgeController.result = cloneValue(Helpers.baseResult)
ForgeController.description = ""
ForgeController.waitingForResult = false
if ForgeController.resultTimeout then
removeEvent(ForgeController.resultTimeout)
ForgeController.resultTimeout = nil
end
end
function ForgeController:handleDescription(currentType)
Helpers.handleDescription(ForgeController, currentType)
end
function ForgeController:show(skipRequest)
if not g_game.getFeature(GameForgeConvergence) then
return ForgeController:hide()
end
resetInfo()
local needsReload = not self.ui or self.ui:isDestroyed()
if needsReload then
self:loadHtml('game_forge.html')
end
if not self.ui then
return
end
if not skipRequest then
g_game.openPortableForgeRequest()
end
self.ui:centerIn('parent')
self.ui:show()
self.ui:raise()
self.ui:focus()
if ForgeButton then
ForgeButton:setOn(true)
end
self:updateResourceBalances()
end
function ForgeController:hide()
resetInfo()
if not self.ui then
return
end
self.ui:hide()
if ForgeButton then
ForgeButton:setOn(false)
end
if self.ui and not self.ui:isDestroyed() then
self.ui:destroy()
end
self.ui = nil
end
function ForgeController:toggle()
ForgeController:toggleConversionMenu()
resetInfo()
if ForgeController.rawData then
forgeData(ForgeController.rawData)
end
if not self.ui or self.ui:isDestroyed() then
self:show()
return
end
if self.ui:isVisible() then
self:hide()
else
self:show()
end
end
ForgeController.currentDust = 0
ForgeController.currentSlivers = 0
ForgeController.currentExaltedCores = 0
ForgeController.rawCurrentGold = 0
ForgeController.currentGold = 0
ForgeController.currentTab = 'conversion'
function ForgeController:updateResourceBalances()
if not self.ui then
return
end
local player = g_game.getLocalPlayer()
self.currentDust = player:getResourceBalance(ResourceTypes.FORGE_DUST or 70)
self.currentSlivers = player:getResourceBalance(ResourceTypes.FORGE_SLIVER)
self.currentExaltedCores = player:getResourceBalance(ResourceTypes.FORGE_CORES)
self.rawCurrentGold = player:getTotalMoney() or 0
self.currentGold = comma_value(self.rawCurrentGold)
end
ForgeController.baseSelected = {
id = -1,
tier = 0,
clip = {},
imagePath = nil,
rarityClipObject = nil,
count = 0,
countLabel = "0 / 1",
key = "",
targetTier = 0
}
function ForgeController:onInit()
end
function ForgeController:terminate()
-- Clean up any pending events
if ForgeController.resultTimeout then
removeEvent(ForgeController.resultTimeout)
ForgeController.resultTimeout = nil
end
-- Clean up all stored callbacks
if ForgeController.callbacks then
for key, _ in pairs(ForgeController.callbacks) do
ForgeController.callbacks[key] = nil
end
ForgeController.callbacks = {}
end
-- Reset flags
ForgeController.waitingForResult = false
ForgeController.showResult = false
ForgeController.showBonus = false
-- Hide UI
if self.ui and not self.ui:isDestroyed() then
self:hide()
end
-- Remove button
if ForgeButton then
ForgeButton:destroy()
ForgeButton = nil
end
end
function ForgeController:getPrice(prices, itemId, currentTier, isConvergence, isTransfer)
local price = 0
local tierIndex = (tonumber(currentTier) or 0) + 1
if isTransfer and isConvergence then
for tier, _price in pairs(prices) do
if isConvergence then
tier = tier > 1 and (tier - 1) or tier
end
if currentTier == tier then
price = tonumber(_price) or 0
break
end
end
elseif isConvergence then
for tier, _price in pairs(prices) do
if tierIndex == tier then
price = tonumber(_price) or 0
break
end
end
else
local itemPtr = Item.create(itemId, 1)
local classification = itemPtr and itemPtr.getClassification and itemPtr:getClassification() or 0
if classification <= 0 then return 0 end
for class, tiers in pairs(prices) do
if class == classification then
for tier, _price in pairs(tiers) do
if tier == tierIndex or (isTransfer and tier == currentTier) then
price = tonumber(_price) or 0
break
end
end
end
end
end
-- Guard against negative prices from server data (e.g. uint64 overflow in pushInteger).
-- A zero price will block forgeAction() via the rawPrice <= 0 check, which is intentional:
-- the client should not allow forging when the server sends invalid pricing.
if price < 0 then price = 0 end
self.rawPrice = price
self.formattedPrice = comma_value(price)
return self.formattedPrice
end
local forgeActions = {
FUSION = 0,
TRANSFER = 1
}
-- Named callback functions to prevent garbage collection issues
ForgeController.callbacks.closeResultCallback = function()
ForgeController.showResult = false
ForgeController.showBonus = false
ForgeController:hide()
if ForgeController.rawOpenForgeData then
onOpenForge(ForgeController.rawOpenForgeData)
end
end
ForgeController.callbacks.updateBonusButton = function()
ForgeController.result.button = "Close"
ForgeController.result.bonusAction = ForgeController.callbacks.closeResultCallback
end
ForgeController.callbacks.showBonusCallback = function()
ForgeController.showResult = false
ForgeController.showBonus = true
-- Use the stored callback
scheduleEvent(ForgeController.callbacks.updateBonusButton, 150)
end
function ForgeController:closeResult()
ForgeController.callbacks.closeResultCallback()
end
function ForgeController:forgeAction(isTransfer)
local data = isTransfer and ForgeController.transfer or ForgeController.fusion
local canDoAction = isTransfer and data.canTransfer or data.canFusion
if data.selected.id == -1 then return end
if data.selectedTarget.id == -1 then return end
if not canDoAction then return end
if ForgeController.rawPrice <= 0 then return end
if ForgeController.rawCurrentGold < ForgeController.rawPrice then return end
if ForgeController.currentDust < data.dust then return end
if isTransfer then
if ForgeController.currentExaltedCores < data.necessaryExaltedCores then return end
end
local actionType = isTransfer and forgeActions.TRANSFER or forgeActions.FUSION
local chanceImproved = false
local reduceTierLoss = false
if not isTransfer then
chanceImproved = data.chanceImprovedChecked
reduceTierLoss = data.reduceTierLossChecked
end
-- Set a flag to indicate we're waiting for a result
ForgeController.waitingForResult = true
-- Set a timeout to show an error if no result is received
if ForgeController.resultTimeout then
removeEvent(ForgeController.resultTimeout)
end
-- Store the callback function to maintain a reference
ForgeController.callbacks.timeoutCallback = function()
if ForgeController.waitingForResult then
ForgeController.waitingForResult = false
-- If no result received after 5 seconds, refresh the forge window
if ForgeController.rawOpenForgeData then
onOpenForge(ForgeController.rawOpenForgeData)
end
end
end
ForgeController.resultTimeout = scheduleEvent(ForgeController.callbacks.timeoutCallback, 5000)
g_game.forgeRequest(actionType, data.isConvergence, data.selected.id, data.selected.tier,
data.selectedTarget.id, chanceImproved, reduceTierLoss)
end
function ForgeController:resultSystemEvent()
ForgeController.result.buttonLabel = "Close"
if not ForgeController.ui or ForgeController.ui:isDestroyed() then
return
end
if ForgeController.result.eventCount == 1 then
ForgeController.result.arrows = {
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.rightArrow, }
}
elseif ForgeController.result.eventCount == 2 then
ForgeController.result.arrows = {
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.rightArrow, }
}
elseif ForgeController.result.eventCount == 3 then
ForgeController.result.arrows = {
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.filledRightArrow, }
}
elseif ForgeController.result.eventCount == 4 then
ForgeController.result.arrows = {
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.filledRightArrow, }
}
elseif ForgeController.result.eventCount == 5 then
ForgeController.result.arrows = {
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.filledRightArrow, }
}
elseif ForgeController.result.eventCount == 6 then
ForgeController.result.arrows = {
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.rightArrow, }
}
if (ForgeController.result.bonus or 0) > 0 then
ForgeController.result.buttonLabel = "Next"
end
if ForgeController.result.success then
ForgeController.result.rightShader = "Outfit - ForgeSuccess"
ForgeController.result.label = "Your transfer attempt was "
ForgeController.result.labelResult = "successful."
ForgeController.result.color = Helpers.green
ForgeController.result.rightShader = ""
ForgeController.result.leftItemId = -1
ForgeController.result.leftTier = 0
else
ForgeController.result.label = "Your transfer attempt was "
ForgeController.result.labelResult = "failed."
ForgeController.result.rightShader = "Outfit - ForgeFailed"
ForgeController.result.leftShader = ""
ForgeController.result.color = Helpers.red
-- Store the callback to maintain reference
ForgeController.callbacks.clearRightItem = function()
ForgeController.result.rightItemId = -1
ForgeController.result.rightTier = 0
end
scheduleEvent(ForgeController.callbacks.clearRightItem, 1000)
end
return
end
ForgeController.result.eventCount = (ForgeController.result.eventCount or 0) + 1
-- Store the callback to maintain reference
ForgeController.callbacks.continueAnimation = function()
ForgeController:resultSystemEvent()
end
scheduleEvent(ForgeController.callbacks.continueAnimation, 750)
end
function forgeResultData(rawData)
-- Clear the waiting flag and timeout
ForgeController.waitingForResult = false
if ForgeController.resultTimeout then
removeEvent(ForgeController.resultTimeout)
ForgeController.resultTimeout = nil
end
-- Ensure UI is loaded before showing results
if not ForgeController.ui or ForgeController.ui:isDestroyed() then
ForgeController:show(true)
end
if not ForgeController.ui then
-- UI failed to load, abort
return
end
ForgeController.result = cloneValue(Helpers.baseResult)
ForgeController.showResult = true
ForgeController.showBonus = false
local data = cloneValue(rawData)
if type(data) == 'table' then
for key, value in pairs(data) do
ForgeController.result[key] = value
end
end
if ForgeController.result.leftTier > 0 then
ForgeController.result.leftClip = ItemsDatabase.getTierClip(ForgeController.result.leftTier)
end
if ForgeController.result.rightTier > 0 then
ForgeController.result.rightClip = ItemsDatabase.getTierClip(ForgeController.result.rightTier)
end
ForgeController.result.arrows = {
{ arrow = Helpers.filledRightArrow, },
{ arrow = Helpers.rightArrow, },
{ arrow = Helpers.rightArrow, }
}
ForgeController.result.buttonLabel = "Close"
ForgeController.result.bonusLabel = "-"
ForgeController.result.label = ""
if ForgeController.fusion.selected and ForgeController.fusion.selected.id ~= -1 then
if ForgeController.fusion.isConvergence then
ForgeController.result.title = "Convergence Fusion Result"
else
ForgeController.result.title = "Fusion Result"
end
end
if ForgeController.transfer.selected and ForgeController.transfer.selected.id ~= -1 then
if ForgeController.transfer.isConvergence then
ForgeController.result.title = "Convergence Transfer Result"
else
ForgeController.result.title = "Transfer Result"
end
end
if ForgeController.result.bonus == 0 then
ForgeController.result.bonusAction = ForgeController.callbacks.closeResultCallback
else
ForgeController.result.buttonLabel = "Next"
local isConvergence = ForgeController.transfer.selected == -1 and ForgeController.fusion.isConvergence
if ForgeController.result.bonus == 1 then
ForgeController.result.bonusItem = 37160
local dust = isConvergence and ForgeController.fusion.convergenceDust or ForgeController.transfer.dust
ForgeController.result.bonusLabel = string.format("Near! The used %s where not consumed.", dust)
elseif ForgeController.result.bonus == 2 then
if ForgeController.fusion.chanceImprovedChecked or ForgeController.fusion.reduceTierLossChecked then
ForgeController.result.bonusItem = 37110
ForgeController.result.bonusLabel = string.format("Fantastic! The used %s where not consumed.",
ForgeController.result.coreCount)
end
elseif ForgeController.result.bonus == 3 then
ForgeController.result.bonusItem = 3031
ForgeController.result.bonusLabel = string.format("Awesome! The used %s where not consumed.",
comma_value(ForgeController.rawPrice))
elseif ForgeController.result.bonus == 4 then
ForgeController.result.bonusItem = ForgeController.result.leftItemId
if ForgeController.result.rightTier >= 2 then
ForgeController.result.bonusTier = ForgeController.result.rightTier - 1
ForgeController.result.bonusItemClip = ItemsDatabase.getTierClip(ForgeController.result.bonusTier)
end
ForgeController.result.bonusLabel = "What luck! Your item only lost one tier instead of being\nconsumed."
end
ForgeController.result.bonusAction = ForgeController.callbacks.showBonusCallback
end
ForgeController.result.leftShader = "Outfit - ForgeDonor"
ForgeController.result.rightShader = "Outfit - cyclopedia-black"
ForgeController.result.eventCount = 1
ForgeController.callbacks.startResultAnimation = function()
ForgeController:resultSystemEvent()
end
scheduleEvent(ForgeController.callbacks.startResultAnimation, 750)
end
local buttonClip = { x = 0, y = 0, width = 43, height = 20 }
local buttonClipPressed = { x = 0, y = 20, width = 43, height = 20 }
-- FUSION MENU
ForgeController.fusion = {
prices = {},
convergencePrices = {},
grades = {},
dust = 100,
convergenceDust = 130,
reduceTierLoss = 50,
reduceTierLossLabel = "100%",
reduceTierLossButtonClip = buttonClip,
reduceTierLossChecked = false,
reduceTierLossBase = 100,
chanceImprovedLabel = "50%",
chanceImprovedButtonClip = buttonClip,
chanceImprovedChecked = false,
chanceImproved = 15,
chanceBase = 50,
dustLabel = 100,
hasConvergence = false,
isConvergence = false,
title = "Further Items Needed For Fusion",
clip = { x = 0, y = 0, width = 116, height = 34 },
items = {},
selected = cloneValue(ForgeController.baseSelected),
selectedTarget = cloneValue(ForgeController.baseSelected),
canFusion = false,
currentList = {},
}
-- Store callback in ForgeController to prevent garbage collection
ForgeController.fusion.handleSelect = function(item)
ForgeController.fusion.selectedTarget = cloneValue(ForgeController.baseSelected)
ForgeController:handleSelect(ForgeController.fusion, item, false)
end
-- Store callback in ForgeController to prevent garbage collection
ForgeController.fusion.exaltedCoreImprovements = function(currentType)
if currentType == 'improve-chance' then
ForgeController.fusion.chanceImprovedChecked = not ForgeController.fusion.chanceImprovedChecked
if ForgeController.fusion.chanceImprovedChecked then
ForgeController.fusion.chanceImprovedButtonClip = buttonClipPressed
ForgeController.fusion.chanceImprovedLabel = tostring(ForgeController.fusion.chanceBase +
ForgeController.fusion.chanceImproved) .. "%"
else
ForgeController.fusion.chanceImprovedButtonClip = buttonClip
ForgeController.fusion.chanceImprovedLabel = tostring(ForgeController.fusion.chanceBase) .. "%"
end
elseif currentType == 'reduce-loss' then
ForgeController.fusion.reduceTierLossChecked = not ForgeController.fusion.reduceTierLossChecked
if ForgeController.fusion.reduceTierLossChecked then
ForgeController.fusion.reduceTierLossButtonClip = buttonClipPressed
ForgeController.fusion.reduceTierLossLabel = tostring(ForgeController.fusion.reduceTierLoss) .. "%"
else
ForgeController.fusion.reduceTierLossButtonClip = buttonClip
ForgeController.fusion.reduceTierLossLabel = tostring(ForgeController.fusion.reduceTierLossBase) .. "%"
end
end
end
function ForgeController:toggleFusionMenu()
self:resetTabsClip()
self.currentTab = 'fusion'
self.fusion.clip = { x = 0, y = 34, width = 116, height = 34 }
end
-- TRANSFER MENU
ForgeController.transfer = {
prices = {},
convergencePrices = {},
grades = {},
dust = 100,
convergenceDust = 160,
hasConvergence = false,
title = "Transfer Requirements",
dustLabel = 100,
exaltedCoresLabel = "???",
necessaryExaltedCores = 0,
clip = { x = 0, y = 0, width = 116, height = 34 },
isConvergence = false,
items = {
donors = {},
receivers = {}
},
convergenceItems = {
donors = {},
receivers = {}
},
currentList = {
donors = {},
receivers = {}
},
selected = cloneValue(ForgeController.baseSelected),
selectedTarget = cloneValue(ForgeController.baseSelected),
canTransfer = false
}
function ForgeController:toggleTransferMenu()
self:resetTabsClip()
self.currentTab = 'transfer'
self.transfer.clip = { x = 0, y = 34, width = 116, height = 34 }
end
local function handleFusionItems(data)
local currentList = {}
for i, item in pairs(data) do
item.key = ("%d_%d"):format(item.id, item.tier)
if item.tier > 0 then
item.clip = ItemsDatabase.getTierClip(item.tier)
end
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(item.id)
if imagePath then
item.imagePath = imagePath
item.rarityClipObject = rarityClipObject
end
item.countLabel = string.format("%d / %d", item.count, 1)
table.insert(currentList, item)
end
table.sort(currentList, function(a, b)
if a.tier == b.tier then
return a.id < b.id
end
return a.tier > b.tier
end)
return currentList
end
function ForgeController:handleSelect(data, item, isTransfer)
data.selected = cloneValue(ForgeController.baseSelected)
data.selectedTarget = cloneValue(ForgeController.baseSelected)
data.canFusion = false
data.canTransfer = false
local isConvergence = data.isConvergence
if isTransfer then
data.selected = item
local targetTier = isConvergence and item.tier or (item.tier - 1)
for _, values in pairs(data.grades) do
if values.tier == targetTier then
data.exaltedCoresLabel = values.exaltedCores
data.necessaryExaltedCores = values.exaltedCores
break
end
end
local list = isConvergence and data.convergenceItems or data.items
local receivers = {}
for _, _item in pairs(list.receivers) do
if item.id ~= _item.id then
table.insert(receivers, _item)
end
end
data.currentList.receivers = receivers
else
data.selected = cloneValue(item)
data.selected.targetTier = data.selected.tier + 1
data.selected.targetClip = ItemsDatabase.getTierClip(data.selected.targetTier)
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(data.selected.id)
if imagePath then
data.selected.imagePath = imagePath
data.selected.rarityClipObject = rarityClipObject
end
if not isConvergence then
data.selectedTarget = cloneValue(item)
data.selectedTarget.targetTier = data.selectedTarget.tier + 1
data.selectedTarget.targetClip = ItemsDatabase.getTierClip(data.selectedTarget.targetTier)
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(data.selectedTarget.id)
if imagePath then
data.selectedTarget.imagePath = imagePath
data.selectedTarget.rarityClipObject = rarityClipObject
end
end
end
local prices = isConvergence and data.convergencePrices or data.prices
ForgeController:getPrice(prices, item.id, data.selected.tier, isConvergence, isTransfer)
if ForgeController.rawPrice > 0 and ForgeController.rawCurrentGold >= ForgeController.rawPrice then
if ForgeController.currentDust >= ForgeController.fusion.dust then
if isConvergence then return end
ForgeController.fusion.canFusion = true
end
end
end
-- Store callback in ForgeController to prevent garbage collection
ForgeController.transfer.handleSelect = function(item)
ForgeController.transfer.exaltedCoresLabel = "???"
if not item then
return
end
ForgeController:handleSelect(ForgeController.transfer, item, true)
end
function ForgeController:handleSelectTarget(item, isTransfer)
local data = isTransfer and ForgeController.transfer or ForgeController.fusion
data.selectedTarget = cloneValue(ForgeController.baseSelected)
ForgeController.transfer.canTransfer = false
ForgeController.fusion.canFusion = false
if not item then
return
end
data.selectedTarget = cloneValue(item)
local targetTier = 0
if isTransfer then
targetTier = data.isConvergence and data.selected.tier or (data.selected.tier - 1)
else
targetTier = data.selected.tier + 1
end
data.selectedTarget.targetTier = targetTier
data.selectedTarget.clip = ItemsDatabase.getTierClip(data.selectedTarget.targetTier)
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(item.id)
if imagePath then
data.selectedTarget.imagePath = imagePath
data.selectedTarget.rarityClipObject = rarityClipObject
end
if ForgeController.currentDust < data.dust then return end
if ForgeController.rawPrice > 0 and ForgeController.rawCurrentGold >= ForgeController.rawPrice then
if isTransfer then
if ForgeController.currentExaltedCores < ForgeController.transfer.necessaryExaltedCores then
return
end
data.canTransfer = true
else
-- For convergence fusion, we need at least 2 items of the same type
if data.isConvergence then
if item.count >= 2 then
data.canFusion = true
end
else
data.canFusion = true
end
end
end
end
function ForgeController:toggleConvergence(isTransfer)
local data = isTransfer and ForgeController.transfer or ForgeController.fusion
data.isConvergence = not data.isConvergence
data.selected = cloneValue(ForgeController.baseSelected)
data.selectedTarget = cloneValue(ForgeController.baseSelected)
ForgeController.formattedPrice = "???"
ForgeController.rawPrice = 0
local source = data.isConvergence and data.convergenceItems or data.items
if isTransfer then
data.canTransfer = false
if not data.isConvergence then
data.title = "Transfer Requirements"
data.dustLabel = data.dust
else
data.title = "Convergence Transfer Requirements"
data.dustLabel = data.convergenceDust
end
else
data.canFusion = false
if not data.isConvergence then
data.title = "Further Items Needed For Fusion"
data.dustLabel = data.dust
else
data.dustLabel = data.convergenceDust
data.title = "Further Items Needed For Convergence Fusion"
end
end
-- Replace contents in-place to preserve the table reference for reactive UI
replaceTableContents(data.currentList, source)
end
-- TRANSFER MENU
local function handleTransferItems(data)
local currentList = {
donors = {},
receivers = {}
}
for slot, currentType in pairs(data) do
for transferType in pairs(currentType) do
for __, item in pairs(currentType[transferType]) do
item.key = string.format("%d_%d", item.id, item.tier)
item.slot = slot
if item.tier > 0 then
item.clip = ItemsDatabase.getTierClip(item.tier)
end
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(item.id)
if imagePath then
item.imagePath = imagePath
item.rarityClipObject = rarityClipObject
end
item.countLabel = string.format("%d / %d", item.count, 1)
table.insert(currentList[transferType], item)
end
end
end
table.sort(currentList.donors, function(a, b)
if a.tier == b.tier then
return a.id < b.id
end
return a.tier > b.tier
end)
return currentList
end
local function handleParseConvergenceFusionItems(data)
local parsedItemsBySlot = {}
for slot, sloItems in pairs(data) do
for _, item in pairs(sloItems) do
item.key = ("%d_%d"):format(item.id, item.tier)
item.slot = slot
if item.tier > 0 then
item.clip = ItemsDatabase.getTierClip(item.tier)
end
local _, imagePath, rarityClipObject = ItemsDatabase.getClipAndImagePath(item.id)
if imagePath then
item.imagePath = imagePath
item.rarityClipObject = rarityClipObject
end
table.insert(parsedItemsBySlot, item)
end
end
return data, parsedItemsBySlot
end
function onOpenForge(data)
ForgeController.rawOpenForgeData = data
-- Don't update if we're showing the result animation or waiting for result
if ForgeController.showResult or ForgeController.showBonus or ForgeController.waitingForResult then
return
end
ForgeController.fusion.chanceImprovedChecked = false
ForgeController.fusion.reduceTierLossChecked = false
ForgeController.conversion.dustMax = data.dustLevel or ForgeController.conversion.dustMax or 100
-- FUSION ITEMS
local items = cloneValue(data.fusionItems or {})
local fusionItems = handleFusionItems(items)
ForgeController.fusion.items = fusionItems
replaceTableContents(ForgeController.fusion.currentList, fusionItems)
local convergenceFusion = cloneValue(data.convergenceFusion or {})
local _, convergenceItemsBySlot = handleParseConvergenceFusionItems(convergenceFusion)
ForgeController.fusion.convergenceItems = convergenceItemsBySlot
-- FUSION ITEMS
-- TRANSFER ITEMS
local transfers = cloneValue(data.transfers or {})
local transferItems = handleTransferItems(transfers)
ForgeController.transfer.items = transferItems
replaceTableContents(ForgeController.transfer.currentList, transferItems)
local convergenceTransfers = cloneValue(data.convergenceTransfers or {})
local convergenceTransferItems = handleTransferItems(convergenceTransfers)
ForgeController.transfer.convergenceItems = convergenceTransferItems
-- TRANSFER ITEMS
local shouldShow = not ForgeController.ui or ForgeController.ui:isDestroyed() or not ForgeController.ui:isVisible()
if shouldShow then
ForgeController:show(true)
ForgeController:toggleFusionMenu()
end
end
function forgeData(data)
ForgeController.rawData = data
ForgeController:updateResourceBalances()
-- FUSION
local classPrices = normalizeClassPriceEntries(data.classPrices)
if next(classPrices) then
ForgeController.fusion.prices = classPrices
end
local convergenceFusion = normalizeTierPriceEntries(data.convergenceFusionPrices)
if next(convergenceFusion) then
ForgeController.fusion.convergencePrices = convergenceFusion
end
ForgeController.fusion.grades = data.fusionGrades or {}
ForgeController.fusion.convergenceDust = data.convergenceFusionDust or 130
ForgeController.fusion.dust = data.normalDustFusion or 100
ForgeController.fusion.reduceTierLoss = data.fusionReduceTierLoss or 50
ForgeController.fusion.chanceImproved = data.fusionChanceImproved or 15
ForgeController.fusion.chanceBase = data.fusionChanceBase or 50
ForgeController.fusion.hasConvergence = data.hasConvergence or false
ForgeController.fusion.dustLabel = ForgeController.fusion.dust
-- FUSION
-- TRANSFER
ForgeController.transfer.prices = ForgeController.fusion.prices or {}
local convergenceTransfer = normalizeTierPriceEntries(data.convergenceTransferPrices)
if next(convergenceTransfer) then
ForgeController.transfer.convergencePrices = convergenceTransfer
end
ForgeController.transfer.convergenceDust = data.convergenceDustTransfer or 160
ForgeController.transfer.dust = data.normalDustTransfer or 100
ForgeController.transfer.hasConvergence = data.hasConvergence or false
ForgeController.transfer.dustLabel = ForgeController.transfer.dust
ForgeController.transfer.grades = data.fusionGrades or {}
-- TRANSFER
-- CONVERSION
ForgeController.conversion.necessaryDustToSliver = 60
ForgeController.conversion.dustToSilver = data.dustToSilver or 3
ForgeController.conversion.dustToSilverLabel = string.format("Generate %d", ForgeController.conversion.dustToSilver)
ForgeController.conversion.sliverToCore = data.sliverToCore or 50
ForgeController.conversion.sliverToCoreLabel = "Generate 1"
ForgeController.conversion.dustCap = data.maxDustCap or 225
local dustMax = data.maxDustLevel or 100
ForgeController.conversion.dustMax = dustMax
ForgeController.conversion.dustMaxIncreaseCost = dustMax - 75
ForgeController.conversion:handleButtons()
-- CONVERSION
end
function ForgeController:getColor(currentType)
ForgeController.conversion:handleButtons()
local red = Helpers.red
local base = Helpers.grey
local green = Helpers.green
if currentType == "dustToSilver" then
if self.currentDust >= self.conversion.necessaryDustToSliver then
return base
else
return red
end
end
if currentType == "sliverToCore" then
if self.currentSlivers >= self.conversion.sliverToCore then
return base
else
return red
end
end
if currentType == "increaseDustLimit" then
if self.currentDust >= self.conversion.dustMaxIncreaseCost then
return base
else
return red
end
end
if currentType == "fusion-preview-count-label" then
if self.fusion.selected.id == -1 then
return red
end
end
if currentType == "transfer-preview-count-label" then
if self.transfer.selected.id == -1 then
return red
end
end
if currentType == "fusion-dust" then
local necessaryDust = self.fusion.isConvergence and self.fusion.convergenceDust or self.fusion.dust
if self.fusion.dustLabel ~= necessaryDust then
self.fusion.dustLabel = necessaryDust
end
if self.currentDust < necessaryDust then
return red
end
end
if currentType == "transfer-dust" or currentType == "transfer-exalted" then
if self.transfer.selected.id == -1 then
return red
end
if currentType == "transfer-dust" then
local necessaryDust = self.transfer.isConvergence and self.transfer.convergenceDust or self.transfer.dust
if self.transfer.dustLabel ~= necessaryDust then
self.transfer.dustLabel = necessaryDust
end
if self.currentDust < necessaryDust then
return red
end
end
if currentType == "transfer-exalted" then
if self.currentExaltedCores < self.transfer.necessaryExaltedCores then
return red
end
end
end
if currentType == "price" then
if self.rawPrice == 0 or self.rawPrice > self.rawCurrentGold then
return red
end
end
if currentType == "improve-chance" then
if not self.fusion.chanceImprovedChecked then
return red
else
return green
end
end
if currentType == "reduce-loss" then
if not self.fusion.reduceTierLossChecked then
return red
else
return green
end
end
if currentType == "improve-chance-cost" then
if self.currentExaltedCores <= 0 then return red end
if self.fusion.reduceTierLossChecked and self.currentExaltedCores == 1 then return red end
end
if currentType == "reduce-loss-cost" then
if self.currentExaltedCores <= 0 then return red end
if self.fusion.chanceImprovedChecked and self.currentExaltedCores == 1 then return red end
end
return base
end
-- CONVERSION MENU
ForgeController.conversion = {
fusionPrices = {},
dustCap = 225,
dustMax = 100,
necessaryDustToSliver = 60,
dustToSliver = 3,
sliverToCore = 50,
dustMaxIncreaseCost = 100,
dustToSilverLabel = "Generate 3",
sliverToCoreLabel = "Generate 1",
dustMaxLabel = "Raise limit from\n100 to 101",
disableDustConversion = true,
disableSliverConversion = true,
disableIncreaseDustLimit = true,
clip = { x = 0, y = 0, width = 118, height = 34 },
types = {
DUST2SLIVER = 2,
SLIVER2CORE = 3,
INCREASE_DUST_LIMIT = 4
},
}
function ForgeController.conversion.handleButtons()
local dustMax = ForgeController.conversion.dustMax or 100
ForgeController.conversion.disableDustConversion = ForgeController.currentDust <
ForgeController.conversion.necessaryDustToSliver
ForgeController.conversion.disableSliverConversion = ForgeController.currentSlivers <
ForgeController.conversion.sliverToCore
ForgeController.conversion.disableIncreaseDustLimit = ForgeController.currentDust <
ForgeController.conversion.dustMaxIncreaseCost
ForgeController.currentDustAndMaxLabel = string.format("%d/%d", ForgeController.currentDust,
dustMax)
if dustMax >= ForgeController.conversion.dustCap then
ForgeController.conversion.dustMaxLabel = "Maximum Reached"
ForgeController.conversion.disableIncreaseDustLimit = true
else
ForgeController.conversion.dustMaxLabel = string.format("Raise limit from\n%d to %d", dustMax, dustMax + 1)
end
end
function ForgeController.conversion.toggle(conversionType)
local player = g_game.getLocalPlayer()
if not player then
return
end
if conversionType == ForgeController.conversion.types.DUST2SLIVER then
local dustBalance = player:getResourceBalance(ResourceTypes.FORGE_DUST) or 0
if dustBalance <= ForgeController.conversion.necessaryDustToSliver then
return
end
g_game.forgeRequest(conversionType)
return
end
if conversionType == ForgeController.conversion.types.SLIVER2CORE then
local sliverBalance = player:getResourceBalance(ResourceTypes.FORGE_SLIVER) or 0
if sliverBalance <= ForgeController.conversion.sliverToCore then
return
end
g_game.forgeRequest(conversionType)
return
end
if conversionType == ForgeController.conversion.types.INCREASE_DUST_LIMIT then
local dustBalance = player:getResourceBalance(ResourceTypes.FORGE_DUST) or 0
local currentNecessaryDust = ForgeController.conversion.dustMaxIncreaseCost
local maxDustCap = ForgeController.conversion.dustCap
local maxDustLevel = ForgeController.conversion.dustMax
if maxDustCap > 0 and maxDustLevel >= maxDustCap then
return
end
if dustBalance < currentNecessaryDust then
return
end
g_game.forgeRequest(conversionType)
end
end
function ForgeController:resetTabsClip()
self.fusion.clip = { x = 0, y = 0, width = 116, height = 34 }
self.transfer.clip = { x = 0, y = 0, width = 116, height = 34 }
self.conversion.clip = { x = 0, y = 0, width = 116, height = 34 }
self.history.clip = { x = 0, y = 0, width = 118, height = 34 }
end
function ForgeController:toggleConversionMenu()
self:resetTabsClip()
self.currentTab = 'conversion'
self.conversion.clip = { x = 0, y = 34, width = 116, height = 34 }
end
-- CONVERSION MENU
-- HISTORY MENU
ForgeController.history = {
currentPage = 0,
lastPage = 0,
showPreviousButton = false,
showNextButton = false,
list = {},
clip = { x = 0, y = 0, width = 118, height = 34 }
}
function ForgeController:toggleHistoryMenu()
self:resetTabsClip()
self.currentTab = 'history'
g_game.sendForgeBrowseHistoryRequest(0)
self.history.clip = { x = 0, y = 34, width = 118, height = 34 }
end
local historyActionLabels = {
[0] = 'Fusion',
[1] = 'Transfer',
[2] = 'Conversion',
[3] = 'Conversion',
[4] = 'Conversion'
}
function onBrowseForgeHistory(page, lastPage, currentCount, historyList)
page = math.max(tonumber(page) or 0, 1)
lastPage = math.max(tonumber(lastPage) or page, 1)
currentCount = tonumber(currentCount) or 0
lastPage = lastPage > 1 and lastPage - 1 or lastPage
ForgeController.history.currentPage = page
ForgeController.history.lastPage = lastPage
ForgeController.history.showPreviousButton = page > 1
ForgeController.history.showNextButton = page < lastPage
for _, entry in ipairs(historyList) do
entry.createdAt = Helpers.formatHistoryDate(entry.createdAt)
-- Convert actionType to number if it's a string, then look up the label
local actionTypeNum = tonumber(entry.actionType) or entry.actionType
entry.actionType = historyActionLabels[actionTypeNum] or 'Unknown'
entry.description = entry.description or ''
if entry.actionType == historyActionLabels[0] or entry.actionType == historyActionLabels[1] then
local firstSpaceIndex = string.find(entry.description, " ")
if firstSpaceIndex then
entry.description = string.sub(entry.description, 1, firstSpaceIndex - 1)
end
end
end
ForgeController.history.list = historyList or {}
end
function ForgeController.history.onHistoryPreviousPage()
local currentPage = ForgeController.history.currentPage or 0
if currentPage <= 1 then
return
end
g_game.sendForgeBrowseHistoryRequest(currentPage - 1)
end
function ForgeController.history.onHistoryNextPage()
local currentPage = ForgeController.history.currentPage or 0
local lastPage = ForgeController.history.lastPage or currentPage
if currentPage >= lastPage then
return
end
g_game.sendForgeBrowseHistoryRequest(currentPage + 1)
end