Compare commits

6 Commits
main ... pages

Author SHA1 Message Date
b961fee453 newline 2026-02-11 23:50:33 -05:00
e62d9248c1 asda 2026-02-11 23:34:43 -05:00
65d011c9c3 fix 2026-02-11 23:33:09 -05:00
634acb0d24 test 2026-02-11 23:29:29 -05:00
ab330c48f8 codeberg 2026-02-11 23:23:30 -05:00
4372fdd7ec Pages 2026-02-11 22:28:36 -05:00
18 changed files with 1 additions and 105593 deletions

1
.domains Normal file
View File

@@ -0,0 +1 @@
opinionatedfirearms.hriggs.pages.hudsonriggs.systems

1
.gitignore vendored
View File

@@ -1 +0,0 @@
source/

View File

@@ -1,527 +0,0 @@
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)

View File

@@ -1,8 +0,0 @@
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).

View File

@@ -1,84 +0,0 @@
# 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`

View File

@@ -1,98 +0,0 @@
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

View File

@@ -1,30 +0,0 @@
-- 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" },
},
}

View File

@@ -1,33 +0,0 @@
-- 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" },
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
-- Auto-generated by tools/ggs-dist-cli.js apply
-- Keep this empty template in git; generated content can overwrite it.
return {
items = {},
}

View File

@@ -1,27 +0,0 @@
-- 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,
},
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
{
"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
}
]
}
]
}

View File

@@ -1,8 +0,0 @@
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).

View File

@@ -1,352 +0,0 @@
#!/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;
}