-- ============================================================================ -- Shared helpers + one-time normalization of Config.Resources. -- -- Config entries accept shorthand (`reward = 'zelezo'`, `duration = 6000`). -- Everything is expanded exactly once here, at load, into the global -- `Resources` table. Runtime code therefore never has to ask -- `type(x) == 'table'` again — it just reads normalized fields. -- ============================================================================ Utils = {} ---Random value from a normalized { min, max } pair. function Utils.Range(range) if range.max <= range.min then return range.min end return math.random(range.min, range.max) end ---Uniform random point inside a circle. sqrt() keeps the distribution even — ---without it props visibly clump around the centre. function Utils.RandomPointInCircle(center, radius) local angle = math.random() * math.pi * 2 local dist = math.sqrt(math.random()) * radius return vec3(center.x + math.cos(angle) * dist, center.y + math.sin(angle) * dist, center.z) end ---Chance roll on a 0-100 scale. function Utils.Chance(percent) if not percent or percent <= 0 then return false end if percent >= 100 then return true end return (math.random() * 100) <= percent end function Utils.HasValue(tbl, value) if not tbl then return false end for _, v in ipairs(tbl) do if v == value then return true end end return false end ---Unique key for a prop so both realms index the same registry entry. function Utils.PropKey(resourceIndex, propIndex) return ('%s:%s'):format(resourceIndex, propIndex) end ---Wraps an exports call so a missing dependency degrades instead of erroring. function Utils.SafeExport(resource, method, ...) if GetResourceState(resource) ~= 'started' then print(('[M-Addon] ERROR: resource "%s" is not started, "%s" was skipped.'):format(resource, method)) return false end local ok, result = pcall(function(...) return exports[resource][method](exports[resource], ...) end, ...) if not ok then print(('[M-Addon] ERROR: exports["%s"]:%s failed -> %s'):format(resource, method, result)) return false end return true, result end -- -------------------------------------------------------------------------- -- LEVELS -- Config.Levels holds the *total* XP needed to reach each level, so the -- current level is simply the highest threshold the player has passed. -- -------------------------------------------------------------------------- function Utils.LevelFromXP(xp) if not Config.UseLevels then return 1 end local level = 1 for i = 2, #Config.Levels do if xp >= Config.Levels[i] then level = i else break end end return level end ---Returns level, xpIntoLevel, xpNeededForNextLevel (0 when maxed). function Utils.LevelProgress(xp) local level = Utils.LevelFromXP(xp) local floor = Config.Levels[level] or 0 local ceiling = Config.Levels[level + 1] if not ceiling then return level, 0, 0 end return level, xp - floor, ceiling - floor end -- -------------------------------------------------------------------------- -- NORMALIZATION -- -------------------------------------------------------------------------- ---Accepts a number or a { min, max } / { a, b } pair and returns { min, max }. local function toRange(value, fallback) if type(value) == 'number' then return { min = math.floor(value), max = math.floor(value) } end if type(value) == 'table' then local min = value.min or value[1] local max = value.max or value[2] or min if min then min, max = math.floor(min), math.floor(max) if max < min then min, max = max, min end return { min = min, max = max } end end return toRange(fallback, 1) end ---Normalizes one reward into { item, min, max, chance }. ---Accepts `amount` (fixed), `min`/`max` (random range), or neither (1x). local function toReward(value) if type(value) == 'string' then return { item = value, min = 1, max = 1, chance = 100 } end if type(value) ~= 'table' or not value.item then return nil end local min, max if value.amount then min, max = value.amount, value.amount else min = value.min or 1 max = value.max or value.min or 1 end min, max = math.floor(min), math.floor(max) if max < min then min, max = max, min end return { item = value.item, min = min, max = max, chance = value.chance or 100 } end ---Accepts every documented reward shape and returns an array of long forms: --- 'zelezo' -> 1x, 100% --- { item = 'zelezo', amount = 3 } -> always 3x --- { item = 'zelezo', min = 1, max = 5 } -> random 1-5 --- { { ... }, { ... } } -> several items at once local function toRewards(value) if type(value) == 'string' then return { toReward(value) } end if type(value) ~= 'table' then return nil end -- A single reward table, written without the surrounding array. if value.item then local reward = toReward(value) return reward and { reward } or nil end local out = {} for _, entry in ipairs(value) do local reward = toReward(entry) if reward then out[#out + 1] = reward end end return #out > 0 and out or nil end ---Picks the animation + hand prop for a tool when the entry doesn't override it. local function toAnim(entry, tool) -- An explicit anim wins; the matching hand prop still comes from defaults -- unless the entry supplies its own propAttach. local key = tool or 'none' local preset = Config.Defaults.anims[key] or Config.Defaults.anims['none'] local anim = preset.anim if type(entry.anim) == 'table' and entry.anim.dict and entry.anim.clip then anim = { dict = entry.anim.dict, clip = entry.anim.clip, flag = entry.anim.flag or 49 } end local prop = preset.prop if entry.propAttach ~= nil then prop = entry.propAttach end return anim, prop or false end ---Resolves `effect = 'weld'` to its preset, or accepts an inline table. ---Anything unknown becomes false, so a typo means "no particles" rather than ---a runtime crash deep inside a ptfx native. local function toEffect(value, label, field) if not value or value == 'none' then return false end if type(value) == 'string' then local preset = Config.Defaults.effects and Config.Defaults.effects[value] if not preset then print(('[M-Addon] CONFIG WARNING: "%s" uses unknown %s "%s" — ignored.') :format(label, field, value)) return false end return preset end if type(value) == 'table' and value.dict and value.name then return { dict = value.dict, name = value.name, scale = value.scale or 1.0, offset = value.offset or vec3(0.0, 0.0, 0.0) } end return false end ---Normalizes the minigame key into `false` or an array of game names. ---nil falls back to the tool's default pool, so a resource that says nothing ---still gets a sensible game. local function toMinigames(value, tool, label) if value == false or value == 'none' then return false end if value == nil then local pool = Config.ToolMinigames and Config.ToolMinigames[tool or 'none'] return pool or false end if type(value) == 'string' then return { value } end if type(value) == 'table' then local out = {} for _, name in ipairs(value) do if type(name) == 'string' then out[#out + 1] = name end end if #out > 0 then return out end end print(('[M-Addon] CONFIG WARNING: "%s" has an invalid minigame value — ignored.'):format(label)) return false end ---`policeAlert = false` or `{ chance = 10, cooldown = 300 }`. local function toPolice(value) if not value then return false end if value == true then return { chance = 10, cooldown = 300 } end if type(value) ~= 'table' then return false end return { chance = value.chance or 10, cooldown = value.cooldown or 300 } end Resources = {} ---Builds the normalized Resources table. Structural problems are reported in ---English for the server owner; the bad entry is skipped and the rest survive. ---Deeper validation (item exists, model exists) happens per realm. function Utils.Normalize() Resources = {} local seen = {} for i, entry in ipairs(Config.Resources) do local label = entry.id or entry.label or ('#' .. i) local skip if type(entry.id) ~= 'string' or entry.id == '' then print(('[M-Addon] CONFIG ERROR: entry %s has no "id" — skipped.'):format(i)) skip = true elseif seen[entry.id] then print(('[M-Addon] CONFIG ERROR: duplicate id "%s" — skipped.'):format(entry.id)) skip = true elseif type(entry.coords) ~= 'vector3' and type(entry.coords) ~= 'table' then print(('[M-Addon] CONFIG ERROR: "%s" has no valid coords — skipped.'):format(label)) skip = true elseif entry.coords.x == 0.0 and entry.coords.y == 0.0 and entry.coords.z == 0.0 then print(('[M-Addon] CONFIG ERROR: "%s" sits at 0,0,0 — skipped.'):format(label)) skip = true elseif type(entry.props) ~= 'table' or #entry.props == 0 then print(('[M-Addon] CONFIG ERROR: "%s" has no props — skipped.'):format(label)) skip = true end -- `tool = 'none'` reads naturally in config but would be looked up as a -- literal item name and fail validation, so treat it as bare hands. local tool = entry.tool if tool == 'none' or tool == '' then tool = false end local rewards = not skip and toRewards(entry.reward) if not skip and not rewards then print(('[M-Addon] CONFIG ERROR: "%s" has no valid reward — skipped.'):format(label)) skip = true end if not skip then seen[entry.id] = true if entry.skillcheck ~= nil then print(('[M-Addon] CONFIG NOTICE: "%s" still uses `skillcheck`, which was replaced by `minigame`. See the README.') :format(label)) end local anim, propAttach = toAnim(entry, tool) Resources[#Resources + 1] = { index = #Resources + 1, id = entry.id, label = entry.label or entry.id, category = entry.category or Config.Defaults.category, icon = entry.icon or Config.Defaults.icon, coords = vec3(entry.coords.x, entry.coords.y, entry.coords.z), radius = entry.radius or Config.Defaults.radius, amount = math.floor(entry.amount or Config.Defaults.amount), props = entry.props, tool = tool or false, consume = entry.consume == true, removeChance = entry.removeChance or 0, rewards = rewards, duration = toRange(entry.duration, Config.Defaults.duration), respawnTime = toRange(entry.respawnTime, Config.Defaults.respawnTime), renderDistance = entry.renderDistance or Config.RenderDistance or Config.Defaults.renderDistance, minLevel = entry.minLevel or 0, jobs = entry.jobs or false, minigames = toMinigames(entry.minigame, tool or 'none', label), minigameDifficulty = entry.minigameDifficulty or 'medium', anim = anim, propAttach = propAttach, blip = entry.blip or false, policeAlert = toPolice(entry.policeAlert), effect = toEffect(entry.effect, label, 'effect'), destroyEffect = toEffect(entry.destroyEffect, label, 'destroyEffect'), rareChance = entry.rareChance or 0, rareRewards = toRewards(entry.rareReward) or false, depleteAfter = entry.depleteAfter or false, depleteTime = entry.depleteTime or 600 } end end return Resources end ---Look an entry up by its string id (used by the exports). function Utils.FindResource(id) for _, resource in ipairs(Resources) do if resource.id == id then return resource end end return nil end -- Normalize immediately: config.lua is loaded before this file, and both realms -- must end up with an identical table. Utils.Normalize()