SHIPPED (nothing to do) - Confession booth ($confessions / $confess) - Ship command ($ship) - Temp-roles ($temprole) - Server backup snapshot ($backup) - Timezone lookup ($time) - includes users setting their own - $botclear - $imute (image mute, optional duration, permanent if none given) - Vanity URL protection (folded into the existing GuildUpdate antinuke detection - no new module needed, it already watches server-profile changes the same way) - $nuke (one-shot, Yes/Cancel confirmation first) - $autonuke (recurring, 5 minute minimum interval) - Bot avatar AND banner per-server ($botprofile) - correction from earlier: banner per-guild for bots is real (discord.js's GuildMemberManager#editMe), I was wrong to hold it back, built both - $purge/$clear up to 2000 (chains Discord's 100-per-call limit, reports how many got skipped for being older than 14 days) - $mute/$timeout with no duration (defaults to Discord's native 28-day max instead of erroring) - clearuser - turned out to already exist ($purge @user) All of the above: tested against the real dev DB / mocked discord.js objects, full command+event regression sweep clean, changelog (v0.0.47) and Docs page updated with real verified snippets. STEP 3 - DONE =============================================================== [x] 1500 new items (375 fish, 375 animals, 376 gear, 378 potions) plus a new "source" rarity tier above secret, per the approved price curve. [x] Potions got a real architectural fix, not just data - every gambling command used to hardcode one specific boost item id, so new potion tiers wouldn't have done anything. shop.js now looks up "the best active boost in a family" instead (xp/luck/grind/interest/ catch_upgrade/insurance), all 13 call sites updated. [x] Heist: getting caught now risks losing part of your catch inventory (rarity-weighted - secret-tier catches are a real gamble, common ones barely at risk), on top of the existing fine + jail time. [x] Found and fixed a real bug this surfaced: $shop used to dump every item in a family into one embed field - fine at 3-4 items, but would have hit Discord's 1024-char field limit and failed to send outright once families grew to 90+ items. Rebuilt as a summary + drill-down ($shop ), safely chunked either way. Generator script lives at scripts/generateItems.js if more items are ever wanted later - run it with `node scripts/generateItems.js `. It reads catches.js/shop.js at generation time to avoid name/id collisions with whatever already exists, which now includes this batch's own 1500 items - so running it again adds a further batch on top, it doesn't reproduce this exact one. The actual game data it produced is checked in at src/data/generatedItems.js. Tested against the real dev DB (roll distribution, full buy/sell/gear- bonus pipeline, boost family lookup, a real end-to-end heist failure with item loss), full regression sweep clean, changelog (v0.0.48) and Docs updated. STEP 4 - DONE (second content pass) =============================================================== [x] 1500 more fish, 1500 more animals (hunt), 1500 more dig (capped at Mythic, matching dig's original design), and 1500 for a brand new "scavenge" catch category (full common-through-source spread). [x] $scavenge got a real fix, not just data - it used to roll a random *common-only* item borrowed from fish/hunt/dig (rollAnyCommon). Now it has its own dedicated pool via rollScavenge - still no gear required and no gear bonus, matching its whole point. [x] $botinfo (alias $stats) - uptime, memory, DB size, server/user/command counts, versions, ping. "Developer: rotten.network" per the ask. Second generator script at scripts/generateItemsBatch2.js (different seed, expanded name pools, dig/scavenge noun pools added) - output checked in at src/data/generatedItemsBatch2.js. Same non-idempotent caveat as the first script: it reads the current catalog to avoid collisions, so re-running it adds a THIRD batch on top, not a reproduction of this one. Tested against the real dev DB (rarity distribution for both dig and the new scavenge pool, zero id/name collisions with the existing 818+750-item catalog, $scavenge end-to-end), full regression sweep clean (143 commands), changelog (v0.0.54) and Docs (new Scavenge Loot section) updated. STEP 5 - DONE =============================================================== [x] $scavenge got its own gear family (Metal Detector/Pro Detector/ Legendary Detector, ,shop family "scavenge") - boosts coin pay and gives an upgrade-chance on finds, same best-owned-tier-wins mechanic as rods/rifles/pickaxes. Purely optional - $scavenge still needs no gear to use at all, unlike fish/hunt/dig's hard unlock gate. Required a catches.js SOURCE_FAMILY fix too (had no 'scavenge' entry, so gear bonuses on scavenge finds silently no-opped even once items existed). [x] Chained commands: "$dig, $mine, $fish, $hunt" in one message now runs all four instead of needing four separate messages. Only splits at a comma immediately followed by a real prefix (lookahead-based), so a comma inside a command's own arguments ($say hello, world) is left alone. Each chained command still runs the FULL per-command guard chain independently (maintenance kill switch, channel/role locks, gamble jail/cooldown, external-lookup cooldown) - chaining never shares or skips a check across commands. Capped at 6 chained commands per message to prevent spam. [x] $mine added as an alias for $dig. Tested against the real dev DB (bought a Legendary Detector, confirmed 2x/12% gear bonus actually applies across 30 real scavenge rolls), pure unit tests on the chain-parsing regex (chains correctly, leaves plain chat and comma-containing command args alone, caps at 6), full command-catalog + event-load regression sweep clean (197 prefix commands incl. aliases, zero collisions), Docs (new Scavenge Gear section, updated Scavenge Loot description) and changelog (v0.0.55) updated. STEP 6 - DONE =============================================================== [x] $heist can now take an explicit weapon choice - $heist (weapon) - instead of always being auto-forced into whichever heist weapon you own with the highest successBonus. Surfaced a real design bug while building this: Heist Weapons were being treated like every other gear family (buying a better tier silently deletes the worse one via shop.js's removeWorseInFamily), even though their own item descriptions already describe a genuine risk/reward tradeoff (more success chance, but a bigger fine if caught) rather than a strict upgrade. Fixed at the source: heist_weapon is now exempt from the "only the best owned item in a family matters" rule everywhere else still correctly applies to (fishing/hunting/digging/scavenge gear, where a better tier really is strictly better). $gift's refusal logic updated to match - only refuses an exact duplicate for heist weapons, not "anything as good or better." [x] heist.js's service layer now locks in each participant's chosen weapon at join time (a Map, not a Set) instead of recomputing "best owned" at resolution - a deliberate choice to bring a weaker weapon has to actually stick through to the fine calculation, or there'd be no real choice at all. Tested against the real dev DB: bought both a Crowbar and an Assault Rifle on the same account and confirmed both stayed owned (the exemption working), then ran a full simulated heist forcing a loss where the participant explicitly brought the Crowbar despite owning the Assault Rifle - confirmed their fine used the Crowbar's 1x multiplier (500 coins), not the Rifle's 3x (which would've been 1500), proving the choice sticks end-to-end through resolution. Full command-catalog + event-load regression sweep clean, Docs (Heist Weapons and $gift sections updated) and changelog (v0.0.56) updated. STEP 7 - DONE =============================================================== [x] Scavenge gear expanded from 3 tiers to 10 - Advanced Detector (uncommon), Military Detector (epic), Mythic/Divine/Celestial/Secret/ Source Detector added alongside the existing Metal/Pro/Legendary Detector, giving scavenge the deepest gear progression of any activity (fishing/hunting stop at legendary, digging stops at epic). Same best-owned-tier-wins mechanic as every other value-multiplier family - unlike Heist Weapons, these ARE strict upgrades, so buying a better detector still correctly removes the worse one. Top tier (Source Detector, 5,000,000 coins): +400% value on scavenge finds, +32% chance to bump a find up one rarity tier. Tested against the real dev DB: bought a Source Detector and confirmed the 5x value multiplier actually applied across 20 real scavenge rolls (19/20 came in over the un-boosted max possible pay), confirmed buying Pro Detector after Metal Detector correctly removes the Metal Detector (the strict-upgrade rule still applies here, unlike heist weapons), zero id collisions against the existing catalog. Full command-catalog + event-load regression sweep clean, Docs page picks the new tiers up automatically (generic per-family gear table, no hardcoded list), items.txt export regenerated, changelog (v0.0.57) updated. STEP 8 - DONE =============================================================== [x] User spotted "No bonus (base tier)" all over items.txt and asked for every item that says that to actually do something. Investigated and found two separate things going on, not one bug: 1. REAL gap: $buy fishing_rod / hunting_rifle / shovel (the hand- authored base unlock items) and 66 common-tier generated fishing/ hunting/digging items that were exact duplicates of them (Bronze Rod, Armored Net, Electric Shovel, etc.) all had a flat 1x value multiplier - genuinely zero bonus beyond unlocking the activity, despite most of the duplicates costing MORE than the base item for the exact same nothing. Fixed: all of them now give +10% value. scripts/fixCommonTierGear.js does the generated-data side of this (regex-patches src/data/generatedItems.js in place, one-off, not idempotent to re-run without re-checking). 2. FALSE alarm: every Heist Weapon (Crowbar/Pistol/Shotgun/Assault Rifle plus ~94 generated variants across every rarity tier up to source) was showing "No bonus" too, but this turned out to be a display-only bug in items.txt's own export script - it only ever checked valueMultiplier/upgradeChance (the fields fishing/hunting/ digging gear use), never successBonus/fineMultiplier (the fields Heist Weapons actually use). The live game data was already correct at every tier; only the export's description text was wrong. Fixed scripts/exportItemList.js to check both field pairs. Tested against the real dev DB: bought fishing_rod, confirmed gearBonusApplied fires and the 1.1x multiplier actually applies across 25 real fish rolls. Confirmed src/data/generatedItems.js still loads cleanly post-regex-patch (376 gear items intact, zero left at the old 1x/0%). Regenerated items.txt - zero "No bonus" lines remain anywhere in the catalog. Full command-catalog + event-load regression sweep clean, Docs page renders clean, changelog (v0.0.58) updated. STEP 9 - DONE =============================================================== [x] Audited every docCard on the dashboard Docs page for a real usage snippet (typed command + actual reply text), not just prose. Found exactly one section that never got them: all 20 Economy & Gambling cards (coinflip through luck_charm) - every other documented command on the whole page already had one. Added a snippet to each, pulled from the real reply text in each command's own source file (not invented wording) so they read like an authentic transcript. Verified by rendering the actual Docs page (views.renderDocs) end to end - 51 code-snippet blocks present (31 pre-existing + 20 new), no HTML escaping issues, full command-catalog + event-load regression sweep clean. Changelog (v0.0.59) updated. STEP 10 - DONE =============================================================== [x] Bot Discord presence/status made fully configurable from the owner- only /admin dashboard page, instead of the single hardcoded string it was before (src/events/ready.js used to call client.user.setPresence once at boot with a fixed custom-status string, never touched again). New "Bot Presence / Status" panel: one entry per line (TYPE: text), types PLAYING/WATCHING/LISTENING/COMPETING/STREAMING/CUSTOM. One entry just stays put; more than one rotates on a configurable interval (5s minimum). Saving nothing keeps the exact original default (a single CUSTOM entry with the bot's old hardcoded text), so this is a strict opt-in - nothing changes for an owner who never touches the new panel. [x] Template variables inside activity text: {total.servers}, {total.members} (genuinely deduplicated across every mutual server via each guild's already-populated member cache - NOT the same as summing each guild's memberCount, which double-counts anyone in more than one mutual server), {total.channels}, {total.roles}, {total.commands}, {uptime}, {ping}. Re-rendered fresh every rotation tick, not baked in once at save time. [x] Extracted the stats math $botinfo already computed (servers, summed user count, commands, uptime, memory, db size, ping, versions) out into a new shared src/services/botStats.js, so $botinfo and the new activity variables both read the exact same numbers instead of two separate computations that could quietly drift apart. $botinfo's own output is unchanged - it's a pure refactor there. [x] New DB table bot_activity_config (single-row JSON blob, owner-write- gated same as command_maintenance) plus src/services/botActivity.js: getConfig/setConfig, a dedicated {dotted.name} template renderer (deliberately separate from embedConfig.js's shared \w+-only renderTemplate, to avoid touching that regex for unrelated features), and tick(client) - polled every 5s from ready.js, internally checking "has this entry's configured interval actually elapsed yet" so it still reacts correctly to interval/entry changes saved from the *dashboard, a separate process* sharing the same DB, without the bot process needing to restart or re-arm a JS interval. Real bug caught before shipping: the parser's "is this a known activity type" check used to be `if (!TYPES[type]) return null`, which silently dropped every PLAYING entry specifically, because ActivityType.Playing's real enum value is 0 - a falsy value - so `!0` evaluated true and rejected an entirely valid line. Fixed to `if (!(type in TYPES))`. Tested against the real dev DB with a mocked discord.js client (discord.js's own Collection, not a plain Map, since guilds.cache/etc. all need .reduce): saved a real 3-entry config (WATCHING/CUSTOM/COMPETING) through the exact same setConfig call the dashboard route makes, reloaded it and confirmed a byte-for-byte match, confirmed an all-invalid save (no colons) is rejected cleanly instead of silently saving nothing, confirmed PLAYING entries now survive the parser after the enum-0 fix, called tick() twice back-to-back and confirmed the second call is a true no-op (doesn't re-rotate before its interval elapses), and confirmed {total.members}/{total.servers} actually substitute real numbers pulled from the mocked client's guild/member caches. Rendered the actual /admin page end-to-end (views.renderAdmin) with the new panel present and correctly pre-filled. Full command-catalog + event-load regression sweep clean (197 commands). Changelog (v0.0.60) updated - not added to the public Docs page since this is bot-owner-only, matching how other owner-only features (global bans, maintenance kill switch) are also left out of the public Docs. STEP 11 - DONE =============================================================== [x] $shop paginated per rarity tier with Prev/Next buttons instead of cramming every tier into one embed. This wasn't just a readability nicety - a big generated family (90+ items) stacking every rarity tier's field into one embed could exceed Discord's 6000-character *total* embed budget even with each individual field already capped at 1024 by the existing chunkedValue helper, meaning the message could genuinely fail to send for the largest families. Only the requester can click (collector filtered to message.author.id, matching the house convention from duel.js/blackjack.js - flat 'shop:prev'/ 'shop:next' customIds, no need to embed a user id since the filter already restricts it), 60s timeout then buttons disable in place. Families with only one populated rarity tier (e.g. tools, heist gear's smaller ends) skip the buttons entirely - no pagination UI shown for something that doesn't need it. [x] New $quote command - reply to a message and it generates a PNG "quote card": the message text large and centered, the author's display name in italics underneath, their @username smaller below that, all over a darkened/desaturated copy of their avatar. Refuses cleanly on a bot message or a message with no text (image/embed-only) instead of producing a broken or blank card. Built on the existing sharp + child-process + FONTCONFIG_FILE pattern already established by mediaCaption.js/mediaCaptionWorker.js (no new dependency - the codebase had already deliberately avoided node-canvas in favor of sharp's proven-cross-platform prebuilds) - new services/quoteCard.js (fetch avatar, spawn worker) and services/quoteCardWorker.js (the actual sharp/SVG compositing, run out- of-process for the same librsvg-text-rendering-needs-a-real-env-var reason documented in mediaCaptionWorker.js). Real bug caught and fixed before shipping: the custom fontconfig this worker runs under only ever registers assets/fonts (just Anton-Regular.ttf) - no system font directories at all - so asking for a generic "sans-serif" family for the display name had nothing to actually resolve to, and font-style="italic" did nothing since Anton has no italic face. Switched the name/username to Anton as well (the one font actually proven to render identically on both this Windows dev box and the Linux VPS) and faked the italic look with a manual SVG skewX() transform instead - which also needed its own fix, since a naive skewX() pivots around y=0 and would have shoved the (large-y, already-centered) name text sideways; the skew is now paired with a y-dependent compensating translate so it visually rotates around the text's own position instead of drifting off-center. Tested end-to-end with real generated test avatars (a flat color, and a gradient so the darken/greyscale effect is actually visible in the output) piped through the actual worker via data: URLs (no real network fetch needed) - confirmed the background darkens/desaturates correctly, confirmed long quote text auto-shrinks font size and wraps up to 5 lines, confirmed long display names/usernames still fit, and visually confirmed the skewed name renders centered (not drifted) at multiple font sizes. Full command-catalog + event-load regression sweep clean (198 commands, $quote added with zero collisions). Not added to the public Docs page - $media (the closest analog, same image-generation-from-a-reply shape) isn't documented there either. Changelog (v0.0.61) updated. STEP 12 - DONE =============================================================== [x] Every catch worth 0 coins now sells for a token 2 coins - boot, seaweed, tracks, opossum, rock (5 items, all common-tier flavor junk). Every single item in the game is worth something now, literally none left at 0. [x] Crafting went from 5 recipes to 791. Every PERMANENT_ITEMS/BOOST_ITEMS entry (401 gear + 384 boosts - hand-authored tiers and all 754 generated variants alike) now has an auto-derived recipe on top of its coin price: fishing gear crafts from fish catches, hunting gear from hunt, digging gear from dig, scavenge gear from scavenge finds: heist weapons and every boost family (no single natural activity source) draw a mix from all four instead. Materials scale with the item's own rarity rank (common needs 2 total, source needs 11), capped to whatever rarity that catch source's roll table actually tops out at (dig's roll table stops at mythic even though dig GEAR is generated up through source, so a source-tier Digging recipe still asks for more mythic dig catches rather than an uncraftable "divine dig catch"). Recipe key is the produced item's own id (,craft fishing_rod reads the same as ,buy fishing_rod) - computed once at require-time as a pure function of shop.js/catches.js data, not a checked-in generated file (deterministic formula, not random content - no need to persist it the way the 1500-item generation passes were). Added a 6th hand-authored catch-bundle recipe, Scavenger's Haul, since scavenge had zero presence anywhere in crafting before this. [x] shop.js refactored so buying and crafting share the exact same "grant ownership" step (grantPermanentItem/grantBoostItem) instead of crafting reaching around it or duplicating the family-supersession logic - buyPermanent/buyBoost handle the coin side, the new craftGrantPermanent/craftGrantBoost skip straight to granting (crafting already spent the catch inputs by then). [x] $craft reworked to handle 791 recipes instead of 5 - a flat list would have been an unreadable wall of text (this is exactly what motivated $shop's own per-rarity pagination a few steps ago). $craft alone shows recipe categories with counts; $craft (fishing, bundle, xp, heist_weapon, etc.) pages through that category one rarity tier at a time with the same ◀ Prev / Next ▶ button pattern $shop now uses; $craft crafts it. Categories are derived, not stored - a recipe's category is whatever catalog actually owns its produced id (gear/boost -> that item's shop family, catch -> a synthetic "bundle" category), same "check which catalog owns this id" trick crafting.findProducedItem already uses elsewhere. Crafting a gear item already owned correctly refuses BEFORE spending any catch inputs (checked ahead of the removal loop) - a doomed craft never costs anything, verified explicitly. Tested against the real dev DB: full recipe-integrity sweep across all 791 recipes (every input resolves to a real catch, every output resolves to a real catch/gear/boost id, no zero-length requirement lists, no non-positive quantities) - zero errors. Crafted a real fishing_rod end-to-end (gathered the exact generated requirement, crafted it, confirmed ownership and zero leftover catches), confirmed crafting it again after re-gathering inputs correctly refuses ("already own this") AND leaves the inputs unspent, crafted a real boost (xp_potion) and confirmed it actually activates, and crafted an original hand-authored catch recipe (hunters_bundle) to confirm the pre-existing path still works unchanged. Rendered the actual /docs page end-to-end with the new crafting counts and per-family crafting mentions. Full command-catalog + event-load regression sweep clean (198 commands). items.txt regenerated (zero "value: 0" or "No bonus" lines remain, Scavenger's Haul present). Changelog (v0.0.62) updated. STEP 13 - DONE =============================================================== [x] User pointed out $shop was showing bare ids/prices with nothing about what an item actually does for a huge chunk of the catalog. Root cause was the same generated-item description gap already found once before while building items.txt (v0.0.58's "false alarm" note) - except that earlier pass only fixed the *export script's* display, never the live $shop/$buy commands or the dashboard Docs tables, which had the exact same gap this whole time. Fixed properly now: moved describeGear/ describeBoost (synthesize real effect text - +X% value, +X% heist success chance, 2x xp for 60 minutes, etc. - from an item's raw numbers when it has no hand-written description) into shop.js itself as exported, shared functions, and pointed every consumer at them: $shop's per-item listing line, $buy's purchase confirmation (didn't show any effect at all before, hand-authored items included), the dashboard Docs page's gear/boost tables, and items.txt's export script (which now reuses shop.js's copy instead of its own separate one). 754 generated items (out of 785 total gear+boosts) go from "just a name" to actually explaining themselves everywhere they're shown. Tested against the real dev DB: confirmed describeGear/describeBoost return real text for sample generated items (phantom_net -> "+10% value", cursed_vigor_serum -> "2x xp for 60 minutes.") while hand-authored items still show their own exact description unchanged. Ran the real $shop command against the fishing family and confirmed every line (hand-authored AND generated) now shows real effect text. Ran the real $buy command end- to-end for both a generated gear item and a generated boost and confirmed the purchase confirmation states the effect. Rendered the actual /docs page and confirmed zero empty table cells remain anywhere (previously silently blank for every generated item). Full command-catalog regression sweep clean (198 commands). items.txt regenerated. Changelog (v0.0.63) updated. STEP 14 - DONE =============================================================== [x] User asked to clean up the shop - every generated gear/potion tier had a pile of items sharing the exact same bonus (only name/price varied), and wanted higher tiers to never dip down into a lower tier's range. Wrote scripts/dedupeGeneratedItems.js (one-off, mutates src/data/generatedItems.js in place, same convention as fixCommonTierGear.js) - groups every generated gear/potion item by family+rarity, keeps at most 3 per group, and gives those 3 a small +/-5-8% spread around the tier's original baseline value instead of leaving them as identical clones. The spread is deliberately kept small relative to the gap between adjacent tiers (e.g. digging's rare->epic gap is 1.5->1.75, nearly 3x bigger than an 8% spread on 1.5), so a celestial-tier roll can never land on what a rare tier would give - this falls out of the existing tier-spacing automatically, no special- casing needed for high tiers specifically. Backed up generatedItems.js to the session scratchpad before running the destructive rewrite (no git in this repo to fall back on). Gear: 401 -> 129 items (25 hand-authored, unaffected, + 104 generated, down from 376). Boosts: 384 -> 156 (6 hand-authored + 150 generated, down from 378). Crafting recipes fell from 791 to 291 automatically - recipes are generated directly off shop.PERMANENT_ITEMS/BOOST_ITEMS at require-time (see STEP 12), so shrinking the catalog shrinks the recipe list with no separate update needed. Tested against the real dev DB: confirmed every family+rarity group in the rewritten file capped at 3 (or stayed at 1 if it only ever had 1, e.g. secret/source tiers), confirmed the 3 kept items in a sample group (fishing:common) now show three DIFFERENT valueMultiplier values (1.045/ 1.1/1.155) instead of three identical 1.1s, and same for a boost group (xp:rare - three different multipliers AND durations). Ran the real $shop command against fishing and visually confirmed the per-item effect text now actually differs line to line. Full command-catalog + recipe-integrity regression sweep clean (198 commands, 291 recipes, zero broken references). Rendered the actual /docs page end-to-end. items.txt regenerated. Changelog (v0.0.64) updated. STEP 17 - DONE (real production bug, reported by user) =============================================================== [x] User reported some players get an error on $inventory. Root cause: fallout from STEP 15's catch-trimming pass - a player who caught one of the thousands of generated catches BEFORE it got removed from the catalog still has that catch_id sitting in their user_catches row, but catches.getInventory() did `CATCH_TYPES[row.catch_id]` with no fallback and then immediately called effectiveValue(userId, item) on it - item.value on undefined crashed the whole command. $sell all shares this exact code path (calls getInventory internally), so it was equally broken. netWorth.js (the dashboard profile/admin-lookup summary) also calls getInventory directly, so it was silently at risk too, just not yet reported. Fixed getInventory to fall back to a bare stand-in (id as name, rarity common, value 0, source null) instead of crashing - matches the exact pattern shop.listInventory already used for the identical situation with gear/boosts (added back in the original PERMANENT_ITEMS/ BOOST_ITEMS design), which is why $shop-side ownership never had this problem, only the catches side did. Found a second, independent instance of the same bug class while checking for it: $market (browse/mine/buy-confirmation) does `catches.CATCH_TYPES[catch_id].name` directly in the command file, so a listing created before the trim (services/market.js's own `list()` already refuses this for NEW listings, but had no way to retroactively fix ones already sitting open) would crash the same three ways. Added a small catchName(id) fallback helper in market.js's command file for the display layer. Tested against the real dev DB by directly reproducing the exact failure mode: inserted a user_catches row (and separately a market_listings row) referencing a catch id that genuinely doesn't exist in the live CATCH_TYPES anymore, then ran the real $inventory, $sell all, $market (no args), and $market mine commands against that data - all four crashed before the fix and all four now complete successfully, correctly showing the orphaned item as its raw id worth 0 coins (catches) or still-listed-and-buyable (market), sitting alongside real unaffected items with no cross- contamination. Full command-catalog + event-load regression sweep clean (199 commands). Changelog (v0.0.67) updated - this one's a real bug fix affecting normal gameplay, not an owner tool, so unlike the $cheat feature this belongs in the public changelog. STEP 18 - DONE (follow-up to STEP 17, reported by user with a screenshot) =============================================================== [x] User showed a real production $inventory screenshot with several gear entries showing as raw ids (armored_grenade, spectral_drill, spectral_snare) instead of real names - leftover from the shop dedup pass (STEP 14) removing those exact generated gear items from the catalog while players already owned them. Unlike the catches case in STEP 17, this never crashed (shop.listInventory already had a fallback shape for a missing PERMANENT_ITEMS/BOOST_ITEMS entry) - the actual problem here was that these items are 100% permanently inert: every gear-bonus/heist-weapon lookup only ever iterates the live catalog checking ownership, so a stale id is invisible to all of them, and there's no sell-back mechanic for gear at all (only catches are sellable) and no way to $gift an id the catalog doesn't recognize either (transferItem refuses immediately). Players were permanently stuck with dead clutter and zero way to remove it themselves. Reproduced this dev-side is impossible against the live production data (this bot deploys via manual file sync to a separate VPS, not something this environment can reach), so instead of a one-off cleanup script that would need to be manually run against production, added shop.pruneOrphanedInventory() and wired it into ready.js to run automatically on every boot - cheap (a couple of DISTINCT+DELETE passes over small tables), fully idempotent, no "already ran" flag needed. Deploying this fix and restarting the bot on the VPS is enough to actually clean the real affected accounts - no manual DB surgery required from here. Tested against the real dev DB by reproducing the exact screenshot: inserted the same three dead gear ids plus a dead boost type alongside one genuinely-still-valid gear item (Assault Rifle) and one valid boost (XP Potion) for a test user, ran the real shop.pruneOrphanedInventory(), and confirmed all 4 dead entries were removed while both valid ones survived untouched - shop.listInventory() before/after confirms the user's inventory goes from 4 gear + 2 boosts down to exactly the 1 valid gear + 1 valid boost that should remain. Full command-catalog + event-load regression sweep clean (199 commands). Changelog (v0.0.68) updated. STEP 19 - DONE =============================================================== [x] User asked for $sell to work on gear, refunding full price - gear had no sell-back path at all before this (only catches were ever sellable), which was part of what made STEP 18's orphaned-gear problem so sticky in the first place - even *valid* gear you no longer wanted (a redundant Heist Weapon tier, a wrong buy) had zero way to convert back to coins. Added shop.sellPermanent(userId, itemId) - refunds item.price in full, refuses outright for limited-use items (Bolt Cutter) rather than computing a partial refund for remaining uses, refuses if not owned. $sell now checks PERMANENT_ITEMS as a fallback path when the target isn't a catch id. $sell all intentionally untouched - still catches-only, gear has to be named explicitly (selling an entire gear loadout by accident with one command felt like the wrong default). Boosts remain non-sellable - they're temporary/self-consuming by design, refunding one felt like a different problem than this ask. Tested against the real dev DB: bought then sold a Fishing Rod and confirmed the exact price came back and ownership was removed, confirmed selling something never owned refuses cleanly ("You don't own that."), confirmed selling a Bolt Cutter is refused outright and ownership is unaffected, and confirmed the Heist Weapon multi-tier case specifically - owned both a Crowbar and an Assault Rifle, sold just the Crowbar, and confirmed the Assault Rifle survived untouched (this was the actual motivating use case, since Heist Weapons are the one family that lets several tiers coexist). Full command-catalog regression sweep clean (199 commands). Changelog (v0.0.69) updated. STEP 20 - DONE =============================================================== [x] User asked to fix catch odds - every generated catch within one rarity tier was sharing the literal same catch-weight (every common fish had identical odds, no individual variation at all), and wanted each one to be its own settable variable they can tune themselves, for every catch across fish/hunt/dig/scavenge. Wrote scripts/varyGeneratedCatchWeights.js (one-off, mutates both generated data files in place, backed up first per the established no-git-here discipline) - gives each generated catch its own distinct weight, deterministically spread +/-30% around its tier's original shared baseline (hash-based scatter per id, not a strict ascending ramp - reads as "some commons just happen to be more common," not an ordered staircase). Distinct weight COUNT per table went from 4-8 (literally one shared number per tier) to 93-104 (genuinely individual). Hand-authored weights in catches.js itself already varied per item and were left untouched. Found something worth flagging, not fixing here (out of scope for this ask): the AGGREGATE tier-level odds (documented as 400/250/150/ 100/50/30/15/4/1 per 1000 common->secret) drifted noticeably toward common well before this step - confirmed by checking the pre-variance backup, the skew was already present after STEP 15's catch-count trim (which capped item COUNTS per tier via a doubling curve without correspondingly adjusting the original per-item base weights to compensate, so count-ratio x weight-ratio compounds past the intended tier ratio). Verified today's variance work is neutral on this - tier sums stayed within ~1% of their pre-variance value (e.g. fish common 12840 -> 12704) - so this is a pre-existing drift from two steps ago, not something introduced now. Worth a real look if the owner wants the documented tier ratios restored exactly, but that's a base-weight recalibration, a different task than "give each item its own number." Tested against the real dev DB: confirmed every *_WEIGHTS array stayed in perfect 1:1 sync with its item list post-rewrite (zero missing, zero orphaned - same check as STEP 15). Rolled $fish/$hunt/$dig/$scavenge 5,000 times each (20,000 total) with zero crashes. Ran a 100,000-roll simulation of $fish to confirm the roll function still produces a sane, non-degenerate rarity spread end to end. Full command-catalog + event-load regression sweep clean (199 commands). items.txt regenerated. Changelog (v0.0.70) updated. STEP 21 - DONE (real production bug, reported by user) =============================================================== [x] User reported clicking the Disable button on the dashboard's Confessions panel didn't actually disable it for the server. Root cause: dashboard/views.js's Confessions form had a hidden AND a Disable button (