Honor modded bag capacity

This commit is contained in:
erereck
2026-08-01 00:32:00 -03:00
parent 9bcfdb1f0d
commit 6e724cedf7
17 changed files with 136 additions and 38 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here | | # | 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`) | | 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` | | 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` | | 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 ## 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 - 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 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. or blocks the deposit," this port spills into the next box with room.
+2 -1
View File
@@ -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 party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there. clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that - **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. with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object - **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid. toggles, with a real filter field and a two-column paged grid.
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST" caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas()) mod.save:set("caught_areas", caughtAreas())
end 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!" self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!") or "You already have\nthis POKéMON family!")
return return
+5 -5
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches. -- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" } local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry -- Vanilla defaults for rules exposed through the constants registry. A
-- deep-merges over these, so a value has to exist before a mod can patch -- value has to exist before a mod can patch it; each one matches the
-- it; each one is the number the engine hard-codes today, so seeding them -- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- changes nothing on a mod-free boot. -- boot.
local CONSTANT_DEFAULTS = { 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) partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua) boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4, moveMax = 4,
+1 -1
View File
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i) table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table" 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 = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1) save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end end
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end end
elseif game.data.items and game.data.items[id] then elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1 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)) self:print(("%s x%d added"):format(id, n))
else else
self:print("bag full") self:print("bag full")
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm): -- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- a distinct item id occupies one slot regardless of quantity; badges -- constants/menu_constants.asm), but mods may replace that limit through
-- live in the inventory table but are not bag items. save.bagOrder -- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- keeps acquisition order like wBagItems (SELECT can reorder it). -- 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 = {} 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) local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil 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 -- 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 -- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap). -- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty) function Bag.add(save, id, qty, data)
local inv = save.inventory 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 return false
end end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+3 -2
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name); -- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row. -- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText) 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 -- 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` -- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses) -- 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 Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge return math.huge
+1 -1
View File
@@ -76,7 +76,7 @@ local function withdraw(game)
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") 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.") list.footer = Strings("You can't carry\nany more items.")
return return
end end
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough list.footer = notEnough
return return
end 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", list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items.")) Strings("You can't carry\nany more items."))
return return
+2 -2
View File
@@ -1803,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {} save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end 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!"))) Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true return true
end end
@@ -2423,7 +2423,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats -- (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. -- 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 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!"))) Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return return
end end
+6 -4
View File
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold return sold
end end
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20 -- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
-- slots; a stack of an item already held costs nothing). This is why -- 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 -- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run -- 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. -- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where) local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {}) 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 for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end end
end end
local used = #(G.save.bagOrder or {}) 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) freeBagSlots(newKinds, where)
end end
end end
+69
View File
@@ -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")
+3 -2
View File
@@ -181,12 +181,13 @@ end
do do
-- the bag has a hard slot cap; the picker must refuse past it -- the bag has a hard slot cap; the picker must refuse past it
local S = newState() local S = newState()
local capacity = Bag.capacity(S.data)
local added = 0 local added = 0
for _, id in ipairs(S.cat.items) do for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end 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 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 S.dirty = false
local spare local spare
for _, id in ipairs(S.cat.items) do for _, id in ipairs(S.cat.items) do
+1 -1
View File
@@ -427,7 +427,7 @@ local function tabCount(id)
return tostring(n) return tostring(n)
elseif id == "items" then elseif id == "items" then
local Bag = require("src.inventory.Bag") 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 elseif id == "events" then
local n = 0 local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end for _ in pairs(S.save.flags or {}) do n = n + 1 end
+8 -5
View File
@@ -8,7 +8,8 @@
-- --
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6 -- 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, -- (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 Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party") local PartyMod = require("src.pokemon.Party")
@@ -338,11 +339,13 @@ end
function Ops.addToBag(S, id) function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end 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)") 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 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 end
function Ops.bagAdjust(S, id, delta) function Ops.bagAdjust(S, id, delta)
@@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta)
if have >= Ops.STACK_MAX then if have >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end end
Bag.add(S.save, id, delta) Bag.add(S.save, id, delta, S.data)
else else
Bag.remove(S.save, id, -delta) Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then if not S.save.inventory[id] then
+4 -3
View File
@@ -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 -- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap). -- S.save.pcItems dict with no slot cap).
-- --
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------------------- bag -- --------------------------------------------------------------- bag
local order = Bag.order(S.save) local order = Bag.order(S.save)
local capacity = Bag.capacity(S.data)
Kit.card(bagX, y, listW, h) Kit.card(bagX, y, listW, h)
Kit.caption(bagX + pad, y + pad, "BAG") 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) bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s 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, Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue) slotFrac >= 1 and PAL.yellow or PAL.blue)