Skip to content

TypeError: An internal type is escaping this module #1991

@usernamei2en32o

Description

@usernamei2en32o

Type error is being caused by line 721 on leaderstats[name]:

leaderstats[name] = BigNumSub(leaderstats[name], value)

leaderstats's type shows as [intersect<a, ~nil>]: *blocked-4472478*, if i set the type manually, the type error goes away.

Full script:

--* [[----------< Constants >----------]]

local COST_TYPES = {
    Dropper1 = `Cash`,
    Dropper2 = `Ore`,
    Dropper3 = `Gems`,
    Valuator = `Ore`,
    Yielder = `Cash`
}
local MAX_UPGRADE_LEVEL = 500
local UPGRADER_MULTIPLIER_INCREASE = `0.05`

local ORE_RARITIES = {79.59, 12.5, 7.5, 0.4, 0.01} -- regular, shiny, gem, rainbow, void
local ORE_TIERS = {`Regular`, `Shiny`, `Gem`, `Rainbow`, `Void`}
local ORE_MULTIPLIERS = {Regular = 1, Shiny = 2.5, Gem = nil, Rainbow = 100, Void = 1000} -- gem nil since it only gives gems
local ORE_EXPIRE_TIME = 15

local UPGRADE_MILESTONE_REQUIREMENT = 25
local UPGRADE_COST_SCALAR_DROPPER = `25`
local UPGRADE_COST_SCALAR_UPGRADER = `100`

-- Cost curve base (keeps linear-ish feel) + multiplicative growth factor per 25 levels
local COST_CURVE_EXP_DROPPER = 1.15
local COST_CURVE_EXP_UPGRADER = 1.35
local COST_CURVE_FACTOR_DROPPER = `1.15`
local COST_CURVE_FACTOR_UPGRADER = `2.0`

-- Multiplicative r(L): r = clamp(BASE + step * floor((L-1)/25), BASE, MAX)
local COST_R_BASE_DROPPER = 1.12
local COST_R_STEP_DROPPER = 0.01
local COST_R_MAX_DROPPER = 1.22
local COST_R_BASE_UPGRADER = 1.16
local COST_R_STEP_UPGRADER = 0.012
local COST_R_MAX_UPGRADER = 1.26

-- Diminishing per-upgrade increments at milestones (no more big spikes)
local INCREMENT_DECAY_DROPPER = `0.985`

local PRICE_OF_FIRST_UPGRADE = `1`
local EARLY_EASING_MAX_LEVEL = 25
local EARLY_EASING_SCALAR_PER_LEVEL = `1.1`
local EARLY_EASING_CURVE_EXPONENT = 1.05

-- Use numeric type for number math (was string before)
local DROP_SPEED_DECREMENT_PER_MILESTONE = 0.05
local MINIMUM_DROP_SPEED = 1

local BUTTON_BUY_INTERVAL = 0.33
local SAVE_INTERVAL_SECONDS = 180 -- data will save on disconnect anyway, this is just an extra safety measure
local MINIMUM_TIME_BETWEEN_SAVES = 10


--* [[----------< Services >----------]]

local Players = game:GetService(`Players`)
local ReplicatedStorage = game:GetService(`ReplicatedStorage`)
local CollectionService = game:GetService(`CollectionService`)
local ServerStorage = game:GetService(`ServerStorage`)
local r = Random
local RNG = r.new()


--* [[----------< Modules >----------]]

local Modules = ReplicatedStorage.Modules
local BigNumber = require(Modules.BigNumber)
local Debounce = require(Modules.Debouncer)
local PlaySoundEffect = require(Modules.PlaySoundEffect)
local Announce = require(Modules.Announce)


--* [[----------< Remotes >----------]]

local Remotes = ReplicatedStorage:WaitForChild(`Remotes`)
local NotifyRemote = Remotes.Notify
local BuildRemote = Remotes.BuildObject
local ButtonPressRemote = Remotes.ButtonPress
local AnimateUpgradeRemote = Remotes.AnimateUpgrade
local ProcessOreRemote = Remotes.ProcessOre
local OreRarityEffectsRemote = Remotes.DoOreRarityEffects
local BuyFactoryRemote = Remotes.BuyFactory

local Factories: Folder = workspace:WaitForChild(`Factories`)
local OreStorage: Folder = Factories:WaitForChild(`OreStorage`)
local SharedPrefabs: Folder = ReplicatedStorage:WaitForChild(`Prefabs`)
local ServerPrefabs: Folder = ServerStorage:WaitForChild(`Prefabs`)
local OresFolder = ServerPrefabs:WaitForChild(`Ores`)
local OreRarityEffects: Folder = SharedPrefabs:WaitForChild(`OreRarityEffects`)


--* [[----------< Other >----------]]

local max, floor = math.max, math.floor
local BigNumAdd, BigNumSub, BigNumMax, BigNumMul = BigNumber.Add, BigNumber.Subtract, BigNumber.Max, BigNumber.Multiply
local NextNumber = RNG.NextNumber

local server_data_ready = false

local module = {}

function module:Run(core)
    
    --* [[----------< Helper Functions >----------]]
    
    -- Simple safe connect wrapper so handler errors don't kill the connection
    local function SafeConnect(signal: RBXScriptSignal, name: string, fn: (...any) -> ...any): RBXScriptConnection
        return signal:Connect(function(...)
            local ok, err = xpcall(fn, debug.traceback, ...)
            if not ok then
                warn(`[FactoryController::{name}]`, err)
            end
        end)
    end
    
    local function CanPlayerAfford(leaderstats: Folder, player: Player, cost: string, currencyName: string): boolean
        local playerCurrencyAmount: string = leaderstats[currencyName]
        local biggerNumber = BigNumMax(cost, playerCurrencyAmount)
        
        if biggerNumber == playerCurrencyAmount then return true end
        
        return false
    end
    
    
    
    local function PickRarityFromList(list)
        local chosenRandomPercent = NextNumber(RNG) * 100
        local currentValue = 0
        
        for tier, chance: number in list do
            -- add to currentValue each iteration until randomValue is within chosenRandomPercent
            currentValue += chance -- may need to use ipairs
            
            if chosenRandomPercent <= currentValue then return tier end
        end
        
        return nil -- incase something goes wrong
    end
    
    
    
    local function CreateOre(factory: Model, ore: Part, cashValue: string, oreValue: string)
        -- first, decide if the ore is regular, shiny, rainbow, or void
        local rarity = ORE_TIERS[PickRarityFromList(ORE_RARITIES)]
        if not rarity then return end
        local multiplier: number? = ORE_MULTIPLIERS[rarity]
        local oreClone = ore:Clone()
        local oreGui = oreClone.BillboardGui
        
        if rarity == `Gem` then
            local gem = ServerPrefabs.Gems.FactoryGem:Clone()
            oreClone:Destroy()
            oreClone = gem
            oreGui = oreClone.BillboardGui
        end
        
        if rarity ~= `Regular` and rarity ~= `Gem` then
            oreClone:AddTag(rarity)
            
            local oreEffects: Folder = OreRarityEffects[rarity]
            for _, effect: Instance in oreEffects:GetChildren() do
                local clone = effect:Clone()
                
                if clone:IsA(`Sound`) then
                    clone.Playing = true
                elseif clone:IsA(`ParticleEmitter`) then
                    clone.Enabled = true
                end
                
                clone.Parent = oreClone
            end
        end
        
        if rarity ~= `Gem` then
            -- Apply rarity multiplier only on spawned ore (not on labels). Use base strings.
            local finalCashValue = cashValue
            local finalOreValue = oreValue
            
            if rarity ~= `Regular` and multiplier ~= nil then
                finalCashValue = BigNumMul(cashValue, tostring(multiplier))
                finalOreValue = BigNumMul(oreValue, tostring(multiplier))
            end
            oreGui.Cash.Cost.Text = finalCashValue
            oreGui.Ore.Cost.Text = finalOreValue
            
        end
        oreClone.AssociatedFactory.Value = factory
        
        -- if ore hasn't reached the goal by this point, destroy it
        task.delay(ORE_EXPIRE_TIME, function()
            if oreClone and oreClone.Parent then
                oreClone:Destroy()
            end
        end) --TODO add animation to this
        
        return oreClone, rarity
    end
    
    
    
    local function BuildObject(player: Player, object: Model, is_purchase: boolean?)
        -- is_purchase will be false if player is loading from data
        for _, instance: Instance in object:GetDescendants() do
            if instance:IsA(`BasePart`) and instance.Name ~= `Transparent` and instance.Name ~= `UpgraderPart` then
                instance.CanCollide =
                    if instance.Name == `Drop`
                    or instance.Name == `Transparent`
                    or instance.Name == `UpgraderPart`
                    then false else true
                if is_purchase and instance.Name ~= `Drop` then
                    if player ~= nil then
                        BuildRemote:FireClient(player, instance)
                    end
                    task.wait(0.2)
                end
                instance.Transparency = if instance.Name == `Drop` then 0.5 else 0
            elseif instance:IsA(`ParticleEmitter`) or instance:IsA(`Beam`) or instance:IsA(`Light`) then
                instance.Enabled = true
            end
        end
    end
    
    
    
    local function UpgradeMachine(setting: Configuration, is_dropper: boolean?)
        local level: number = setting:GetAttribute(`Level`)
        local upgradeCost: string = setting:GetAttribute(`UpgradeCost`)
		local cashWorthIncrease: string = setting:GetAttribute(`CashValueIncreasePerUpgrade`)
		local oreValueIncrease: string = setting:GetAttribute(`OreValueIncreasePerUpgrade`)
        
		-- increment level first, then compute milestone on new level
        setting:SetAttribute(`Level`, level + 1)
        
        local is_milestone = level % UPGRADE_MILESTONE_REQUIREMENT == 0
        local costScalarPerLevel = if is_dropper then UPGRADE_COST_SCALAR_DROPPER else UPGRADE_COST_SCALAR_UPGRADER
        
        -- Curved cost: base + (level * scalar) + (curveFactor * level^exp)
        -- Then apply a mild multiplicative factor r(L) per 25-level segment (clamped)
        local baseCostString = upgradeCost
        if baseCostString == `0` or baseCostString == `` then baseCostString = PRICE_OF_FIRST_UPGRADE end
        local costCurveExponent = if is_dropper then COST_CURVE_EXP_DROPPER else COST_CURVE_EXP_UPGRADER
        local curveFactorString = if is_dropper then COST_CURVE_FACTOR_DROPPER else COST_CURVE_FACTOR_UPGRADER
        
        -- Early-game easing for droppers: first 25 paid upgrades are cheaper
        local priceLevelIndex = max(0, level - 1)
        local effectiveScalarPerLevel = costScalarPerLevel
        local effectiveCurveExponent = costCurveExponent
        local effectiveCurveFactorString = curveFactorString
        
        if is_dropper and priceLevelIndex <= EARLY_EASING_MAX_LEVEL then
            effectiveScalarPerLevel = EARLY_EASING_SCALAR_PER_LEVEL
            effectiveCurveExponent = EARLY_EASING_CURVE_EXPONENT
            effectiveCurveFactorString = curveFactorString
        end
        
        local levelAsString = `{priceLevelIndex}`
        local linearPart = BigNumMul(levelAsString, effectiveScalarPerLevel)
        local levelPowerApproximationString = `{max(0, floor((priceLevelIndex ^ effectiveCurveExponent) + 0.5))}`
        local curvePart = BigNumMul(effectiveCurveFactorString, levelPowerApproximationString)
        
        local newCost = BigNumAdd(baseCostString, BigNumAdd(linearPart, curvePart))
        
        -- multiplicative growth factor r(L)
        local upgradeSegment = max(0, floor((level - 1) / UPGRADE_MILESTONE_REQUIREMENT))
        local baseRadius = if is_dropper then COST_R_BASE_DROPPER else COST_R_BASE_UPGRADER
        local stepRadius = if is_dropper then COST_R_STEP_DROPPER else COST_R_STEP_UPGRADER
        local maxRadius = if is_dropper then COST_R_MAX_DROPPER else COST_R_MAX_UPGRADER
        local radius = baseRadius + stepRadius * upgradeSegment
        if radius > maxRadius then radius = maxRadius end
        newCost = BigNumMul(newCost, (`{string.format("%.3f", radius)}`))
        setting:SetAttribute(`UpgradeCost`, newCost)
		
		if is_dropper then
			-- Update settings as source of truth
			local cashWorth = setting:GetAttribute(`CashValue`)
			local oreWorth = setting:GetAttribute(`OreValue`)
            
            setting:SetAttribute(`CashWorth`, BigNumAdd(cashWorth, cashWorthIncrease))
            setting:SetAttribute(`OreWorth`, BigNumAdd(oreWorth, oreValueIncrease))
            
            -- At milestones, slightly decrease future increment sizes and tighten drop speed
            if is_milestone then
                cashWorthIncrease = BigNumMul(cashWorthIncrease, INCREMENT_DECAY_DROPPER)
                oreValueIncrease = BigNumMul(oreValueIncrease, INCREMENT_DECAY_DROPPER)
                local dropSpeed: number = setting:GetAttribute(`DropSpeed`)
                if dropSpeed > MINIMUM_DROP_SPEED then
                    setting:SetAttribute(`DropSpeed`, max(MINIMUM_DROP_SPEED, dropSpeed - DROP_SPEED_DECREMENT_PER_MILESTONE))
                end
            end
            
			-- Reflect in UI
			local buttons = setting:FindFirstAncestorWhichIsA(`Model`).Interior.Buttons
			local button = buttons[`{setting.Name}Button`]
			local buttonGui = button.Button.BillboardGui
			local costLabel = buttonGui.Cost.Cost
			local upgradeLabels = buttonGui.UpgradeVisual
            
			costLabel.Text = upgradeCost
			upgradeLabels.A.Text = cashWorth
			upgradeLabels.C.Text = BigNumAdd(cashWorth, cashWorthIncrease)
        else
            -- Upgrader settings drive UI
            local multiplier = setting:GetAttribute(`Multiplier`)
            setting:SetAttribute(`Multiplier`, BigNumAdd(multiplier, UPGRADER_MULTIPLIER_INCREASE))
            
            local buttons = setting:FindFirstAncestorWhichIsA(`Model`).Interior.Buttons
            local button = buttons[`{setting.Name}Button`]
            local buttonGui = button.Button.BillboardGui
            local costLabel = buttonGui.Cost.Cost
            local upgradeLabels = buttonGui.UpgradeVisual
            
            costLabel.Text = upgradeCost
            upgradeLabels.A.Text = multiplier
            upgradeLabels.C.Text = BigNumAdd(multiplier, UPGRADER_MULTIPLIER_INCREASE)
        end
    end
    
    
    
    -- for notifications that will be sent frequently - need to reduce them to not annoy the player
    local function SendSpammyNotification(player: Player, message: string, msgType: ("Good" | "Neutral" | "Bad"))
        if not core.SpammyNotificationDebounce then
            NotifyRemote:FireClient(player, msgType, message)
            core.SpammyNotificationDebounce = true
            task.delay(3.5, function()
                core.SpammyNotificationDebounce = false
            end)
        end
    end
    
    
    
    local function CheckIfStillTouching(character: Model, buttonHitbox: BasePart): boolean
        local touchingParts = buttonHitbox:GetTouchingParts()
        for _, part in touchingParts do
            local touchingCharacter = part.Parent
            if touchingCharacter == character then
                return true
            end
        end
        
        return false
    end
    
    
    --* [[----------< Factory Class: Helper Functions >----------]]
    
    local Factory = {}
    Factory.__index = Factory
    
    function Factory.new(factory: Instance)
        local self = setmetatable({}, Factory)
        
        self.Factory = factory
        self.BuyFolder = factory.Buy -- will be destroyed when the factory is bought
        self.Exterior = factory.Exterior
        self.Interior = factory.Interior
        self.TierFolder = factory.Parent
        self.TierConfig = self.TierFolder.TierConfig
        self.FactoryType = self.TierConfig:GetAttribute(`FactoryType`)
        
        self.FactoryConfig = factory.FactoryConfig
        self.Dropper1Config = self.FactoryConfig.Dropper1
        self.Dropper2Config = self.FactoryConfig.Dropper2
        self.Dropper3Config = self.FactoryConfig.Dropper3
        self.ValuatorConfig = self.FactoryConfig.Valuator
        self.YielderConfig = self.FactoryConfig.Yielder
        
        self.BuyCost = self.TierConfig.FactoryBuyCost
        self.Owner = self.FactoryConfig:GetAttribute(`Owner`)
        self.OwnerSign = self.Exterior.OwnerSign.Sign.SurfaceGui.Frame
        
        -- components
        self.Machines = self.Interior.Machines
        self.Dropper1 = self.Machines.Dropper1
        self.Dropper2 = self.Machines.Dropper2
        self.Dropper3 = self.Machines.Dropper3
        self.SellPoint = self.Machines.SellPoint
        self.Valuator = self.Machines.Valuator
        self.Yielder = self.Machines.Yielder
        
        -- buttons
        self.Buttons = self.Interior.Buttons
        self.CollectButton = self.Buttons.CollectButton
        self.DropperButton1 = self.Buttons.Dropper1Button
        self.DropperButton2 = self.Buttons.Dropper2Button
        self.DropperButton3 = self.Buttons.Dropper3Button
        self.ValuatorButton = self.Buttons.ValuatorButton
        self.YielderButton = self.Buttons.YielderButton
        
        self.RNG = Random.new()
        self.Connections = {}
        self.LastSaveTime = 0
        self.is_alive = true
        self.DroppersUnlocked = 0
        
        self:CheckForPlayersToAutoClaim()
        self:SetupFactoryBuyButton()
        
        return self
    end
    
    
    
    function Factory:ValidateButtonTouch(hit: BasePart, is_factory_buy: boolean?): (boolean, Player?)
        if not hit.Parent:FindFirstChildWhichIsA(`Humanoid`) then return false, nil end
        local player = Players[hit.Parent.Name]
        if not player then return false, nil end
        if is_factory_buy and player.Name ~= self.Owner then return false, nil end
        if Debounce(player, 0.1) then return false, nil end
        
        return true, player
    end
    
    
    
    function Factory:ValidateOre(ore: Part, upgrader: Model?, is_sell: boolean)
        if not ore:HasTag(`Ore`) or is_sell and ore:HasTag(`Gem`) then return nil end
        if not ore:FindFirstChild(`AssociatedFactory`) then return nil end
        if ore.AssociatedFactory.Value ~= self.Factory then return nil end
        if not is_sell and ore:GetAttribute(`UpgradedBy{upgrader}`) then return nil end
        local owner = self.Owner and Players:FindFirstChild(self.Owner)
        if not owner then return nil end
        if not owner or ore:GetNetworkOwner() ~= owner then return nil end
        
        return owner
    end
    
    
    
    function Factory:TouchBegin(hit: BasePart, buttonHitbox: BasePart, callback: () -> ()?)
            -- ensure this doesn't run multiple times
            -- this also ensures that the player can't somehow press multiple buttons at once
            if self.is_touching then return end
            
            local character: Model = hit.Parent
            local player: Player? = Players:GetPlayerFromCharacter(character)
            if not player or player ~= Players:FindFirstChild(self.Owner) then return end
            
            self.is_touching = true
            
            local buttonModel = buttonHitbox.Parent
            
            task.defer(function()
                while CheckIfStillTouching(character, buttonHitbox) do
                    ButtonPressRemote:FireClient(player, buttonModel, `press`)
                    callback(hit, buttonModel)
                    task.wait(BUTTON_BUY_INTERVAL)
                end
                
                self.is_touching = false
            end)
        end
    
    
    
    function Factory:UpdateCollector()
        local collectorPart = self.CollectButton.Button
        local collectorGui = collectorPart.BillboardGui
        
        local sellPointConfig = self.FactoryConfig.SellPoint
        local cashToCollect = sellPointConfig:GetAttribute(`CashToCollect`)
        local oreToCollect = sellPointConfig:GetAttribute(`OreToCollect`)
        local gemsToCollect = sellPointConfig:GetAttribute(`GemsToCollect`)

        local cashLabel = collectorGui.Cash.Cost
        local oreLabel = collectorGui.Ore.Cost
        local gemsLabel = collectorGui.Gems.Cost

        cashLabel.Text = cashToCollect
        oreLabel.Text = oreToCollect
        gemsLabel.Text = gemsToCollect
    end
    
    
    
    function Factory:Save()
        local data = core.GetData(Players:FindFirstChild(self.Owner))
        if not data then return end
        
        -- data references
        local factoryData = data.FactoryData
        if not factoryData then
            factoryData = {}
            data.FactoryData = factoryData
        end
        
        local factoryType: string = self.FactoryType
        
        factoryData.Factories = factoryData.Factories or {}
        local factory = factoryData.Factories[factoryType]
        if not factory then
            factory = {}
            factoryData.Factories[factoryType] = factory
        end
        
        -- iterate through every machine
		for _, machine: Configuration in self.FactoryConfig:GetChildren() do
			-- ensure a table exists for this machine in the player's data
			local machineData = factory[machine.Name]
			if machineData == nil then
				machineData = {}
				factory[machine.Name] = machineData
			end
            
            -- in the machine folder, iterate through every attribute
            for name: string, value: any in machine:GetAttributes() do
				-- write current world value into data
				machineData[name] = value
            end
        end
        
        self.LastSaveTime = os.clock()
    end
    
    
    
    function Factory:Load()
        local data = core.GetData(Players:FindFirstChild(self.Owner))
        if not data then return end
        
        -- data references
        local factoryData = data.FactoryData
        if not factoryData then return end
        
        factoryData.Factories = factoryData.Factories or {}
        local factory = factoryData.Factories[self.FactoryType]
        if not factory then
            factory = {}
            factoryData.Factories[self.FactoryType] = factory
        end
        
        -- iterate through every machine
        for _, machine: Configuration in self.FactoryConfig:GetChildren() do
            local machineData = factory[machine.Name]
            if not machineData then
                -- initialize data snapshot from world defaults so it exists for next save
                local init = {}
                for name: string, value: any in machine:GetAttributes() do
                    init[name] = value
                end
                factory[machine.Name] = init
                continue
            end
            
            -- in the machine, iterate through every attribute
            for name: string, value: any in machine:GetAttributes() do
                if machineData[name] ~= nil then
                    machine:SetAttribute(name, machineData[name])
                end
            end
        end
    end
    
    
    
    function Factory:InitializeFactory()
        local owner = Players:FindFirstChild(self.Owner)
        local data = core.GetData(owner)
        if not data then print(`no data`); return end
        
        local factoryConfig = self.FactoryConfig
        local physicalMachines = self.Machines
        
        for _, machine: Configuration in factoryConfig:GetChildren() do
            if machine.Name == `SellPoint` then continue end
            local buttonModel = self.Buttons:FindFirstChild(`{machine.Name}Button`)
            if not buttonModel then continue end
            print(buttonModel)
            print(buttonModel.Button)
            local buttonGui = buttonModel.Button.BillboardGui
            local costLabel = buttonGui.Cost.Cost
            local upgradeLabels = buttonGui.UpgradeVisual
            costLabel.Text = machine:GetAttribute(`UpgradeCost`)
            
            local is_dropper = machine:GetAttribute(`OreValue`) ~= nil
            local is_bought = machine:GetAttribute(`IsBought`)
            
            if is_dropper then
                local current = machine:GetAttribute(`CashValue`)
                print(current)
                upgradeLabels.A.Text = current
                upgradeLabels.C.Text = BigNumAdd(current, machine:GetAttribute(`CashValueIncreasePerUpgrade`))
                -- ore worth does not need to be updated as there's no visual for it
            else
                print(machine)
                local current = machine:GetAttribute(`Multiplier`)
                print(current)
                upgradeLabels.A.Text = current
                upgradeLabels.C.Text = BigNumAdd(current, UPGRADER_MULTIPLIER_INCREASE)
            end
            
            if is_bought then
                local foundMachine: Model? = physicalMachines:FindFirstChild(machine.Name)
                if not foundMachine then continue end
                
                if owner and foundMachine then
                    BuildObject(owner, foundMachine, false)
                end
            end
        end
        
        self:UpdateCollector()
    end
    
    
    
    function Factory:Create()
        self:SetupDroppers(self.Dropper1)
        self:SetupDroppers(self.Dropper2)
        self:SetupDroppers(self.Dropper3)
        
        self:SetupUpgraders(self.Valuator.Name)
        self:SetupUpgraders(self.Yielder.Name)
        
        self:SetupBuyButtons()
        self:SetupSellPoint()
        self:SetupCollector()
        
        self:Load()
        self:InitializeFactory()
        
        self:OnDisconnect()
        
        -- save every SAVE_INTERVAL_SECONDS seconds as long as there hasn't been a save recently
        task.defer(function()
            while self.is_alive do
                task.wait(SAVE_INTERVAL_SECONDS)
                if not self.is_alive then break end
                local owner = self.Owner
                if not owner and Players:FindFirstChild(owner) then break end
                if os.clock() - self.LastSaveTime < MINIMUM_TIME_BETWEEN_SAVES then continue end
                local ok, err = xpcall(function()
                    self:Save()
                end, debug.traceback)
                if not ok then warn("[FactoryController::AutoSave]", err) end
            end
        end)
    end
    
    
    
    --* [[----------< Factory Class: Core Functions >----------]]
    
    function Factory:CheckForPlayersToAutoClaim()
        local function OnPlayerAdded(player: Player)
            if not server_data_ready then
                repeat task.wait(0.1) until core.GetData(player) ~= nil
                server_data_ready = true -- only needs to happen once; then it'll be available for all other players
            end
            local data = core.GetData(player)
            if not data then return end
            
            local factoryData = data.FactoryData.Factories[self.FactoryType]
            if factoryData == nil then return end
            if not factoryData.Owned then return end
            
            -- make sure the player doesn't already own a factory of this type
            for _, factory in workspace.Factories[self.FactoryType]:GetChildren() do
                if factory.FactoryConfig:GetAttribute(`Owner`) == player.Name then return end
            end
            
            -- if we reach this point, the player doesn't own a factory of this type
            -- so we can claim it
            self.FactoryConfig:SetAttribute(`Owner`, player.Name)
            self.Owner = player.Name
            self.OwnerSign.Owner.Text = `{player.Name}'s`
            
            self:Create()
            
            self.BuyFolder:Destroy()
            
            self.Connections[`AutoClaimFactories`]:Disconnect()
        end
        
        self.Connections[`AutoClaimFactories`] = SafeConnect(Players.PlayerAdded, "PlayerAdded", OnPlayerAdded)
    end
    
    
    
	function Factory:SetupFactoryBuyButton()
		local buyButton = self.BuyFolder.BuyFactory.Button
		local buyCosts = self.BuyCost:GetAttributes()
        local factoryType = self.FactoryType
        local factoryConfig = self.FactoryConfig
        
		local function FactoryBuyButtonTouched(hit)
			local is_valid, player = self:ValidateButtonTouch(hit)
			if not is_valid then return end
            
            if Debounce(hit.Parent) then return end
            
			local data = core.GetData(player)
			if not data then return end
            
			local leaderstats = data.Leaderstats
			local factories = workspace.Factories
            local factoriesData = data.FactoryData
            local factoryData = factoriesData.Factories[factoryType]
            
			for _, factory in factories[factoryType]:GetChildren() do
				if factoryConfig:GetAttribute(`Owner`) == player.Name then
                    SendSpammyNotification(player, `You already own a {factoryType} Factory!`, `Bad`)
					return
				end
			end
            
			for name, value in buyCosts do
				if not CanPlayerAfford(leaderstats, player, value, name) then
                    SendSpammyNotification(player, `You don't have enough {name} to buy this!`, `Bad`)
					return
				end
			end
            
			-- if we reach this point, we can buy the factory
			-- take the currencies away from player
            for name, value in buyCosts do
                if value == `0` then continue end
                local stat = leaderstats[name]
				if stat then -- if cost is a leaderstat currency. it could also be an ore
					leaderstats[name] = BigNumSub(leaderstats[name], value)
					-- else --TODO
				end
			end
            
			-- now claim the factory and run factory functions
            factoryConfig:SetAttribute(`Owner`, player.Name)
            self.Owner = player.Name
			self.OwnerSign.Owner.Text = `{player.Name}'s`
            factoryData.Owned = true
            factoriesData.FactoriesOwned += 1
            
            BuyFactoryRemote:FireClient(player, self.BuyFolder)
            
            -- gradually destroy the buy folder as the client animations finish
            task.delay(0.5, function()
                local xpToAdd = 20 * (factoriesData.FactoriesOwned * 2.5)
                core.AddXP(player, `{xpToAdd}`, self.BuyFolder.Door)
                task.wait(0.5)
                self.BuyFolder.Door:Destroy()
                task.wait(4)
                self.BuyFolder:Destroy()
            end)
            
			-- now run factory functions
			self:Create()
		end
        
        self.Connections[`BuyButtonTouched`] = SafeConnect(buyButton.Touched, "BuyButtonTouched", FactoryBuyButtonTouched)
	end
    
    
    
    function Factory:SetupDroppers(dropper: Model)
        local dropperConfig = self.FactoryConfig[dropper.Name]
        
        local ORE_TO_DROP = OresFolder:FindFirstChild(self.FactoryType)
        if not ORE_TO_DROP then return end
        
        -- variables
        local cashWorth, oreWorth = dropperConfig:GetAttribute(`CashValue`), dropperConfig:GetAttribute(`OreValue`)
        local dropSpeed = dropperConfig:GetAttribute(`DropSpeed`)
        local dropSound: Sound? = dropper.Drop:FindFirstChildWhichIsA(`Sound`)
        local dropper_unlock_incremented = false
        
        task.defer(function()
            repeat task.wait(0.33) until not self.is_alive or dropperConfig:GetAttribute(`IsBought`)
            if not self.is_alive then return end

            while self.is_alive and dropperConfig:GetAttribute(`IsBought`) do
                local ok, err = xpcall(function()
                    if not dropper_unlock_incremented then
                        dropper_unlock_incremented = true
                        self.DroppersUnlocked += 1
                    end
                    
                    -- Use settings as source of truth for spawning
                    local ore, rarity = CreateOre(self.Factory, ORE_TO_DROP, cashWorth, oreWorth)
                    if not ore or not rarity then return end
                    
                    local owner = self.Owner
                    ore.Position = dropper.Drop.Position
                    
                    if rarity ~= `Regular` and rarity ~= `Gem` then
                        local findOwner = owner and Players:FindFirstChild(owner)
                        if findOwner then
                            OreRarityEffectsRemote:FireClient(findOwner, ore, rarity, dropper)
                        end
                    end
                    
                    if rarity == `Gem` then
                        PlaySoundEffect(`GemDrop`, NumberRange.new(0.95, 1.05), ore)
                    elseif rarity == `Void` then
                        local findOwner = owner and Players:FindFirstChild(owner)
                        if findOwner then
                            Announce.Global(`A VOID ORE Spawned In {findOwner.Name}'s {self.FactoryType} Factory!!!`, `#00FFFF`, `[Server]`)
                        end
                    end
                    
                    ore.Parent = OreStorage[`{rarity}Ores`]
                    local findOwner = owner and Players:FindFirstChild(owner)
                    if findOwner then
                        ore:SetNetworkOwner(findOwner)
                    end
                    
                    if dropSound then dropSound:Play() end
                    
                    task.wait(dropSpeed)
                end, debug.traceback)
                if not ok then warn("[FactoryController::DropperLoop]", err) end
                if not self.is_alive then break end
            end
        end)
    end
    
    
    
    function Factory:SetupBuyButtons()
        local owner = self.Owner
        self.is_touching = false
        
        local function BuyButtonTouched(hit, buttonModel: Model)
            if buttonModel.Name == `CollectButton` then return end
            
            local is_valid, player = self:ValidateButtonTouch(hit)
            if not is_valid then return end
            
            -- also make sure the player touching is the owner of the factory
            if Players:FindFirstChild(owner) ~= player then return end
            
            local data = core.GetData(player)
            if not data then return end
            
            -- find the cost, and make sure the player can afford it
            local objectRealName = buttonModel.Name:gsub(`Button`, ``)
            local associatedMachine = self.Machines[objectRealName]
            local associatedSettings = self.FactoryConfig[objectRealName]
            local level = associatedSettings:GetAttribute(`Level`)
            
            local is_dropper = associatedSettings:GetAttribute(`OreValue`)
            local is_bought = associatedSettings:GetAttribute(`IsBought`)
            local is_max_level = associatedSettings:GetAttribute(`IsMaxLevel`)
            
            if is_max_level then return end -- early exit flag to save performance a bit
            if level >= MAX_UPGRADE_LEVEL then
                associatedSettings:SetAttribute(`IsMaxLevel`, true)
                return
            end
            
            local cost = associatedSettings:GetAttribute(`UpgradeCost`)
            local costType = COST_TYPES[objectRealName]
            if costType == `Ore` then
                costType = self.FactoryType
            end
            
            local leaderstats = data.Leaderstats
            local ores = data.Ores
            
            -- find the location of the currency
            local location = if leaderstats[costType] then leaderstats else ores
            
            if not CanPlayerAfford(location, player, cost, costType) then
                -- if the object has already been bought, change message to Upgrade instead
                local buyOrUpgrade = if is_bought then `upgrade` else `buy`
                SendSpammyNotification(player, `You don't have enough {costType} to {buyOrUpgrade} this!`, `Bad`)
                return
            end
            
            -- if we reach this point, player can buy the upgrade - subtract the cost, and upgrade the machine
            core.CurrencySubtract(player, costType, cost)
            local xpToAdd = BigNumMul(`{level}`, `3.5`)
            core.AddXP(player, xpToAdd, associatedMachine.PrimaryPart)
            
            UpgradeMachine(self.FactoryConfig[objectRealName], is_dropper)
            
            -- signal to client that the upgrade actually succeeded
            ButtonPressRemote:FireClient(player, buttonModel, `success`)
            
            if not is_bought then
                associatedSettings:SetAttribute(`IsBought`, true)
                local title = buttonModel.Button.BillboardGui.Title
                local storedTitle = title.Text
                title.Text = `Purchasing...`
                BuildObject(player, associatedMachine, true)
                title.Text = storedTitle:gsub(`Buy`, `Upgrade`) 
            end
        end
        
        local buttonsFolder = self.Buttons
        for _, buttonModel: Model in buttonsFolder:GetChildren() do
            if buttonModel.Name == `CollectButton` then continue end
            
            local hitbox = buttonModel.Hitbox
            self.Connections[`ButtonTouched_{buttonModel.Name}`] = SafeConnect(hitbox.Touched, `ButtonTouched_{buttonModel.Name}`, function(hit)
                self:TouchBegin(hit, hitbox, BuyButtonTouched)
            end)
        end 
    end
    
    
    
    function Factory:SetupUpgraders(upgrader: string)
        local upgraderConfig = self.FactoryConfig[upgrader]
        local machine = self.Machines[upgrader]
        local upgraderUpgradePart = machine.UpgraderPart
        
        task.defer(function()
            repeat task.wait(0.33) until not self.is_alive or upgraderConfig:GetAttribute(`IsBought`)
            if not self.is_alive then return end
            
            local function UpgradeOreOnTouch(ore: BasePart)
                local owner = self:ValidateOre(ore, machine)
                if not owner then return end

                -- ore is valid, so upgrade it
                ore:SetAttribute(`UpgradedBy{upgrader}`, true)
                local multiplier = upgraderConfig:GetAttribute(`Multiplier`)
                local oreGui = ore.BillboardGui
                local valueToUpgrade = if upgrader == `Valuator` then oreGui.Cash.Cost else oreGui.Ore.Cost
                valueToUpgrade.Text = BigNumMul(valueToUpgrade.Text, multiplier)
                
                PlaySoundEffect(`Hum`, NumberRange.new(0.8, 1.2), ore)
                PlaySoundEffect(`Ding`, NumberRange.new(0.7, 1), ore)
                local player: Player? = Players:FindFirstChild(owner)
                if player then
                    AnimateUpgradeRemote:FireClient(player, ore, upgrader)
                end
            end
            
            self.Connections[`UpgraderTouched_{upgrader}`] = SafeConnect(upgraderUpgradePart.Touched, `UpgraderTouched_{upgrader}`, UpgradeOreOnTouch)
        end)
    end
    
    
    
    function Factory:SetupSellPoint()
        local sellPart: Part = self.SellPoint.SellPart
        local factoryConfig = self.FactoryConfig
        local sellPointConfig = factoryConfig.SellPoint
        local cashToCollect = sellPointConfig:GetAttribute(`CashToCollect`)
        local oreToCollect = sellPointConfig:GetAttribute(`OreToCollect`)
        local gemsToCollect = sellPointConfig:GetAttribute(`GemsToCollect`)
        local points = self.Interior.Decoration.AnimCurvePoints
        
        local function SellOreOnTouch(ore: BasePart)
            local is_gem = ore:HasTag(`Gem`)
            local owner = self:ValidateOre(ore, nil, true)
            if not owner then return end
            
            ore:AddTag(`Processed`)
            local oreGui = ore.BillboardGui
            
            if not is_gem then
                local cashWorth = oreGui.Cash.Cost
                local oreWorth = oreGui.Ore.Cost
                sellPointConfig:SetAttribute(`CashToCollect`, BigNumAdd(cashToCollect, cashWorth.Text))
                sellPointConfig:SetAttribute(`OreToCollect`, BigNumAdd(oreToCollect, oreWorth.Text))
            else
                local gemWorth = oreGui.Gems.Cost
                sellPointConfig:SetAttribute(`GemsToCollect`, BigNumAdd(gemsToCollect, gemWorth.Text))
            end
            
            oreGui.Enabled = false
            ProcessOreRemote:FireClient(owner, ore, sellPart, points)
            self:UpdateCollector()
            
            local xpToAdd = `{self.DroppersUnlocked * 30 * (ore:HasTag(`Shiny`) and 2 or 1)}`
            core.AddXP(owner, xpToAdd, sellPart)
            
            task.delay(3, function()
                ore:Destroy()
            end)
        end
        
    self.Connections[`SellPartTouched`] = SafeConnect(sellPart.Touched, "SellPartTouched", SellOreOnTouch)
    end
    
    
    
    function Factory:SetupCollector()
        local collectorPart = self.CollectButton.Button
        local collector_debounce = false
        
        -- Cheaper zero-string check
        local function IsZeroString(s: string?): boolean
            if not s or s == `` then return true end
            -- common zero encodings
            return s == `0` or s == `0e0` or s == `0.0` or s == `0.00`
        end
        
        local function CollectorButtonTouched(hit)
            local is_valid, player = self:ValidateButtonTouch(hit)
            if not is_valid then return end
            
            -- Debounce per player, longer than buy button (e.g. 2 seconds)
            if collector_debounce then return end
            collector_debounce = true
            task.delay(2, function()
                collector_debounce = false
            end)
            
            local factoryConfig = self.FactoryConfig
            local sellPointConfig = factoryConfig.SellPoint
            
            local cashToCollect = sellPointConfig:GetAttribute(`CashToCollect`)
            local oreToCollect = sellPointConfig:GetAttribute(`OreToCollect`)
            local gemsToCollect = sellPointConfig:GetAttribute(`GemsToCollect`)
            
            local any_not_zero = false
            
            if not IsZeroString(cashToCollect) then 
                core.CurrencyAdd(player, `Cash`, cashToCollect)
                any_not_zero = true
            end
            if not IsZeroString(oreToCollect) then 
                core.CurrencyAdd(player, self.FactoryType, oreToCollect)
                any_not_zero = true
            end
            if not IsZeroString(gemsToCollect) then 
                core.CurrencyAdd(player, `Gems`, gemsToCollect) 
                any_not_zero = true
            end
            
            ButtonPressRemote:FireClient(player, self.CollectButton, if any_not_zero then `success` else `press`)
            
            sellPointConfig:SetAttribute(`CashToCollect`, `0`)
            sellPointConfig:SetAttribute(`OreToCollect`, `0`)
            sellPointConfig:SetAttribute(`GemsToCollect`, `0`)
            self:UpdateCollector()
        end
        
    self.Connections[`CollectorButtonTouched`] = SafeConnect(collectorPart.Touched, "CollectorButtonTouched", CollectorButtonTouched)
    end
    
    
    
    function Factory:OnDisconnect()
        local player = self.Owner and Players:FindFirstChild(self.Owner)
        if not player then return end
        
        local function PlayerRemoving(leavingPlayer)
            print(`player removing`)
            -- make sure the player who left is the current subject
            if leavingPlayer == player then
                self.is_alive = false
                print(`player confirmed, saving`)
                self:Save()
                for _, connection: RBXScriptConnection in self.Connections do
                    if not connection or not connection.Disconnect then continue end
                    connection:Disconnect()
                end
                
                self.Connections = nil
                --TODO add link to data script - make sure data is saved, then destroy factory & replace it
            end
        end
    self.Connections[`Disconnect`] = SafeConnect(Players.PlayerRemoving, "PlayerRemoving", PlayerRemoving)
    end
    
    
    
    --* [[----------< Factory Controller: Init >----------]]
    
    -- initialize factories
    for _, factory: Model in CollectionService:GetTagged(`Factory`) do
        task.defer(Factory.new, factory)
    end
    -- add new factories as they're created via player leaving & having their factory replaced
    SafeConnect(CollectionService:GetInstanceAddedSignal(`Factory`), "FactoryAdded", Factory.new)
end

return module

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingnew solverThis issue is specific to the new solver.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions