Files
gen1recomp/src/box/Organizer.lua
T
2026-10-06 11:00:16 -04:00

334 lines
15 KiB
Lua

local Store = require("src.box.Store")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local Organizer = {}
Organizer.SORTS = { "species", "name", "level", "type", "nature", "ability", "trainer", "shiny", "egg", "friendship" }
Organizer.LABELS = { species = "Pokédex number", name = "Nickname", level = "Level", type = "Type",
nature = "Nature", ability = "Ability", trainer = "Trainer", shiny = "Shiny", egg = "Egg", friendship = "Friendship" }
Organizer.FIELDS = { "shiny", "egg", "species", "type", "levelMin", "levelMax", "nature", "ability",
"gender", "trainer", "game", "mark", "tag", "name", "move", "item" }
Organizer.FIELD_LABELS = { shiny = "Shiny", egg = "Egg", species = "Species", type = "Type",
levelMin = "Level at least", levelMax = "Level at most", nature = "Nature", ability = "Ability",
gender = "Gender", trainer = "Trainer", game = "Game", mark = "Marked", tag = "Tag contains",
name = "Nickname", move = "Move", item = "Held item" }
function Organizer.available(field, version)
if not version then return true end
local generation = GameVersion.generation(version)
if field == "tag" then return false end
if generation < 3 and (field == "mark" or field == "nature" or field == "ability") then return false end
if generation == 1 and (field == "gender" or field == "egg" or field == "item") then return false end
return true
end
local function integer(n, lo, hi)
return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi
end
local function contains(list, value)
for _, item in ipairs(list) do if item == value then return true end end
return false
end
local function array(list, max)
if type(list) ~= "table" or #list > max then return false end
for key in pairs(list) do if not integer(key, 1, #list) then return false end end
return true
end
function Organizer.validate(config, count)
count = count or Store.BOXES
if type(config) ~= "table" or (config.mode ~= "sort" and config.mode ~= "rules")
or not contains(Organizer.SORTS, config.sort)
or type(config.descending) ~= "boolean"
or config.box ~= nil and not integer(config.box, 1, count) then
return nil, "Choose a valid sort and box."
end
if config.mode == "sort" then
if config.rules ~= nil then
local ruleConfig = Store.copy(config)
ruleConfig.mode, ruleConfig.box = "rules", nil
return Organizer.validate(ruleConfig)
end
return true
end
if config.box ~= nil or not array(config.rules, 24) or #config.rules == 0
or (config.fallback ~= "keep" and config.fallback ~= "sort") then
return nil, "Add a rule and choose what happens to the rest."
end
for index, rule in ipairs(config.rules) do
if type(rule) ~= "table" or not integer(rule.firstBox, 1, count)
or not integer(rule.lastBox, rule.firstBox, count) then
return nil, "Rule " .. index .. " needs a destination available in this collection."
end
if not array(rule.conditions, 8) or #rule.conditions == 0
or (rule.match ~= "all" and rule.match ~= "any") then
return nil, "Rule " .. index .. " needs a condition."
end
for _, condition in ipairs(rule.conditions) do
if type(condition) ~= "table" or not contains(Organizer.FIELDS, condition.field) then
return nil, "A rule condition is invalid."
end
local field, value = condition.field, condition.value
if field == "shiny" or field == "egg" then
if type(value) ~= "boolean" then return nil, "Choose yes or no for this condition." end
elseif field == "levelMin" or field == "levelMax" then
if not integer(value, 0, 255) then return nil, "Choose a level from 0 to 255." end
elseif type(value) ~= "string" or #value == 0 or #value > 128 or value:find("%c") then
return nil, "Choose a value for this condition."
elseif field == "game" and not GameVersion.VERSIONS[value]
or field == "mark" and not contains({ "circle", "square", "triangle", "heart" }, value) then
return nil, "Choose a valid game or marking."
end
end
end
return true
end
local function normalized(value) return require("src.box.Search").normalize(value) end
local function matches(entry, condition)
local d, field, wanted = entry.display, condition.field, condition.value
if field == "shiny" or field == "egg" then return (d[field] == true) == wanted end
if field == "levelMin" then return tonumber(d.level) ~= nil and tonumber(d.level) >= wanted end
if field == "levelMax" then return tonumber(d.level) ~= nil and tonumber(d.level) <= wanted end
if field == "mark" then
local mask = ({ circle = 1, square = 2, triangle = 4, heart = 8 })[wanted]
local value = tonumber(entry.generation == 3 and entry.mon.markings or entry.markings) or 0
return math.floor(value / mask) % 2 == 1
end
if field == "tag" then return normalized(entry.tags):find(normalized(wanted), 1, true) ~= nil end
if field == "type" then
for item in tostring(d.types or ""):gmatch("[^/]+") do
if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end
end
return false
end
if field == "move" then
for item in tostring(d.moves or ""):gmatch("[^,]+") do
if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end
end
return false
end
local value = field == "game" and entry.version or d[field]
return value ~= nil and normalized(value) == normalized(wanted)
end
local function ruleMatches(entry, rule)
for _, condition in ipairs(rule.conditions) do
local hit = matches(entry, condition)
if rule.match == "any" and hit then return true end
if rule.match == "all" and not hit then return false end
end
return rule.match == "all"
end
local function sortValue(entry, field)
local d = entry.display
if field == "species" then
local national = tonumber(d.national)
return national and national > 0 and national or nil
end
if field == "type" then return d.types and d.types ~= "" and normalized(d.types) or nil end
if field == "shiny" or field == "egg" then return d[field] and 1 or 0 end
if field == "level" or field == "friendship" then return tonumber(d[field]) end
return d[field] and d[field] ~= "" and normalized(d[field]) or nil
end
local function compare(a, b, config)
local av, bv = sortValue(a.entry, config.sort), sortValue(b.entry, config.sort)
if av == nil or bv == nil then
if av ~= bv then return av ~= nil end
elseif av ~= bv then
if config.descending then return av > bv end
return av < bv
end
for _, key in ipairs({ "species", "name", "level" }) do
av, bv = sortValue(a.entry, key), sortValue(b.entry, key)
if av == nil or bv == nil then
if av ~= bv then return av ~= nil end
elseif av ~= bv then return av < bv end
end
return a.box < b.box or a.box == b.box and a.slot < b.slot
end
function Organizer.warehouse(state)
local layout = { count = Store.BOXES, capacity = Store.SLOTS, rows = {}, names = {} }
for b, box in ipairs(state.boxes) do
layout.names[b] = box.name
for slot = 1, Store.SLOTS do
if box.mons[slot] then layout.rows[#layout.rows + 1] = { box = b, slot = slot, entry = box.mons[slot] } end
end
end
return layout
end
function Organizer.game(save, version)
if not GameVersion.VERSIONS[version] then return nil, "Choose a supported game." end
local generation = GameVersion.generation(version)
if not Catalog.get(version).ready then return nil, "Import this game's ROM before organizing its PC." end
local layout = { count = generation == 1 and 12 or 14, capacity = generation == 3 and 30 or 20,
rows = {}, names = {}, compact = generation < 3 }
local boxes = Records.boxes(save, generation)
if type(boxes) ~= "table" then return nil, "This save's PC could not be read." end
for b, box in pairs(boxes) do
if not integer(b, 1, layout.count) or type(box) ~= "table" then
return nil, "This save has a PC box this tool cannot represent."
end
end
for b = 1, layout.count do
local list = Records.list(save, generation, b)
if type(list) ~= "table" then return nil, "A PC box could not be read." end
local seen = {}
for key, mon in pairs(list) do
local slot = tonumber(key)
if not integer(slot, 1, layout.capacity) or type(mon) ~= "table" or seen[slot] then
return nil, "A PC record is invalid or appears twice in one slot."
end
seen[slot] = true
end
local box = boxes[b]
layout.names[b] = generation == 3 and box and box.name or save.boxNames and save.boxNames[b] or "BOX " .. b
for slot = 1, layout.capacity do
local mon = list[slot] or list[tostring(slot)]
if mon then layout.rows[#layout.rows + 1] = { box = b, slot = slot,
entry = { version = version, generation = generation, mon = mon, display = Catalog.describe(version, mon) } } end
end
end
return layout
end
function Organizer.analyze(layout, config)
local valid, why = Organizer.validate(config, layout.count)
if not valid then return nil, why end
local counts, reserved, rest = {}, {}, 0
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
counts[i] = 0
for b = rule.firstBox, rule.lastBox do reserved[b] = true end
end
for _, row in ipairs(layout.rows) do
local found
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
if ruleMatches(row.entry, rule) then counts[i], found = counts[i] + 1, true; break end
end
if not found then rest = rest + 1 end
end
local free = 0
for b = 1, layout.count do if not reserved[b] then free = free + 1 end end
return { ruleCounts = counts, remaining = rest, reserved = reserved, freeBoxes = free, total = #layout.rows }
end
function Organizer.plan(layout, config)
local valid, why = Organizer.validate(config, layout.count)
if not valid then return nil, why end
local boxes, groups, rest, reserved = {}, {}, {}, {}
local report = { total = #layout.rows, moved = 0, ruleCounts = {}, boxes = {}, assignments = {} }
for b = 1, layout.count do
boxes[b] = {}
report.boxes[b] = { name = layout.names[b], before = 0, after = 0, capacity = layout.capacity, moved = 0 }
end
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
groups[i], report.ruleCounts[i] = {}, 0
for b = rule.firstBox, rule.lastBox do reserved[b] = true end
end
for _, row in ipairs(layout.rows) do
report.boxes[row.box].before = report.boxes[row.box].before + 1
local chosen
if config.mode == "rules" then
for i, rule in ipairs(config.rules) do
if ruleMatches(row.entry, rule) then chosen = i; break end
end
end
if chosen then
groups[chosen][#groups[chosen] + 1] = row
report.ruleCounts[chosen] = report.ruleCounts[chosen] + 1
elseif config.mode == "rules" and config.fallback == "keep"
or config.mode == "sort" and config.box and row.box ~= config.box then
boxes[row.box][row.slot] = row
else rest[#rest + 1] = row end
end
local function place(rows, firstBox, lastBox, skipReserved)
table.sort(rows, function(a, b) return compare(a, b, config) end)
local index = 1
for b = firstBox, lastBox do
if not skipReserved or not reserved[b] then
for slot = 1, layout.capacity do
if not boxes[b][slot] and rows[index] then boxes[b][slot], index = rows[index], index + 1 end
end
end
end
return index > #rows
end
if config.mode == "rules" then
for i, rule in ipairs(config.rules) do
if not place(groups[i], rule.firstBox, rule.lastBox) then
return nil, "Rule " .. i .. " needs more room in its destination. Widen its box range."
end
end
end
if not place(rest, config.box or 1, config.box or layout.count, config.mode == "rules") then
return nil, "The remaining Pokémon need more room outside the rule boxes. Keep their boxes or free a destination."
end
for b = 1, layout.count do
if layout.compact and (config.mode == "rules" or not config.box or config.box == b) then
local dense = {}
for slot = 1, layout.capacity do if boxes[b][slot] then dense[#dense + 1] = boxes[b][slot] end end
boxes[b] = dense
end
for slot = 1, layout.capacity do
local row = boxes[b][slot]
if row then
report.boxes[b].after = report.boxes[b].after + 1
local moved = row.box ~= b or row.slot ~= slot
if moved then report.moved, report.boxes[b].moved = report.moved + 1, report.boxes[b].moved + 1 end
report.assignments[#report.assignments + 1] = { fromBox = row.box, fromSlot = row.slot,
box = b, slot = slot, entry = row.entry, moved = moved }
end
end
end
return report, boxes
end
function Organizer.applyWarehouse(state, config)
local report, boxes = Organizer.plan(Organizer.warehouse(state), config)
if not report then return nil, boxes end
local nextState = Store.copy(state)
for b = 1, Store.BOXES do
nextState.boxes[b].mons = {}
for slot, row in pairs(boxes[b]) do nextState.boxes[b].mons[slot] = Store.copy(row.entry) end
end
nextState.revision = state.revision + 1
return nextState, report
end
function Organizer.applyGame(save, version, config)
local layout, why = Organizer.game(save, version)
if not layout then return nil, why end
local valid, err = Organizer.validate(config, layout.count)
if not valid then return nil, err end
if config.mode == "rules" then
for _, rule in ipairs(config.rules) do
for _, condition in ipairs(rule.conditions) do
if not Organizer.available(condition.field, version) then
return nil, GameVersion.info(version).label .. "'s Game PC doesn't record "
.. Organizer.FIELD_LABELS[condition.field] .. ". Choose another condition."
end
end
end
end
local report, boxes = Organizer.plan(layout, config)
if not report then return nil, boxes end
local nextSave, generation = Store.copy(save), GameVersion.generation(version)
if generation == 3 then
nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} }
nextSave.storage.boxes = nextSave.storage.boxes or {}
for b = 1, layout.count do
if config.mode == "rules" or not config.box or config.box == b then
local box = nextSave.storage.boxes[b] or { name = layout.names[b], wallpaper = 0 }
box.mons = {}; nextSave.storage.boxes[b] = box
for slot, row in pairs(boxes[b]) do box.mons[slot] = Store.copy(row.entry.mon) end
end
end
else
nextSave.boxes, nextSave.box = Records.boxes(nextSave, generation), nil
for b = 1, layout.count do
if config.mode == "rules" or not config.box or config.box == b then
nextSave.boxes[b] = {}
for slot, row in pairs(boxes[b]) do nextSave.boxes[b][slot] = Store.copy(row.entry.mon) end
end
end
end
return nextSave, report
end
return Organizer