Inital Commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
source/
|
||||||
527
42/media/lua/server/distribution/OFDistributionBlocker.lua
Normal file
527
42/media/lua/server/distribution/OFDistributionBlocker.lua
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
require 'Items/ProceduralDistributions'
|
||||||
|
require 'Items/SuburbsDistributions'
|
||||||
|
require 'Items/ItemPicker'
|
||||||
|
pcall(require, 'Vehicles/VehicleDistributions')
|
||||||
|
|
||||||
|
if _G.__OF_DISTRO_BLOCKER_REGISTERED then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_G.__OF_DISTRO_BLOCKER_REGISTERED = true
|
||||||
|
|
||||||
|
local function safeRequire(moduleName)
|
||||||
|
local ok, result = pcall(require, moduleName)
|
||||||
|
if ok and type(result) == "table" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local config = safeRequire("OFBlockConfig")
|
||||||
|
local spawnProfile = safeRequire("OFSpawnProfile")
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalizeItemType(value)
|
||||||
|
local s = trim(value)
|
||||||
|
if not s or s == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if s:sub(1, 1) == "@" then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
if s:find("%.") then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
return "Base." .. s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalizePrefix(value)
|
||||||
|
local s = trim(value)
|
||||||
|
if not s or s == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if s:sub(-1) == "*" then
|
||||||
|
s = s:sub(1, -2)
|
||||||
|
end
|
||||||
|
return s:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalizeAliasName(value)
|
||||||
|
local s = trim(value)
|
||||||
|
if not s or s == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if s:sub(1, 1) == "@" then
|
||||||
|
s = s:sub(2)
|
||||||
|
end
|
||||||
|
return s:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addAliasItems(outItems, outSeen, aliasMap, aliasName, visiting)
|
||||||
|
local key = normalizeAliasName(aliasName)
|
||||||
|
if not key or visiting[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local values = aliasMap[key]
|
||||||
|
if type(values) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
visiting[key] = true
|
||||||
|
for _, entry in ipairs(values) do
|
||||||
|
if type(entry) == "string" and entry:sub(1, 1) == "@" then
|
||||||
|
addAliasItems(outItems, outSeen, aliasMap, entry, visiting)
|
||||||
|
else
|
||||||
|
local normalized = normalizeItemType(entry)
|
||||||
|
if normalized and normalized:sub(1, 1) ~= "@" then
|
||||||
|
local lowered = normalized:lower()
|
||||||
|
if not outSeen[lowered] then
|
||||||
|
outSeen[lowered] = true
|
||||||
|
outItems[#outItems + 1] = lowered
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
visiting[key] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function expandItems(rawItems, aliasMap)
|
||||||
|
local result = {}
|
||||||
|
local seen = {}
|
||||||
|
if type(rawItems) ~= "table" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, entry in ipairs(rawItems) do
|
||||||
|
if type(entry) == "string" and entry:sub(1, 1) == "@" then
|
||||||
|
addAliasItems(result, seen, aliasMap, entry, {})
|
||||||
|
else
|
||||||
|
local normalized = normalizeItemType(entry)
|
||||||
|
if normalized then
|
||||||
|
local lowered = normalized:lower()
|
||||||
|
if not seen[lowered] then
|
||||||
|
seen[lowered] = true
|
||||||
|
result[#result + 1] = lowered
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compileAliasMap(rawAliases)
|
||||||
|
local aliasMap = {}
|
||||||
|
if type(rawAliases) ~= "table" then
|
||||||
|
return aliasMap
|
||||||
|
end
|
||||||
|
for aliasName, list in pairs(rawAliases) do
|
||||||
|
local key = normalizeAliasName(aliasName)
|
||||||
|
if key then
|
||||||
|
aliasMap[key] = list
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return aliasMap
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compileBlockMatcher(spec, aliasMap)
|
||||||
|
local matcher = {
|
||||||
|
items = {},
|
||||||
|
prefixes = {},
|
||||||
|
}
|
||||||
|
if type(spec) ~= "table" then
|
||||||
|
return matcher
|
||||||
|
end
|
||||||
|
|
||||||
|
local expandedItems = expandItems(spec.items, aliasMap)
|
||||||
|
for _, lowered in ipairs(expandedItems) do
|
||||||
|
matcher.items[lowered] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(spec.prefixes) == "table" then
|
||||||
|
for _, rawPrefix in ipairs(spec.prefixes) do
|
||||||
|
local prefix = normalizePrefix(rawPrefix)
|
||||||
|
if prefix then
|
||||||
|
matcher.prefixes[#matcher.prefixes + 1] = prefix
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return matcher
|
||||||
|
end
|
||||||
|
|
||||||
|
local function itemMatches(itemTypeLower, matcher)
|
||||||
|
if matcher.items[itemTypeLower] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
for _, prefix in ipairs(matcher.prefixes) do
|
||||||
|
if itemTypeLower:sub(1, #prefix) == prefix then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compileListPatterns(raw)
|
||||||
|
if raw == nil or raw == "*" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local patterns = {}
|
||||||
|
|
||||||
|
local function addPattern(value)
|
||||||
|
local s = trim(value)
|
||||||
|
if not s or s == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if s == "*" then
|
||||||
|
patterns = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
patterns[#patterns + 1] = s:lower()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(raw) == "string" then
|
||||||
|
addPattern(raw)
|
||||||
|
elseif type(raw) == "table" then
|
||||||
|
local source = raw.lists or raw
|
||||||
|
if type(source) == "string" then
|
||||||
|
addPattern(source)
|
||||||
|
elseif type(source) == "table" then
|
||||||
|
for _, value in ipairs(source) do
|
||||||
|
if addPattern(value) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
end
|
||||||
|
|
||||||
|
local function listMatchesPattern(listNameLower, patternLower)
|
||||||
|
if not patternLower or patternLower == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if patternLower:sub(-1) == "*" then
|
||||||
|
local prefix = patternLower:sub(1, -2)
|
||||||
|
return listNameLower:sub(1, #prefix) == prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
if listNameLower == patternLower then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return listNameLower:sub(-#patternLower - 1) == ("." .. patternLower)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function listMatches(listNameLower, patterns)
|
||||||
|
if not patterns then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
if listMatchesPattern(listNameLower, pattern) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compileRules(rawRules, aliasMap)
|
||||||
|
local compiled = {}
|
||||||
|
if type(rawRules) ~= "table" then
|
||||||
|
return compiled
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, rule in ipairs(rawRules) do
|
||||||
|
if type(rule) == "table" then
|
||||||
|
local whenBlock = rule.when or {}
|
||||||
|
compiled[#compiled + 1] = {
|
||||||
|
id = trim(rule.id) or "unnamed",
|
||||||
|
enabled = rule.enabled ~= false,
|
||||||
|
startsAt = tonumber(rule.startsAt or whenBlock.startsAt),
|
||||||
|
endsAt = tonumber(rule.endsAt or whenBlock.endsAt),
|
||||||
|
listPatterns = compileListPatterns(rule.where),
|
||||||
|
matcher = compileBlockMatcher(rule.block or rule, aliasMap),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return compiled
|
||||||
|
end
|
||||||
|
|
||||||
|
local aliasMap = compileAliasMap(config.aliases)
|
||||||
|
local globalMatcher = compileBlockMatcher(config.global, aliasMap)
|
||||||
|
local byListMatchers = {}
|
||||||
|
if type(config.byList) == "table" then
|
||||||
|
for listPattern, spec in pairs(config.byList) do
|
||||||
|
byListMatchers[#byListMatchers + 1] = {
|
||||||
|
pattern = trim(listPattern) and trim(listPattern):lower() or "",
|
||||||
|
matcher = compileBlockMatcher(spec, aliasMap),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ruleMatchers = compileRules(config.rules, aliasMap)
|
||||||
|
|
||||||
|
local function compileSpawnProfile(rawProfile)
|
||||||
|
local managedItemSet = {}
|
||||||
|
local placementsByList = {}
|
||||||
|
local managedCount = 0
|
||||||
|
|
||||||
|
local function addPlacement(listName, itemType, rawWeight)
|
||||||
|
local cleanList = trim(listName)
|
||||||
|
local weight = tonumber(rawWeight)
|
||||||
|
if not cleanList or cleanList == "" or not weight or weight <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not placementsByList[cleanList] then
|
||||||
|
placementsByList[cleanList] = {}
|
||||||
|
end
|
||||||
|
placementsByList[cleanList][itemType] = weight
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addEntry(itemTypeRaw, entry)
|
||||||
|
local normalized = normalizeItemType(itemTypeRaw)
|
||||||
|
if not normalized or normalized:sub(1, 1) == "@" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lowered = normalized:lower()
|
||||||
|
if not managedItemSet[lowered] then
|
||||||
|
managedItemSet[lowered] = true
|
||||||
|
managedCount = managedCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if entry.enabled == false then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(entry.placements) == "table" then
|
||||||
|
if entry.placements[1] then
|
||||||
|
for _, row in ipairs(entry.placements) do
|
||||||
|
if type(row) == "table" then
|
||||||
|
addPlacement(row.list, normalized, row.weight)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for listName, weight in pairs(entry.placements) do
|
||||||
|
addPlacement(listName, normalized, weight)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(rawProfile) == "table" then
|
||||||
|
if type(rawProfile.items) == "table" then
|
||||||
|
for itemType, entry in pairs(rawProfile.items) do
|
||||||
|
addEntry(itemType, entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(rawProfile.entries) == "table" then
|
||||||
|
for _, entry in ipairs(rawProfile.entries) do
|
||||||
|
if type(entry) == "table" then
|
||||||
|
addEntry(entry.item, entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return managedItemSet, placementsByList, managedCount
|
||||||
|
end
|
||||||
|
|
||||||
|
local managedSpawnItems, profilePlacementsByList, managedSpawnItemCount = compileSpawnProfile(spawnProfile)
|
||||||
|
|
||||||
|
local function isRuleActive(rule, nowEpoch)
|
||||||
|
if not rule.enabled then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if (rule.startsAt or rule.endsAt) and not nowEpoch then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if rule.startsAt and nowEpoch < rule.startsAt then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if rule.endsAt and nowEpoch > rule.endsAt then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shouldBlock(listName, itemType, nowEpoch)
|
||||||
|
local listLower = (trim(listName) or "unknown"):lower()
|
||||||
|
local itemLower = normalizeItemType(itemType)
|
||||||
|
if not itemLower then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
itemLower = itemLower:lower()
|
||||||
|
|
||||||
|
if managedSpawnItems[itemLower] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if itemMatches(itemLower, globalMatcher) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, entry in ipairs(byListMatchers) do
|
||||||
|
if entry.pattern ~= "" and listMatchesPattern(listLower, entry.pattern) and itemMatches(itemLower, entry.matcher) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, rule in ipairs(ruleMatchers) do
|
||||||
|
if isRuleActive(rule, nowEpoch) and listMatches(listLower, rule.listPatterns) and itemMatches(itemLower, rule.matcher) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function removeBlockedEntries(items, listName, nowEpoch)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local removed = 0
|
||||||
|
local i = 1
|
||||||
|
while i <= #items do
|
||||||
|
local itemType = items[i]
|
||||||
|
if type(itemType) == "string" then
|
||||||
|
if shouldBlock(listName, itemType, nowEpoch) then
|
||||||
|
table.remove(items, i)
|
||||||
|
if i <= #items then
|
||||||
|
table.remove(items, i)
|
||||||
|
end
|
||||||
|
removed = removed + 1
|
||||||
|
else
|
||||||
|
i = i + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return removed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function patchProceduralDistributions(nowEpoch)
|
||||||
|
local removed = 0
|
||||||
|
local pd = ProceduralDistributions and ProceduralDistributions.list
|
||||||
|
if type(pd) ~= "table" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
for listName, listDef in pairs(pd) do
|
||||||
|
if type(listDef) == "table" then
|
||||||
|
removed = removed + removeBlockedEntries(listDef.items, listName, nowEpoch)
|
||||||
|
if type(listDef.junk) == "table" then
|
||||||
|
removed = removed + removeBlockedEntries(listDef.junk.items, listName, nowEpoch)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return removed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function patchNestedDistributionTree(rootName, rootTable, nowEpoch)
|
||||||
|
if type(rootTable) ~= "table" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local removed = 0
|
||||||
|
local visited = {}
|
||||||
|
|
||||||
|
local function walk(node, path)
|
||||||
|
if type(node) ~= "table" or visited[node] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
visited[node] = true
|
||||||
|
|
||||||
|
if type(node.items) == "table" then
|
||||||
|
removed = removed + removeBlockedEntries(node.items, path, nowEpoch)
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(node.junk) == "table" and type(node.junk.items) == "table" then
|
||||||
|
removed = removed + removeBlockedEntries(node.junk.items, path, nowEpoch)
|
||||||
|
end
|
||||||
|
|
||||||
|
for key, value in pairs(node) do
|
||||||
|
if key ~= "items" and type(value) == "table" then
|
||||||
|
local keyName = type(key) == "string" and key or tostring(key)
|
||||||
|
local nextPath = path .. "." .. keyName
|
||||||
|
walk(value, nextPath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
walk(rootTable, rootName)
|
||||||
|
return removed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureProceduralList(listName)
|
||||||
|
local pd = ProceduralDistributions and ProceduralDistributions.list
|
||||||
|
if type(pd) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(pd[listName]) ~= "table" then
|
||||||
|
pd[listName] = {
|
||||||
|
rolls = 2,
|
||||||
|
items = {},
|
||||||
|
junk = { rolls = 1, items = {} },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if type(pd[listName].items) ~= "table" then
|
||||||
|
pd[listName].items = {}
|
||||||
|
end
|
||||||
|
return pd[listName]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyProfilePlacements()
|
||||||
|
local added = 0
|
||||||
|
for listName, entries in pairs(profilePlacementsByList) do
|
||||||
|
local listDef = ensureProceduralList(listName)
|
||||||
|
if listDef then
|
||||||
|
for itemType, weight in pairs(entries) do
|
||||||
|
table.insert(listDef.items, itemType)
|
||||||
|
table.insert(listDef.items, weight)
|
||||||
|
added = added + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return added
|
||||||
|
end
|
||||||
|
|
||||||
|
local function patchAllDistributions()
|
||||||
|
local nowEpoch = nil
|
||||||
|
if os and os.time then
|
||||||
|
nowEpoch = os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local removed = 0
|
||||||
|
removed = removed + patchProceduralDistributions(nowEpoch)
|
||||||
|
removed = removed + patchNestedDistributionTree("SuburbsDistributions", SuburbsDistributions, nowEpoch)
|
||||||
|
removed = removed + patchNestedDistributionTree("VehicleDistributions", VehicleDistributions, nowEpoch)
|
||||||
|
local added = applyProfilePlacements()
|
||||||
|
|
||||||
|
if ItemPickerJava and ItemPickerJava.Parse then
|
||||||
|
ItemPickerJava.Parse()
|
||||||
|
end
|
||||||
|
|
||||||
|
print(string.format("[OFDistributionBlocker] Removed %d entries and added %d profile entries (managed items: %d).", removed, added, managedSpawnItemCount))
|
||||||
|
end
|
||||||
|
|
||||||
|
Events.OnInitWorld.Add(patchAllDistributions)
|
||||||
|
Events.OnLoadMapZones.Add(patchAllDistributions)
|
||||||
|
Events.OnGameStart.Add(patchAllDistributions)
|
||||||
8
42/mod.info
Normal file
8
42/mod.info
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name=Opinionated Firearms
|
||||||
|
id=opinionated_firearms
|
||||||
|
author=Riggs0
|
||||||
|
modversion=1.0.0
|
||||||
|
versionMin=42.12.13
|
||||||
|
require=GaelGunStore_ALPHA
|
||||||
|
|
||||||
|
description=Opinionated Firearms spawn distribution controller for GaelGunStore (B42).
|
||||||
82
README.md
82
README.md
@@ -1,2 +1,84 @@
|
|||||||
# Opinionated Firearms
|
# Opinionated Firearms
|
||||||
|
|
||||||
|
Project Zomboid B42 patch mod + tooling for managing `GaelGunStore` firearm/attachment spawn distribution.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Use CLI `extract` to build a JSON catalog from GGS source (`loot.lua` + scripts).
|
||||||
|
2. Load that catalog JSON in the webapp.
|
||||||
|
3. In the webapp, choose per item:
|
||||||
|
- spawn enabled/disabled
|
||||||
|
- where it should spawn (distribution list)
|
||||||
|
- spawn rate weight
|
||||||
|
4. Export a profile JSON from the webapp.
|
||||||
|
5. Use CLI `apply` to convert the profile JSON into `OFSpawnProfile.lua`.
|
||||||
|
6. Start Project Zomboid with this mod + `GaelGunStore_ALPHA`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Script: `tools/ggs-dist-cli.js`
|
||||||
|
|
||||||
|
### Extract catalog
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tools/ggs-dist-cli.js extract --ggs-root source/GaelGunStore/42 --out data/ggs-spawn-catalog.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Output contains:
|
||||||
|
|
||||||
|
- all firearms/attachments from GGS scripts
|
||||||
|
- where they spawn (`list`)
|
||||||
|
- base spawn weight (`weight`)
|
||||||
|
- sandbox key (`sv`) used by GGS spawn multipliers
|
||||||
|
|
||||||
|
### Apply webapp profile
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tools/ggs-dist-cli.js apply --profile data/of-spawn-profile.json --out common/media/lua/shared/OFSpawnProfile.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes the Lua profile the mod reads at runtime.
|
||||||
|
|
||||||
|
## Webapp
|
||||||
|
|
||||||
|
Path: `webapp/index.html`
|
||||||
|
|
||||||
|
Serve locally (recommended):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m http.server 8080
|
||||||
|
# open http://localhost:8080/webapp/
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- import extracted catalog JSON
|
||||||
|
- import existing profile JSON
|
||||||
|
- filter/search full item list
|
||||||
|
- toggle per-item spawn enabled
|
||||||
|
- edit per-list placements and weights
|
||||||
|
- export profile JSON for CLI `apply`
|
||||||
|
|
||||||
|
## Runtime mod behavior
|
||||||
|
|
||||||
|
Main patcher: `42/media/lua/server/distribution/OFDistributionBlocker.lua`
|
||||||
|
|
||||||
|
- loads block rules (`OFBlockConfig`) and spawn profile (`OFSpawnProfile`)
|
||||||
|
- removes blocked/managed entries from distributions
|
||||||
|
- re-adds managed item placements with chosen weights from spawn profile
|
||||||
|
- reparses ItemPicker after patching
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `mod.info`
|
||||||
|
- `42/mod.info`
|
||||||
|
- `42/media/lua/server/distribution/OFDistributionBlocker.lua`
|
||||||
|
- `common/media/lua/shared/OFSpawnProfile.lua`
|
||||||
|
- `common/media/lua/shared/OFBlockConfig.lua`
|
||||||
|
- `common/media/lua/shared/OFBlockRules_Default.lua`
|
||||||
|
- `common/media/lua/shared/OFBlockRules_User.lua`
|
||||||
|
- `common/media/lua/shared/OFSourceCatalog.lua`
|
||||||
|
- `tools/ggs-dist-cli.js`
|
||||||
|
- `webapp/index.html`
|
||||||
|
- `webapp/styles.css`
|
||||||
|
- `webapp/app.js`
|
||||||
|
|||||||
98
common/media/lua/shared/OFBlockConfig.lua
Normal file
98
common/media/lua/shared/OFBlockConfig.lua
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
local function safeRequire(moduleName)
|
||||||
|
local ok, result = pcall(require, moduleName)
|
||||||
|
if ok and type(result) == "table" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function appendArray(target, source)
|
||||||
|
if type(source) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, value in ipairs(source) do
|
||||||
|
target[#target + 1] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mergeSpec(baseSpec, extraSpec)
|
||||||
|
local merged = {
|
||||||
|
items = {},
|
||||||
|
prefixes = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if type(baseSpec) == "table" then
|
||||||
|
appendArray(merged.items, baseSpec.items)
|
||||||
|
appendArray(merged.prefixes, baseSpec.prefixes)
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(extraSpec) == "table" then
|
||||||
|
appendArray(merged.items, extraSpec.items)
|
||||||
|
appendArray(merged.prefixes, extraSpec.prefixes)
|
||||||
|
end
|
||||||
|
|
||||||
|
return merged
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mergeAliasTables(baseAliases, extraAliases)
|
||||||
|
local merged = {}
|
||||||
|
if type(baseAliases) == "table" then
|
||||||
|
for key, list in pairs(baseAliases) do
|
||||||
|
merged[key] = merged[key] or {}
|
||||||
|
appendArray(merged[key], list)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(extraAliases) == "table" then
|
||||||
|
for key, list in pairs(extraAliases) do
|
||||||
|
merged[key] = merged[key] or {}
|
||||||
|
appendArray(merged[key], list)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return merged
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mergeRuleTables(baseRules, extraRules)
|
||||||
|
local merged = {}
|
||||||
|
appendArray(merged, baseRules)
|
||||||
|
appendArray(merged, extraRules)
|
||||||
|
return merged
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mergeByList(baseByList, extraByList)
|
||||||
|
local merged = {}
|
||||||
|
|
||||||
|
if type(baseByList) == "table" then
|
||||||
|
for listName, spec in pairs(baseByList) do
|
||||||
|
merged[listName] = mergeSpec(nil, spec)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(extraByList) == "table" then
|
||||||
|
for listName, spec in pairs(extraByList) do
|
||||||
|
merged[listName] = mergeSpec(merged[listName], spec)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return merged
|
||||||
|
end
|
||||||
|
|
||||||
|
local defaults = safeRequire("OFBlockRules_Default")
|
||||||
|
local user = safeRequire("OFBlockRules_User")
|
||||||
|
local sourceCatalog = safeRequire("OFSourceCatalog")
|
||||||
|
|
||||||
|
local aliasCatalog = {
|
||||||
|
firearms = sourceCatalog.firearms or {},
|
||||||
|
attachments = sourceCatalog.attachments or {},
|
||||||
|
ggs_all = sourceCatalog.ggs_all or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local merged = {
|
||||||
|
global = mergeSpec(defaults.global, user.global),
|
||||||
|
byList = mergeByList(defaults.byList, user.byList),
|
||||||
|
rules = mergeRuleTables(defaults.rules, user.rules),
|
||||||
|
aliases = mergeAliasTables(defaults.aliases, user.aliases),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.aliases = mergeAliasTables(aliasCatalog, merged.aliases)
|
||||||
|
|
||||||
|
return merged
|
||||||
30
common/media/lua/shared/OFBlockRules_Default.lua
Normal file
30
common/media/lua/shared/OFBlockRules_Default.lua
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Default rule set for Opinionated Firearms distribution blocking.
|
||||||
|
-- Keep this empty for a clean-slate baseline.
|
||||||
|
return {
|
||||||
|
global = {
|
||||||
|
items = {},
|
||||||
|
prefixes = {},
|
||||||
|
},
|
||||||
|
byList = {
|
||||||
|
-- Example:
|
||||||
|
-- GunStorePistols = { items = { "Base.AA12" } },
|
||||||
|
},
|
||||||
|
rules = {
|
||||||
|
-- Example rule:
|
||||||
|
-- {
|
||||||
|
-- id = "weekend-attachment-ban",
|
||||||
|
-- enabled = false,
|
||||||
|
-- where = { lists = { "GunStorePistols", "PoliceStorageGuns" } },
|
||||||
|
-- when = { startsAt = 1762473600, endsAt = 1762732800 }, -- unix epoch UTC
|
||||||
|
-- block = {
|
||||||
|
-- items = { "@attachments" },
|
||||||
|
-- prefixes = { "Base.Clip_" },
|
||||||
|
-- },
|
||||||
|
-- },
|
||||||
|
},
|
||||||
|
aliases = {
|
||||||
|
-- Additional custom aliases can be added here.
|
||||||
|
-- Example:
|
||||||
|
-- police_vote_list = { "Base.AA12", "Base.AK47" },
|
||||||
|
},
|
||||||
|
}
|
||||||
33
common/media/lua/shared/OFBlockRules_User.lua
Normal file
33
common/media/lua/shared/OFBlockRules_User.lua
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- User-editable rules.
|
||||||
|
-- The web app can overwrite this file with vote results later.
|
||||||
|
return {
|
||||||
|
global = {
|
||||||
|
items = {
|
||||||
|
-- "Base.AA12",
|
||||||
|
-- "@attachments",
|
||||||
|
},
|
||||||
|
prefixes = {
|
||||||
|
-- "Base.Clip_",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
byList = {
|
||||||
|
-- PoliceStorageGuns = {
|
||||||
|
-- items = { "Base.AK47" },
|
||||||
|
-- },
|
||||||
|
},
|
||||||
|
rules = {
|
||||||
|
-- {
|
||||||
|
-- id = "example-scheduled-block",
|
||||||
|
-- enabled = false,
|
||||||
|
-- where = { lists = { "GunStorePistols", "ArmyStorageGuns" } },
|
||||||
|
-- when = { startsAt = 1762473600, endsAt = 1762732800 }, -- unix epoch UTC
|
||||||
|
-- block = {
|
||||||
|
-- items = { "Base.AK74u", "@firearms" },
|
||||||
|
-- prefixes = { "Base.Clip_" },
|
||||||
|
-- },
|
||||||
|
-- },
|
||||||
|
},
|
||||||
|
aliases = {
|
||||||
|
-- event_list = { "Base.AK74", "Base.AK47" },
|
||||||
|
},
|
||||||
|
}
|
||||||
1836
common/media/lua/shared/OFSourceCatalog.lua
Normal file
1836
common/media/lua/shared/OFSourceCatalog.lua
Normal file
File diff suppressed because it is too large
Load Diff
5
common/media/lua/shared/OFSpawnProfile.lua
Normal file
5
common/media/lua/shared/OFSpawnProfile.lua
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Auto-generated by tools/ggs-dist-cli.js apply
|
||||||
|
-- Keep this empty template in git; generated content can overwrite it.
|
||||||
|
return {
|
||||||
|
items = {},
|
||||||
|
}
|
||||||
27
data/OFSpawnProfile.generated.lua
Normal file
27
data/OFSpawnProfile.generated.lua
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Auto-generated by tools/ggs-dist-cli.js apply
|
||||||
|
-- Generated at: 2026-02-11T22:49:03.184Z
|
||||||
|
return {
|
||||||
|
items = {
|
||||||
|
["Base.1P78"] = {
|
||||||
|
enabled = true,
|
||||||
|
placements = {
|
||||||
|
["ArmyStorageAmmunition"] = 1,
|
||||||
|
["ArmyStorageGuns"] = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["Base.1PN93_4"] = {
|
||||||
|
enabled = false,
|
||||||
|
placements = {
|
||||||
|
["ArmyStorageAmmunition"] = 1,
|
||||||
|
["ArmyStorageGuns"] = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["Base.9x39_Silencer"] = {
|
||||||
|
enabled = true,
|
||||||
|
placements = {
|
||||||
|
["ArmyStorageAmmunition"] = 1,
|
||||||
|
["ArmyStorageGuns"] = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
102537
data/ggs-spawn-catalog.json
Normal file
102537
data/ggs-spawn-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
47
data/of-spawn-profile.sample.json
Normal file
47
data/of-spawn-profile.sample.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"item": "Base.1P78",
|
||||||
|
"enabled": true,
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageAmmunition",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageGuns",
|
||||||
|
"weight": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": "Base.1PN93_4",
|
||||||
|
"enabled": false,
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageAmmunition",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageGuns",
|
||||||
|
"weight": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": "Base.9x39_Silencer",
|
||||||
|
"enabled": true,
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageAmmunition",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"list": "ArmyStorageGuns",
|
||||||
|
"weight": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
mod.info
Normal file
8
mod.info
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name=Opinionated Firearms
|
||||||
|
id=opinionated_firearms
|
||||||
|
author=Riggs0
|
||||||
|
modversion=1.0.0
|
||||||
|
versionMin=42.12.13
|
||||||
|
require=GaelGunStore_ALPHA
|
||||||
|
|
||||||
|
description=Opinionated Firearms spawn distribution controller for GaelGunStore (B42).
|
||||||
352
tools/ggs-dist-cli.js
Normal file
352
tools/ggs-dist-cli.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
node tools/ggs-dist-cli.js extract [--ggs-root <path>] [--out <file>]
|
||||||
|
node tools/ggs-dist-cli.js apply --profile <file> [--out <file>]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
extract Build a full firearm/attachment spawn catalog from GaelGunStore.
|
||||||
|
apply Convert a webapp profile JSON into Lua used by the B42 mod patcher.
|
||||||
|
`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { _: [] };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (token.startsWith("--")) {
|
||||||
|
const key = token.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
args[key] = true;
|
||||||
|
} else {
|
||||||
|
args[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args._.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(filePath) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(filePath) {
|
||||||
|
return fs.readFileSync(filePath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeText(filePath, content) {
|
||||||
|
ensureDir(filePath);
|
||||||
|
fs.writeFileSync(filePath, content, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkFiles(rootDir, predicate, out) {
|
||||||
|
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(rootDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkFiles(fullPath, predicate, out);
|
||||||
|
} else if (predicate(fullPath)) {
|
||||||
|
out.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectItemTypesFromScripts(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
walkFiles(rootDir, (fullPath) => fullPath.toLowerCase().endsWith(".txt"), files);
|
||||||
|
const itemSet = new Set();
|
||||||
|
const itemPattern = /^\s*item\s+([A-Za-z0-9_]+)/gm;
|
||||||
|
for (const filePath of files) {
|
||||||
|
const text = readText(filePath);
|
||||||
|
let match = itemPattern.exec(text);
|
||||||
|
while (match) {
|
||||||
|
itemSet.add(`Base.${match[1]}`);
|
||||||
|
match = itemPattern.exec(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(itemSet).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLootEntries(lootLuaPath) {
|
||||||
|
const lootLua = readText(lootLuaPath);
|
||||||
|
const pattern =
|
||||||
|
/\{\s*list\s*=\s*"([^"]+)"\s*,\s*item\s*=\s*"([^"]+)"\s*,\s*weight\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*sv\s*=\s*"([^"]+)"\s*\}/g;
|
||||||
|
const entries = [];
|
||||||
|
let match = pattern.exec(lootLua);
|
||||||
|
while (match) {
|
||||||
|
entries.push({
|
||||||
|
list: match[1],
|
||||||
|
item: match[2],
|
||||||
|
weight: Number.parseFloat(match[3]),
|
||||||
|
sv: match[4],
|
||||||
|
});
|
||||||
|
match = pattern.exec(lootLua);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregatePlacements(entries) {
|
||||||
|
const placementMap = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const previous = placementMap.get(entry.list) || 0;
|
||||||
|
placementMap.set(entry.list, previous + entry.weight);
|
||||||
|
}
|
||||||
|
return Array.from(placementMap.entries())
|
||||||
|
.map(([list, weight]) => ({ list, weight: Number(weight.toFixed(6)) }))
|
||||||
|
.sort((a, b) => a.list.localeCompare(b.list));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemType(item) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.includes(".") ? trimmed : `Base.${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCatalog(ggsRoot) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const firearmScriptsDir = path.join(ggsRoot, "media", "scripts", "Firearms");
|
||||||
|
const attachmentScriptsDir = path.join(ggsRoot, "media", "scripts", "GunPartItem");
|
||||||
|
const lootLuaPath = path.join(ggsRoot, "media", "lua", "server", "item", "loot.lua");
|
||||||
|
|
||||||
|
if (!fs.existsSync(firearmScriptsDir)) {
|
||||||
|
throw new Error(`Missing firearms scripts dir: ${firearmScriptsDir}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(attachmentScriptsDir)) {
|
||||||
|
throw new Error(`Missing attachments scripts dir: ${attachmentScriptsDir}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(lootLuaPath)) {
|
||||||
|
throw new Error(`Missing loot file: ${lootLuaPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firearms = collectItemTypesFromScripts(firearmScriptsDir);
|
||||||
|
const attachments = collectItemTypesFromScripts(attachmentScriptsDir);
|
||||||
|
const firearmSet = new Set(firearms);
|
||||||
|
const attachmentSet = new Set(attachments);
|
||||||
|
|
||||||
|
const lootEntries = parseLootEntries(lootLuaPath);
|
||||||
|
const perItemLoot = new Map();
|
||||||
|
const allLists = new Set();
|
||||||
|
const allItems = new Set([...firearms, ...attachments]);
|
||||||
|
|
||||||
|
for (const entry of lootEntries) {
|
||||||
|
const normalized = normalizeItemType(entry.item);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!allItems.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allLists.add(entry.list);
|
||||||
|
|
||||||
|
const existing = perItemLoot.get(normalized) || [];
|
||||||
|
existing.push({
|
||||||
|
list: entry.list,
|
||||||
|
weight: entry.weight,
|
||||||
|
sv: entry.sv,
|
||||||
|
});
|
||||||
|
perItemLoot.set(normalized, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(allItems)
|
||||||
|
.sort()
|
||||||
|
.map((itemType) => {
|
||||||
|
const placements = perItemLoot.get(itemType) || [];
|
||||||
|
const aggregatedPlacements = aggregatePlacements(placements);
|
||||||
|
const svKeys = Array.from(new Set(placements.map((p) => p.sv))).sort();
|
||||||
|
let category = "unknown";
|
||||||
|
if (firearmSet.has(itemType)) {
|
||||||
|
category = "firearm";
|
||||||
|
} else if (attachmentSet.has(itemType)) {
|
||||||
|
category = "attachment";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: itemType,
|
||||||
|
shortId: itemType.replace(/^Base\./, ""),
|
||||||
|
category,
|
||||||
|
defaultEnabled: false,
|
||||||
|
spawnControlKeys: svKeys,
|
||||||
|
placements,
|
||||||
|
aggregatedPlacements,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
ggsRoot: path.relative(cwd, path.resolve(ggsRoot)) || ".",
|
||||||
|
lootLuaPath: path.relative(cwd, path.resolve(lootLuaPath)),
|
||||||
|
firearmScriptsDir: path.relative(cwd, path.resolve(firearmScriptsDir)),
|
||||||
|
attachmentScriptsDir: path.relative(cwd, path.resolve(attachmentScriptsDir)),
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
spawnRateFormula: "effectiveWeight = baseWeight * lootAmountMultiplier * SandboxVars[sv]",
|
||||||
|
lootAmountMultiplierLookup: [0, 0.25, 0.5, 1, 2, 4],
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
firearms: firearms.length,
|
||||||
|
attachments: attachments.length,
|
||||||
|
totalItems: items.length,
|
||||||
|
placementRows: lootEntries.length,
|
||||||
|
distributionLists: allLists.size,
|
||||||
|
},
|
||||||
|
lists: Array.from(allLists).sort(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLuaString(value) {
|
||||||
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLuaProfile(profileJson) {
|
||||||
|
if (!profileJson || !Array.isArray(profileJson.entries)) {
|
||||||
|
throw new Error("Profile JSON must contain an entries array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsMap = new Map();
|
||||||
|
for (const rawEntry of profileJson.entries) {
|
||||||
|
const itemType = normalizeItemType(rawEntry && rawEntry.item);
|
||||||
|
if (!itemType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = rawEntry.enabled !== false;
|
||||||
|
const placements = {};
|
||||||
|
|
||||||
|
if (Array.isArray(rawEntry.placements)) {
|
||||||
|
for (const placement of rawEntry.placements) {
|
||||||
|
const listName =
|
||||||
|
placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||||
|
const weight = Number.parseFloat(placement && placement.weight);
|
||||||
|
if (!listName || !Number.isFinite(weight) || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placements[listName] = Number(weight.toFixed(6));
|
||||||
|
}
|
||||||
|
} else if (rawEntry && typeof rawEntry.placements === "object" && rawEntry.placements) {
|
||||||
|
for (const [listName, rawWeight] of Object.entries(rawEntry.placements)) {
|
||||||
|
const cleanList = listName.trim();
|
||||||
|
const weight = Number.parseFloat(rawWeight);
|
||||||
|
if (!cleanList || !Number.isFinite(weight) || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placements[cleanList] = Number(weight.toFixed(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsMap.set(itemType, {
|
||||||
|
enabled,
|
||||||
|
placements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemTypes = Array.from(itemsMap.keys()).sort();
|
||||||
|
const lines = [];
|
||||||
|
lines.push("-- Auto-generated by tools/ggs-dist-cli.js apply");
|
||||||
|
lines.push(`-- Generated at: ${new Date().toISOString()}`);
|
||||||
|
lines.push("return {");
|
||||||
|
lines.push(" items = {");
|
||||||
|
|
||||||
|
for (const itemType of itemTypes) {
|
||||||
|
const config = itemsMap.get(itemType);
|
||||||
|
lines.push(` [${formatLuaString(itemType)}] = {`);
|
||||||
|
lines.push(` enabled = ${config.enabled ? "true" : "false"},`);
|
||||||
|
lines.push(" placements = {");
|
||||||
|
|
||||||
|
const listNames = Object.keys(config.placements).sort();
|
||||||
|
for (const listName of listNames) {
|
||||||
|
const weight = config.placements[listName];
|
||||||
|
lines.push(` [${formatLuaString(listName)}] = ${weight},`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" },");
|
||||||
|
lines.push(" },");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" },");
|
||||||
|
lines.push("}");
|
||||||
|
lines.push("");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandExtract(args) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const ggsRoot = path.resolve(cwd, args["ggs-root"] || path.join("source", "GaelGunStore", "42"));
|
||||||
|
const outPath = path.resolve(cwd, args.out || path.join("data", "ggs-spawn-catalog.json"));
|
||||||
|
|
||||||
|
const catalog = buildCatalog(ggsRoot);
|
||||||
|
writeText(outPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`Extracted catalog: ${outPath}`);
|
||||||
|
console.log(
|
||||||
|
`Items=${catalog.counts.totalItems}, Firearms=${catalog.counts.firearms}, Attachments=${catalog.counts.attachments}, Lists=${catalog.counts.distributionLists}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandApply(args) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const profilePath = args.profile ? path.resolve(cwd, args.profile) : null;
|
||||||
|
const outPath = path.resolve(
|
||||||
|
cwd,
|
||||||
|
args.out || path.join("common", "media", "lua", "shared", "OFSpawnProfile.lua")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!profilePath) {
|
||||||
|
throw new Error("Missing required --profile <file> argument.");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(profilePath)) {
|
||||||
|
throw new Error(`Profile file not found: ${profilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileJson = JSON.parse(readText(profilePath));
|
||||||
|
const luaProfile = toLuaProfile(profileJson);
|
||||||
|
writeText(outPath, luaProfile);
|
||||||
|
|
||||||
|
console.log(`Applied profile to Lua: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const command = args._[0];
|
||||||
|
|
||||||
|
if (!command || command === "help" || args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "extract") {
|
||||||
|
commandExtract(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "apply") {
|
||||||
|
commandApply(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown command: ${command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ggs-dist-cli] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
557
webapp/app.js
Normal file
557
webapp/app.js
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
catalog: null,
|
||||||
|
profileByItem: {},
|
||||||
|
selectedItem: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = {
|
||||||
|
statusText: document.getElementById("statusText"),
|
||||||
|
catalogFile: document.getElementById("catalogFile"),
|
||||||
|
profileFile: document.getElementById("profileFile"),
|
||||||
|
exportProfile: document.getElementById("exportProfile"),
|
||||||
|
searchInput: document.getElementById("searchInput"),
|
||||||
|
categoryFilter: document.getElementById("categoryFilter"),
|
||||||
|
spawnFilter: document.getElementById("spawnFilter"),
|
||||||
|
itemTableBody: document.getElementById("itemTableBody"),
|
||||||
|
selectedDetails: document.getElementById("selectedDetails"),
|
||||||
|
resetSelected: document.getElementById("resetSelected"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
dom.statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileText(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNumber(value, fallback = 0) {
|
||||||
|
const n = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemType(item) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.includes(".") ? trimmed : `Base.${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCatalog(raw) {
|
||||||
|
if (!raw || !Array.isArray(raw.items)) {
|
||||||
|
throw new Error("Invalid catalog format. Missing items array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists = new Set(Array.isArray(raw.lists) ? raw.lists : []);
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const rawItem of raw.items) {
|
||||||
|
const itemType = normalizeItemType(rawItem.item);
|
||||||
|
if (!itemType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated = Array.isArray(rawItem.aggregatedPlacements)
|
||||||
|
? rawItem.aggregatedPlacements
|
||||||
|
: [];
|
||||||
|
const placements = {};
|
||||||
|
for (const placement of aggregated) {
|
||||||
|
const listName = placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||||
|
const weight = safeNumber(placement && placement.weight, 0);
|
||||||
|
if (!listName || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placements[listName] = Number(weight.toFixed(6));
|
||||||
|
lists.add(listName);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
item: itemType,
|
||||||
|
shortId: typeof rawItem.shortId === "string" ? rawItem.shortId : itemType.replace(/^Base\./, ""),
|
||||||
|
category: typeof rawItem.category === "string" ? rawItem.category : "unknown",
|
||||||
|
defaultEnabled: rawItem.defaultEnabled !== false,
|
||||||
|
spawnControlKeys: Array.isArray(rawItem.spawnControlKeys) ? rawItem.spawnControlKeys : [],
|
||||||
|
defaultPlacements: placements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => a.item.localeCompare(b.item));
|
||||||
|
return {
|
||||||
|
generatedAt: raw.generatedAt || null,
|
||||||
|
source: raw.source || {},
|
||||||
|
items,
|
||||||
|
lists: Array.from(lists).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeProfileFromCatalog() {
|
||||||
|
const profileByItem = {};
|
||||||
|
for (const item of state.catalog.items) {
|
||||||
|
const placements = {};
|
||||||
|
for (const [listName, weight] of Object.entries(item.defaultPlacements)) {
|
||||||
|
placements[listName] = weight;
|
||||||
|
}
|
||||||
|
profileByItem[item.item] = {
|
||||||
|
item: item.item,
|
||||||
|
category: item.category,
|
||||||
|
enabled: item.defaultEnabled,
|
||||||
|
placements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.profileByItem = profileByItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileEntry(itemType) {
|
||||||
|
return state.profileByItem[itemType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProfileEntry(itemType, updater) {
|
||||||
|
const current = getProfileEntry(itemType);
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updater(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredItems() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const search = dom.searchInput.value.trim().toLowerCase();
|
||||||
|
const category = dom.categoryFilter.value;
|
||||||
|
const spawnState = dom.spawnFilter.value;
|
||||||
|
|
||||||
|
return state.catalog.items.filter((item) => {
|
||||||
|
const entry = getProfileEntry(item.item);
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (category !== "all" && item.category !== category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnState === "enabled" && !entry.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnState === "disabled" && entry.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
item.item.toLowerCase().includes(search) ||
|
||||||
|
item.shortId.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemTable() {
|
||||||
|
const filteredItems = getFilteredItems();
|
||||||
|
dom.itemTableBody.innerHTML = "";
|
||||||
|
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const entry = getProfileEntry(item.item);
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
if (state.selectedItem === item.item) {
|
||||||
|
row.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnTd = document.createElement("td");
|
||||||
|
const spawnCheck = document.createElement("input");
|
||||||
|
spawnCheck.type = "checkbox";
|
||||||
|
spawnCheck.checked = entry.enabled;
|
||||||
|
spawnCheck.addEventListener("click", (event) => event.stopPropagation());
|
||||||
|
spawnCheck.addEventListener("change", () => {
|
||||||
|
updateProfileEntry(item.item, (target) => {
|
||||||
|
target.enabled = spawnCheck.checked;
|
||||||
|
});
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
spawnTd.appendChild(spawnCheck);
|
||||||
|
|
||||||
|
const itemTd = document.createElement("td");
|
||||||
|
itemTd.textContent = item.shortId;
|
||||||
|
|
||||||
|
const categoryTd = document.createElement("td");
|
||||||
|
categoryTd.textContent = item.category;
|
||||||
|
|
||||||
|
const listsTd = document.createElement("td");
|
||||||
|
listsTd.textContent = String(Object.keys(entry.placements).length);
|
||||||
|
|
||||||
|
row.appendChild(spawnTd);
|
||||||
|
row.appendChild(itemTd);
|
||||||
|
row.appendChild(categoryTd);
|
||||||
|
row.appendChild(listsTd);
|
||||||
|
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
state.selectedItem = item.item;
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.itemTableBody.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlacementsTable(item, entry, container) {
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "placements-table";
|
||||||
|
const thead = document.createElement("thead");
|
||||||
|
thead.innerHTML = "<tr><th>List</th><th>Weight</th><th></th></tr>";
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
const tbody = document.createElement("tbody");
|
||||||
|
const placementNames = Object.keys(entry.placements).sort();
|
||||||
|
|
||||||
|
for (const listName of placementNames) {
|
||||||
|
const weight = entry.placements[listName];
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const listTd = document.createElement("td");
|
||||||
|
listTd.textContent = listName;
|
||||||
|
|
||||||
|
const weightTd = document.createElement("td");
|
||||||
|
const weightInput = document.createElement("input");
|
||||||
|
weightInput.type = "number";
|
||||||
|
weightInput.min = "0";
|
||||||
|
weightInput.step = "0.001";
|
||||||
|
weightInput.value = String(weight);
|
||||||
|
weightInput.addEventListener("change", () => {
|
||||||
|
const next = safeNumber(weightInput.value, 0);
|
||||||
|
if (next <= 0) {
|
||||||
|
delete entry.placements[listName];
|
||||||
|
} else {
|
||||||
|
entry.placements[listName] = Number(next.toFixed(6));
|
||||||
|
}
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
weightTd.appendChild(weightInput);
|
||||||
|
|
||||||
|
const actionTd = document.createElement("td");
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "small-btn remove";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", () => {
|
||||||
|
delete entry.placements[listName];
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
actionTd.appendChild(removeBtn);
|
||||||
|
|
||||||
|
tr.appendChild(listTd);
|
||||||
|
tr.appendChild(weightTd);
|
||||||
|
tr.appendChild(actionTd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
container.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedDetails() {
|
||||||
|
const selected = state.selectedItem;
|
||||||
|
dom.selectedDetails.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.catalog || !selected) {
|
||||||
|
dom.selectedDetails.textContent = "Select an item to edit placements and spawn rate.";
|
||||||
|
dom.selectedDetails.className = "details-empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = state.catalog.items.find((it) => it.item === selected);
|
||||||
|
const entry = getProfileEntry(selected);
|
||||||
|
if (!item || !entry) {
|
||||||
|
dom.selectedDetails.textContent = "Selected item not found.";
|
||||||
|
dom.selectedDetails.className = "details-empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.selectedDetails.className = "details-body";
|
||||||
|
|
||||||
|
const itemHeader = document.createElement("div");
|
||||||
|
itemHeader.className = "item-header";
|
||||||
|
itemHeader.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<h3 class="item-title">${item.item}</h3>
|
||||||
|
<span class="badge">${item.category}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dom.selectedDetails.appendChild(itemHeader);
|
||||||
|
|
||||||
|
const enabledRow = document.createElement("div");
|
||||||
|
enabledRow.className = "inline-row";
|
||||||
|
const enabledInput = document.createElement("input");
|
||||||
|
enabledInput.type = "checkbox";
|
||||||
|
enabledInput.checked = entry.enabled;
|
||||||
|
enabledInput.addEventListener("change", () => {
|
||||||
|
entry.enabled = enabledInput.checked;
|
||||||
|
renderItemTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledLabel = document.createElement("label");
|
||||||
|
enabledLabel.textContent = "Spawn enabled";
|
||||||
|
enabledLabel.prepend(enabledInput);
|
||||||
|
enabledLabel.style.display = "inline-flex";
|
||||||
|
enabledLabel.style.alignItems = "center";
|
||||||
|
enabledLabel.style.gap = "0.35rem";
|
||||||
|
enabledRow.appendChild(enabledLabel);
|
||||||
|
dom.selectedDetails.appendChild(enabledRow);
|
||||||
|
|
||||||
|
const placementsLabel = document.createElement("p");
|
||||||
|
placementsLabel.textContent = "Placements (distribution list + spawn rate weight):";
|
||||||
|
placementsLabel.style.margin = "0 0 0.4rem";
|
||||||
|
dom.selectedDetails.appendChild(placementsLabel);
|
||||||
|
|
||||||
|
renderPlacementsTable(item, entry, dom.selectedDetails);
|
||||||
|
|
||||||
|
const addRow = document.createElement("div");
|
||||||
|
addRow.className = "inline-row";
|
||||||
|
|
||||||
|
const listSelect = document.createElement("select");
|
||||||
|
const usedLists = new Set(Object.keys(entry.placements));
|
||||||
|
const availableLists = state.catalog.lists.filter((listName) => !usedLists.has(listName));
|
||||||
|
for (const listName of availableLists) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = listName;
|
||||||
|
option.textContent = listName;
|
||||||
|
listSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customInput = document.createElement("input");
|
||||||
|
customInput.type = "text";
|
||||||
|
customInput.placeholder = "or custom list name";
|
||||||
|
|
||||||
|
const weightInput = document.createElement("input");
|
||||||
|
weightInput.type = "number";
|
||||||
|
weightInput.min = "0";
|
||||||
|
weightInput.step = "0.001";
|
||||||
|
weightInput.value = "1";
|
||||||
|
|
||||||
|
const addButton = document.createElement("button");
|
||||||
|
addButton.type = "button";
|
||||||
|
addButton.className = "small-btn";
|
||||||
|
addButton.textContent = "Add Placement";
|
||||||
|
addButton.addEventListener("click", () => {
|
||||||
|
const custom = customInput.value.trim();
|
||||||
|
const selectedList = custom || listSelect.value;
|
||||||
|
const weight = safeNumber(weightInput.value, 0);
|
||||||
|
if (!selectedList || weight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.placements[selectedList] = Number(weight.toFixed(6));
|
||||||
|
if (!state.catalog.lists.includes(selectedList)) {
|
||||||
|
state.catalog.lists.push(selectedList);
|
||||||
|
state.catalog.lists.sort();
|
||||||
|
}
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
addRow.appendChild(listSelect);
|
||||||
|
addRow.appendChild(customInput);
|
||||||
|
addRow.appendChild(weightInput);
|
||||||
|
addRow.appendChild(addButton);
|
||||||
|
dom.selectedDetails.appendChild(addRow);
|
||||||
|
|
||||||
|
const resetRow = document.createElement("div");
|
||||||
|
resetRow.className = "inline-row";
|
||||||
|
|
||||||
|
const restoreButton = document.createElement("button");
|
||||||
|
restoreButton.type = "button";
|
||||||
|
restoreButton.className = "small-btn";
|
||||||
|
restoreButton.textContent = "Restore Catalog Placements";
|
||||||
|
restoreButton.addEventListener("click", () => {
|
||||||
|
entry.enabled = item.defaultEnabled;
|
||||||
|
entry.placements = { ...item.defaultPlacements };
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearButton = document.createElement("button");
|
||||||
|
clearButton.type = "button";
|
||||||
|
clearButton.className = "small-btn remove";
|
||||||
|
clearButton.textContent = "Clear Placements";
|
||||||
|
clearButton.addEventListener("click", () => {
|
||||||
|
entry.placements = {};
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetRow.appendChild(restoreButton);
|
||||||
|
resetRow.appendChild(clearButton);
|
||||||
|
dom.selectedDetails.appendChild(resetRow);
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.innerHTML = `
|
||||||
|
Sandbox weight controls: ${item.spawnControlKeys.length ? item.spawnControlKeys.join(", ") : "none"}<br>
|
||||||
|
Catalog list count: ${Object.keys(item.defaultPlacements).length}
|
||||||
|
`;
|
||||||
|
dom.selectedDetails.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportProfile() {
|
||||||
|
const entries = Object.keys(state.profileByItem)
|
||||||
|
.sort()
|
||||||
|
.map((itemType) => {
|
||||||
|
const entry = state.profileByItem[itemType];
|
||||||
|
const placements = Object.keys(entry.placements)
|
||||||
|
.sort()
|
||||||
|
.map((listName) => ({
|
||||||
|
list: listName,
|
||||||
|
weight: Number(entry.placements[listName]),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
item: itemType,
|
||||||
|
category: entry.category,
|
||||||
|
enabled: entry.enabled,
|
||||||
|
placements,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
sourceCatalog: {
|
||||||
|
generatedAt: state.catalog ? state.catalog.generatedAt : null,
|
||||||
|
source: state.catalog ? state.catalog.source : {},
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTextFile(fileName, content) {
|
||||||
|
const blob = new Blob([content], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCatalogFileSelected() {
|
||||||
|
const file = dom.catalogFile.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await readFileText(file);
|
||||||
|
const rawCatalog = JSON.parse(text);
|
||||||
|
state.catalog = normalizeCatalog(rawCatalog);
|
||||||
|
initializeProfileFromCatalog();
|
||||||
|
state.selectedItem = state.catalog.items.length ? state.catalog.items[0].item : null;
|
||||||
|
setStatus(
|
||||||
|
`Catalog loaded (${state.catalog.items.length} items, ${state.catalog.lists.length} lists).`
|
||||||
|
);
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Catalog load failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
dom.catalogFile.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProfileFileSelected() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
setStatus("Load a catalog first.");
|
||||||
|
dom.profileFile.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = dom.profileFile.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await readFileText(file);
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
if (!Array.isArray(raw.entries)) {
|
||||||
|
throw new Error("Profile must contain an entries array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of raw.entries) {
|
||||||
|
const itemType = normalizeItemType(row.item);
|
||||||
|
if (!itemType || !state.profileByItem[itemType]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = state.profileByItem[itemType];
|
||||||
|
entry.enabled = row.enabled !== false;
|
||||||
|
entry.placements = {};
|
||||||
|
if (Array.isArray(row.placements)) {
|
||||||
|
for (const placement of row.placements) {
|
||||||
|
const listName =
|
||||||
|
placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||||
|
const weight = safeNumber(placement && placement.weight, 0);
|
||||||
|
if (!listName || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.placements[listName] = Number(weight.toFixed(6));
|
||||||
|
if (!state.catalog.lists.includes(listName)) {
|
||||||
|
state.catalog.lists.push(listName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.catalog.lists.sort();
|
||||||
|
setStatus("Profile loaded and applied to current catalog.");
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Profile load failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
dom.profileFile.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExportProfile() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
setStatus("Load a catalog before exporting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildExportProfile();
|
||||||
|
downloadTextFile("of-spawn-profile.json", `${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
setStatus("Profile exported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResetSelected() {
|
||||||
|
if (!state.catalog || !state.selectedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = state.catalog.items.find((it) => it.item === state.selectedItem);
|
||||||
|
const entry = getProfileEntry(state.selectedItem);
|
||||||
|
if (!item || !entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.enabled = item.defaultEnabled;
|
||||||
|
entry.placements = { ...item.defaultPlacements };
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
setStatus(`Reset ${item.shortId} to catalog defaults.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.catalogFile.addEventListener("change", onCatalogFileSelected);
|
||||||
|
dom.profileFile.addEventListener("change", onProfileFileSelected);
|
||||||
|
dom.exportProfile.addEventListener("click", onExportProfile);
|
||||||
|
dom.searchInput.addEventListener("input", renderItemTable);
|
||||||
|
dom.categoryFilter.addEventListener("change", renderItemTable);
|
||||||
|
dom.spawnFilter.addEventListener("change", renderItemTable);
|
||||||
|
dom.resetSelected.addEventListener("click", onResetSelected);
|
||||||
77
webapp/index.html
Normal file
77
webapp/index.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Opinionated Firearms Spawn List Builder</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Opinionated Firearms Spawn List Builder</h1>
|
||||||
|
<p>Edit firearm/attachment spawn enablement, placement lists, and spawn rates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<label class="file-button">
|
||||||
|
Load Catalog JSON
|
||||||
|
<input id="catalogFile" type="file" accept=".json,application/json">
|
||||||
|
</label>
|
||||||
|
<label class="file-button secondary">
|
||||||
|
Load Profile JSON
|
||||||
|
<input id="profileFile" type="file" accept=".json,application/json">
|
||||||
|
</label>
|
||||||
|
<button id="exportProfile" type="button">Export Profile JSON</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status">
|
||||||
|
<span id="statusText">Load an extracted catalog JSON to begin.</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Items</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<input id="searchInput" type="search" placeholder="Search item ID...">
|
||||||
|
<select id="categoryFilter">
|
||||||
|
<option value="all">All categories</option>
|
||||||
|
<option value="firearm">Firearms</option>
|
||||||
|
<option value="attachment">Attachments</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
<select id="spawnFilter">
|
||||||
|
<option value="all">All spawn states</option>
|
||||||
|
<option value="enabled">Spawn enabled</option>
|
||||||
|
<option value="disabled">Spawn disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Spawn</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Spawn Loacation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="itemTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel details-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Selected Item</h2>
|
||||||
|
<button id="resetSelected" type="button">Reset to Catalog</button>
|
||||||
|
</div>
|
||||||
|
<div id="selectedDetails" class="details-empty">Select an item to edit placements and spawn rate.</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
263
webapp/styles.css
Normal file
263
webapp/styles.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf1;
|
||||||
|
--panel-2: #f8f0e2;
|
||||||
|
--line: #d8ccb8;
|
||||||
|
--text: #2f2418;
|
||||||
|
--muted: #7d6852;
|
||||||
|
--accent: #9c3f1f;
|
||||||
|
--accent-2: #4f6f52;
|
||||||
|
--focus: #c77800;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 95% 0%, #e2c8a0 0%, transparent 60%),
|
||||||
|
linear-gradient(145deg, #f8f2e7 0%, var(--bg) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem 1.3rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 250, 241, 0.8);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button,
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 0.48rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button.secondary {
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.file-button:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
.file-button:focus-within {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 0.5rem 1.3rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 1.1fr) minmax(320px, 0.9fr);
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.3rem 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
min-height: 66vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"] {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #fff2de;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.selected {
|
||||||
|
background: #f9dfbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-empty {
|
||||||
|
padding: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placements-table th,
|
||||||
|
.placements-table td {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.34rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn.remove {
|
||||||
|
border-color: #b74a38;
|
||||||
|
color: #8f2617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0.7rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 45vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user