From 6e724cedf7750ec650617ff8efd943b319281bc6 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:32:00 -0300 Subject: [PATCH] Honor modded bag capacity --- docs/gameboy-hardware-limitations.md | 6 ++- docs/new-features.md | 3 +- mods/nuzlocke/main.lua | 2 +- src/core/Data.lua | 10 ++-- src/core/SaveData.lua | 2 +- src/dev/Console.lua | 2 +- src/inventory/Bag.lua | 30 +++++++++--- src/script/Commands.lua | 5 +- src/ui/PlayerPC.lua | 2 +- src/ui/ShopMenu.lua | 2 +- src/world/OverworldController.lua | 4 +- tests/drivers/route.lua | 10 ++-- tests/modkit/cases/bag_capacity.lua | 69 ++++++++++++++++++++++++++++ tests/save_editor_task6_tests.lua | 5 +- tools/save-editor/App.lua | 2 +- tools/save-editor/Ops.lua | 13 ++++-- tools/save-editor/panels/Items.lua | 7 +-- 17 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 tests/modkit/cases/bag_capacity.lua diff --git a/docs/gameboy-hardware-limitations.md b/docs/gameboy-hardware-limitations.md index c5da32f6..53f740fd 100644 --- a/docs/gameboy-hardware-limitations.md +++ b/docs/gameboy-hardware-limitations.md @@ -12,7 +12,7 @@ this port. | # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here | |---|---|---|---|---| -| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) | +| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) | | 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) | | 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` | | 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` | @@ -38,6 +38,10 @@ this port. ## Notes +- Mods may patch `constants.bagSize` through the public content registry. The + native `save.lua` format keeps every existing item when the configured + limit changes; exporting to a cartridge `.sav` still writes only the first + 20 bag slots because the original SRAM layout has no room for more. - PC Box **overflow handling** was deliberately changed even though the 20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards or blocks the deposit," this port spills into the next box with room. diff --git a/docs/new-features.md b/docs/new-features.md index afc5d3d5..be76940e 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs: party dock, so deposit and withdraw live in one place. Empty slots are clickable and create a mon there. - **Items**: money, a searchable item picker (replacing the arrows that - cycled one id at a time through ~250 items), the 20-slot bag, PC storage + cycled one id at a time through ~250 items), the configurable bag (20 slots + by default), PC storage with no slot cap, and the eight badges as toggle chips. - **Events**: flags, defeated trainers, taken items and per-map object toggles, with a real filter field and a two-column paged grid. diff --git a/mods/nuzlocke/main.lua b/mods/nuzlocke/main.lua index e77da325..e4d0dacf 100644 --- a/mods/nuzlocke/main.lua +++ b/mods/nuzlocke/main.lua @@ -156,7 +156,7 @@ return function(mod) caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST" mod.save:set("caught_areas", caughtAreas()) end - Bag.add(self.game.save, ball, 1) + Bag.add(self.game.save, ball, 1, self.game.data) self:say(reason == "area" and "This area already\nhas a captured POKéMON!" or "You already have\nthis POKéMON family!") return diff --git a/src/core/Data.lua b/src/core/Data.lua index ae292d40..13c0a1dd 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -14,12 +14,12 @@ local MODULES = { -- Optional for compatibility with developer and stale caches. local OPTIONAL = { "audio", "palettes", "icons" } --- The rules the engine still carries as literals. The constants registry --- deep-merges over these, so a value has to exist before a mod can patch --- it; each one is the number the engine hard-codes today, so seeding them --- changes nothing on a mod-free boot. +-- Vanilla defaults for rules exposed through the constants registry. A +-- value has to exist before a mod can patch it; each one matches the +-- engine's no-mod behavior, so seeding them changes nothing on a vanilla +-- boot. local CONSTANT_DEFAULTS = { - bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua) + bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback) partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua) boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua) moveMax = 4, diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7d537f3c..cc730b58 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1058,7 +1058,7 @@ local function reclaim(save, data, report) if type(entry) == "table" and known(data.items, entry.id) then table.remove(orphaned.items, i) if entry.from == "pcItems" or type(save.inventory) ~= "table" - or not Bag.add(save, entry.id, entry.count or 1) then + or not Bag.add(save, entry.id, entry.count or 1, data) then save.pcItems = save.pcItems or {} save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1) end diff --git a/src/dev/Console.lua b/src/dev/Console.lua index d4f92106..5b9f1909 100644 --- a/src/dev/Console.lua +++ b/src/dev/Console.lua @@ -214,7 +214,7 @@ function VERBS.give(self, rest) end elseif game.data.items and game.data.items[id] then local n = tonumber(count) or 1 - if require("src.inventory.Bag").add(save, id, n) then + if require("src.inventory.Bag").add(save, id, n, game.data) then self:print(("%s x%d added"):format(id, n)) else self:print("bag full") diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index bd5e8f6a..3bfef34f 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -1,11 +1,26 @@ --- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm): --- a distinct item id occupies one slot regardless of quantity; badges --- live in the inventory table but are not bag items. save.bagOrder --- keeps acquisition order like wBagItems (SELECT can reorder it). +-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY, +-- constants/menu_constants.asm), but mods may replace that limit through +-- Data.constants.bagSize. A distinct item id occupies one slot regardless +-- of quantity; badges live in the inventory table but are not bag items. +-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder +-- it). local Bag = {} -Bag.CAPACITY = 20 +local DEFAULT_CAPACITY = 20 + +-- `data` is injectable for the save editor and headless mod tests. Normal +-- gameplay may omit it because the loader merges mods into the Data +-- singleton before any item can be added. The fallback keeps old/stale +-- generated caches and isolated callers at the vanilla limit. +function Bag.capacity(data) + data = data or require("src.core.Data") + local configured = data and data.constants and data.constants.bagSize + if type(configured) == "number" and configured >= 1 then + return math.floor(configured) + end + return DEFAULT_CAPACITY +end local function isBadge(id) return id:find("BADGE", 1, true) ~= nil @@ -55,9 +70,10 @@ end -- Add qty of an item; returns false (and adds nothing) when a new slot -- is needed and the bag is full, or when the stack would pass 99 -- (AddItemToInventory's per-slot quantity cap). -function Bag.add(save, id, qty) +function Bag.add(save, id, qty, data) local inv = save.inventory - if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then + if not inv[id] and not isBadge(id) + and Bag.slots(save) >= Bag.capacity(data) then return false end if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 61237842..93ed8869 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -210,11 +210,12 @@ end -- text (label or literal; {RAM:wStringBuffer} becomes the item name); -- pass false when the script shows its own received-text row. function Commands.give_item(ctx, itemId, count, gotText) - -- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt + -- the bag can refuse at its configured capacity (20 in vanilla): halt -- the script, so later set_flag rows don't burn the gift -- make -- room and talk again, like the original (pokered's `jr nc, .bag_full` -- skips the received text entirely when AddItemToInventory refuses) - if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then + if not require("src.inventory.Bag").add( + ctx.save, itemId, count or 1, ctx.game.data) then Commands.show_text(ctx, ctx.game.data.text and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) return math.huge diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index a2e3944d..eb07c939 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -76,7 +76,7 @@ local function withdraw(game) onChoose = function(item, list) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") - if not Bag.add(game.save, item.value, qty) then + if not Bag.add(game.save, item.value, qty, game.data) then list.footer = Strings("You can't carry\nany more items.") return end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 9d24a8f0..e51eeb17 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -67,7 +67,7 @@ local function buy(game, stock) list.footer = notEnough return end - if not Bag.add(game.save, item.value, qty) then + if not Bag.add(game.save, item.value, qty, game.data) then list.footer = txt(game, "_PokemartItemBagFullText", Strings("You can't carry\nany more items.")) return diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index c67683f7..81b76b00 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1803,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy) if h.x == fx and h.y == fy then save.hiddenTaken = save.hiddenTaken or {} if save.hiddenTaken[key] then return false end - if not require("src.inventory.Bag").add(save, h.item, 1) then + if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) return true end @@ -2423,7 +2423,7 @@ function OverworldState:talkTo(npc) -- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats -- the string "0" as truthy, so screen it out and fall through to text. if d.item and d.item ~= "0" and d.item ~= 0 then - if not require("src.inventory.Bag").add(Game.save, d.item, 1) then + if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) return end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 7e1659e8..4cad673d 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -2414,14 +2414,15 @@ local function sellItem(id) return sold end --- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20 --- slots; a stack of an item already held costs nothing). This is why +-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a +-- capacity mod; a stack of an item already held costs nothing). This is why -- every restock had been reporting "HYPER_POTION x0": the buy list -- opened, the quantity was set, the engine said "no room" -- and the run -- walked into the Mansion with FULL_HEALs but not one HP restore. local function freeBagSlots(needed, where) local used = #(G.save.bagOrder or {}) - local free = 20 - used + local capacity = require("src.inventory.Bag").capacity(G.data) + local free = capacity - used for _, id in ipairs(SELLABLE_JUNK) do if free >= needed then break end if ((G.save.inventory or {})[id] or 0) > 0 then @@ -2632,7 +2633,8 @@ function ops.shop(s, where) end end local used = #(G.save.bagOrder or {}) - if newKinds > 0 and 20 - used < newKinds then + local capacity = require("src.inventory.Bag").capacity(G.data) + if newKinds > 0 and capacity - used < newKinds then freeBagSlots(newKinds, where) end end diff --git a/tests/modkit/cases/bag_capacity.lua b/tests/modkit/cases/bag_capacity.lua new file mode 100644 index 00000000..88096cce --- /dev/null +++ b/tests/modkit/cases/bag_capacity.lua @@ -0,0 +1,69 @@ +-- T4: constants.bagSize controls the bag through the public mod API while +-- vanilla and existing-save behavior remain unchanged. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Bag = require("src.inventory.Bag") + +local CAPACITY_MOD = { + ["mods/fix_small_bag/manifest.json"] = [[{ + "id": "fix_small_bag", + "name": "Fixture Small Bag", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_small_bag/main.lua"] = [[ + local mod = ... + mod.content.constants:patch("bagSize", 2) + ]], +} + +-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit. +do + local data = T.fixtures.fresh() + local run = T.sdk.loadNone({ data = data }) + T.eq(#run.errors, 0, "the no-mod baseline loads cleanly") + T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots") + T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20") + run.release() +end + +-- The public constants registry changes both the reported and enforced cap. +do + local data = T.fixtures.fresh() + local run = T.sdk.loadMods({ "mods/fix_small_bag" }, + { data = data, fs = T.sdk.memfs(CAPACITY_MOD) }) + T.eq(#run.errors, 0, "the capacity mod loads cleanly") + T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize") + + local save = { inventory = {} } + T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits") + T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits") + T.check(not Bag.add(save, "FIX_TM", 1, data), + "a new item is refused at the modded limit") + T.eq(Bag.slots(save), 2, "a refused add does not change the bag") + run.release() +end + +-- Saves are dictionaries, not fixed arrays: lowering the active cap never +-- truncates an older or modded save. Existing stacks remain usable while a +-- new item waits until the player makes enough room. +do + local data = T.fixtures.fresh() + data.constants.bagSize = 1 + local save = { + inventory = { FIX_POTION = 1, FIX_BALL = 1 }, + bagOrder = { "FIX_POTION", "FIX_BALL" }, + } + T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots") + T.check(Bag.add(save, "FIX_POTION", 1, data), + "an over-cap save can still add to an existing stack") + T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated") + T.check(not Bag.add(save, "FIX_TM", 1, data), + "an over-cap save cannot add another item kind") + T.eq(Bag.slots(save), 2, "the compatibility path never drops items") +end + +T.finish("bag_capacity") diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua index b84f9749..34d9a4de 100644 --- a/tests/save_editor_task6_tests.lua +++ b/tests/save_editor_task6_tests.lua @@ -181,12 +181,13 @@ end do -- the bag has a hard slot cap; the picker must refuse past it local S = newState() + local capacity = Bag.capacity(S.data) local added = 0 for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end - if added >= Bag.CAPACITY then break end + if added >= capacity then break end end - eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap") + eq(Bag.slots(S.save), capacity, "the bag filled to its cap") S.dirty = false local spare for _, id in ipairs(S.cat.items) do diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 018d108a..0faf902f 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -427,7 +427,7 @@ local function tabCount(id) return tostring(n) elseif id == "items" then local Bag = require("src.inventory.Bag") - return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY) + return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data)) elseif id == "events" then local n = 0 for _ in pairs(S.save.flags or {}) do n = n + 1 end diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 0a883a22..e6212e29 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -8,7 +8,8 @@ -- -- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6 -- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999, --- item stack 99 and 20 bag slots (src/inventory/Bag). +-- item stack 99 and the configured bag capacity (20 by default; +-- src/inventory/Bag). local Pokemon = require("src.pokemon.Pokemon") local PartyMod = require("src.pokemon.Party") @@ -338,11 +339,13 @@ end function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end - if Bag.add(S.save, id, 1) then + local capacity = Bag.capacity(S.data) + if Bag.add(S.save, id, 1, S.data) then return Ops.mark(S, ("Added %s to the bag (%d/%d slots)") - :format(id, Bag.slots(S.save), Bag.CAPACITY)) + :format(id, Bag.slots(S.save), capacity)) end - return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY)) + return Ops.say(S, ("Bag is full (%d/%d slots)") + :format(Bag.slots(S.save), capacity)) end function Ops.bagAdjust(S, id, delta) @@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta) if have >= Ops.STACK_MAX then return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) end - Bag.add(S.save, id, delta) + Bag.add(S.save, id, delta, S.data) else Bag.remove(S.save, id, -delta) if not S.save.inventory[id] then diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index f66291ab..ae1f9f68 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -1,4 +1,4 @@ --- Items panel: money, the shared item picker, badges, the 20-slot bag +-- Items panel: money, the shared item picker, badges, the configurable bag -- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain -- S.save.pcItems dict with no slot cap). -- @@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h) -- --------------------------------------------------------------- bag local order = Bag.order(S.save) + local capacity = Bag.capacity(S.data) Kit.card(bagX, y, listW, h) Kit.caption(bagX + pad, y + pad, "BAG") - Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY), + Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity), bagX + listW - pad, y + pad, PAL.caption) local barY = y + pad + Kit.textHeight("caption") + 8 * s - local slotFrac = Bag.slots(S.save) / Bag.CAPACITY + local slotFrac = Bag.slots(S.save) / capacity Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100, slotFrac >= 1 and PAL.yellow or PAL.blue)