-- Dev console overlay (POKEPORT_DEV=1, backtick): a Lua REPL with the live -- game, data and mod list in scope, built-in verbs (warp / give / flag / -- party / mods / reload) and an event/hook tracer driven off the Runtime -- buses. It rides the state stack, so while it is open it owns the -- keyboard (Game:keypressed routes to onKeyPressed) and the world below -- does not update. Never required on a player boot. local Font = require("src.render.Font") local Console = {} Console.__index = Console local ROWS = 15 -- scrollback rows drawn above the input line local COLS = 19 -- 160px canvas minus the border local HISTORY_MAX = 64 local SCROLLBACK_MAX = 200 -- keypressed names -> characters; the console types from key events because -- love.textinput never reaches Game. Shift reads the live keyboard. local KEY_CHARS = { space = " ", ["1"] = "1!", ["2"] = "2@", ["3"] = "3#", ["4"] = "4$", ["5"] = "5%", ["6"] = "6^", ["7"] = "7&", ["8"] = "8*", ["9"] = "9(", ["0"] = "0)", ["-"] = "-_", ["="] = "=+", ["["] = "[{", ["]"] = "]}", ["\\"] = "\\|", [";"] = ";:", ["'"] = "'\"", [","] = ",<", ["."] = ".>", ["/"] = "/?", } local function shiftDown() return love and love.keyboard and love.keyboard.isDown and (love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")) end -- one-line pretty printer with a depth fuse, for expression results local function pp(value, depth) depth = depth or 0 local kind = type(value) if kind == "string" then return string.format("%q", value) end if kind ~= "table" then return tostring(value) end if depth >= 2 then return "{...}" end local parts, n = {}, 0 for k, v in pairs(value) do n = n + 1 if n > 8 then parts[#parts + 1] = "..." break end parts[#parts + 1] = tostring(k) .. "=" .. pp(v, depth + 1) end return "{" .. table.concat(parts, ", ") .. "}" end function Console.new(game) local self = setmetatable({ game = game, buffer = "", lines = {}, history = {}, historyIndex = 0, scroll = 0, }, Console) self.env = setmetatable({ game = game, data = game.data, mods = game.mods, pp = pp, }, { __index = _G }) self:print("dev console -- `help` for verbs, ` to close") return self end function Console:print(text) for line in (tostring(text) .. "\n"):gmatch("([^\n]*)\n") do -- wrap to the canvas width so long payload dumps stay readable repeat self.lines[#self.lines + 1] = line:sub(1, COLS) line = line:sub(COLS + 1) until line == "" end while #self.lines > SCROLLBACK_MAX do table.remove(self.lines, 1) end self.scroll = 0 end -- ------- tracer -- glob -> anchored lua pattern ("battle.*" matches battle.turn etc.) local function globPattern(glob) local escaped = glob:gsub("[%^%$%(%)%%%.%[%]%+%-%?]", "%%%0") return "^" .. escaped:gsub("%*", ".*") .. "$" end -- The buses have no name catalog to subscribe against, so the tracer shims -- the live instances: an instance field shadows the class method and -- removing it restores the original. Runtime.wants is widened too, or -- guarded call sites would skip payload construction for unwatched names. function Console:startTrace(glob) self:stopTrace() local Runtime = require("src.mods.Runtime") local loader = self.game.mods local events = loader and loader.events local hooks = loader and loader.hooks if not (events and hooks) then self:print("trace: no loader buses") return end local pattern = globPattern(glob) local function matches(name) return type(name) == "string" and name:match(pattern) ~= nil end local console = self local trace = { glob = glob, events = events, hooks = hooks, emit = events.emit, call = hooks.call, wants = Runtime.wants, wantsHook = Runtime.wantsHook, } events.emit = function(bus, name, payload) if matches(name) then console:print("[event] " .. name .. " " .. pp(payload)) end return trace.emit(bus, name, payload) end hooks.call = function(bus, name, vanilla, ...) if not matches(name) then return trace.call(bus, name, vanilla, ...) end console:print("[hook] " .. name .. " in " .. pp({ ... })) local result = { trace.call(bus, name, vanilla, ...) } console:print("[hook] " .. name .. " out " .. pp(result)) local unpack_ = table.unpack or unpack return unpack_(result) end Runtime.wants = function(name) return matches(name) or trace.wants(name) end Runtime.wantsHook = function(name) return matches(name) or trace.wantsHook(name) end self.trace = trace self:print(("tracing %s"):format(glob)) end function Console:stopTrace() local trace = self.trace if not trace then return end local Runtime = require("src.mods.Runtime") -- clearing the instance fields re-exposes the class methods trace.events.emit = nil trace.hooks.call = nil Runtime.wants = trace.wants Runtime.wantsHook = trace.wantsHook self.trace = nil end -- ------- verbs local VERBS = {} function VERBS.help(self) self:print("warp MAP [x y]") self:print("give ID [n|level]") self:print("flag NAME [on|off]") self:print("party mods reload") self:print("trace PAT | trace off") self:print("anything else = lua") end function VERBS.mods(self) local status = self.game.modStatus or (self.game.mods and self.game.mods:status()) if not status then self:print("no loader") return end for _, mod in ipairs(status.available) do self:print(("%s %s %s"):format(mod.id, mod.version or "?", mod.state)) end self:print(("%d errors"):format(#status.errors)) end function VERBS.reload(self) self:stopTrace() local _, summary = require("src.dev.HotReload").run(self.game) -- the reload swapped the buses and the env's loader reference with them self.env.mods = self.game.mods self.env.data = self.game.data self:print(summary) end function VERBS.warp(self, rest) local mapId, x, y = rest:match("^(%S+)%s*(%d*)%s*(%d*)") local game = self.game if not mapId or not (game.data.maps and game.data.maps[mapId]) then self:print("unknown map: " .. tostring(mapId)) return end x, y = tonumber(x) or 5, tonumber(y) or 5 -- the driver kit's teleport rebuild: everything (this console included) -- pops and a fresh overworld enters at the target while game.stack:top() do game.stack:pop() end game.stack:push(require("src.world.OverworldController"), mapId, x, y, "down") end function VERBS.give(self, rest) local id, count = rest:match("^(%S+)%s*(%d*)") local game = self.game local save = game.save if not id or id == "" then self:print("give what?") return end if game.data.pokemon and game.data.pokemon[id] then local level = tonumber(count) or 5 local mon = require("src.pokemon.Pokemon").new(game.data, id, level) if require("src.pokemon.Party").add(save.party, mon) then self:print(("%s L%d joined the party"):format(id, level)) elseif require("src.pokemon.Boxes").deposit(save, mon) then self:print(("%s L%d sent to the PC"):format(id, level)) else self:print("party and boxes full") 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, game.data) then self:print(("%s x%d added"):format(id, n)) else self:print("bag full") end else self:print("unknown id: " .. id) end end function VERBS.flag(self, rest) local name, value = rest:match("^(%S+)%s*(%S*)") local flags = self.game.save and self.game.save.flags if not name or not flags then self:print("flag what?") return end if value == "on" then flags[name] = true elseif value == "off" then flags[name] = nil end self:print(("%s = %s"):format(name, tostring(flags[name] or false))) end function VERBS.party(self) local party = self.game.save and self.game.save.party or {} if #party == 0 then self:print("(empty)") return end for i, mon in ipairs(party) do self:print(("%d %s L%d %d/%d"):format(i, tostring(mon.species), mon.level or 0, mon.hp or 0, (mon.stats and mon.stats.hp) or 0)) end end function VERBS.trace(self, rest) local glob = rest:match("^(%S+)") if not glob or glob == "off" then self:stopTrace() if glob then self:print("trace off") end return end self:startTrace(glob) end -- ------- repl function Console:exec(line) self:print("> " .. line) if line:match("^%s*$") then return end self.history[#self.history + 1] = line while #self.history > HISTORY_MAX do table.remove(self.history, 1) end self.historyIndex = #self.history + 1 local verb, rest = line:match("^(%S+)%s*(.*)$") local handler = VERBS[verb] if handler then local ok, err = pcall(handler, self, rest or "") if not ok then self:print("error: " .. tostring(err)) end return end -- expression first so `1+1` prints 2; statements fall through local chunk, err = loadstring("return " .. line, "=console") if not chunk then chunk, err = loadstring(line, "=console") end if not chunk then self:print("error: " .. tostring(err)) return end setfenv(chunk, self.env) local results = { pcall(chunk) } if not results[1] then self:print("error: " .. tostring(results[2])) return end if #results == 1 then return end for i = 2, #results do self:print(pp(results[i])) end end -- tab completion against the env (and one dotted level into it) function Console:complete() local prefix, partial = self.buffer:match("^(.-)([%w_%.]*)$") local holder, field = partial:match("^([%w_]+)%.([%w_]*)$") local scope, stem if holder then local ok, value = pcall(function() return self.env[holder] end) if not ok or type(value) ~= "table" then return end scope, stem = value, field prefix = prefix .. holder .. "." else scope, stem = self.env, partial end local matches = {} local seen = scope while type(seen) == "table" do for key in pairs(seen) do if type(key) == "string" and key:sub(1, #stem) == stem then matches[#matches + 1] = key end end local meta = getmetatable(seen) seen = meta and meta.__index if type(seen) ~= "table" then break end end table.sort(matches) if #matches == 1 then self.buffer = prefix .. matches[1] elseif #matches > 1 then self:print(table.concat(matches, " ", 1, math.min(#matches, 12))) end end -- ------- input & drawing function Console:onKeyPressed(key) if key == "`" then self:stopTrace() self.game.stack:pop() elseif key == "return" or key == "kpenter" then local line = self.buffer self.buffer = "" self:exec(line) elseif key == "backspace" then self.buffer = self.buffer:sub(1, -2) elseif key == "tab" then self:complete() elseif key == "up" then if self.historyIndex > 1 then self.historyIndex = self.historyIndex - 1 self.buffer = self.history[self.historyIndex] or "" end elseif key == "down" then if self.historyIndex <= #self.history then self.historyIndex = self.historyIndex + 1 self.buffer = self.history[self.historyIndex] or "" end elseif key == "pageup" then self.scroll = math.min(self.scroll + ROWS, math.max(0, #self.lines - ROWS)) elseif key == "pagedown" then self.scroll = math.max(0, self.scroll - ROWS) else local chars = KEY_CHARS[key] if chars then local index = shiftDown() and 2 or 1 self.buffer = self.buffer .. chars:sub(index, index) elseif key:match("^%a$") then self.buffer = self.buffer .. (shiftDown() and key:upper() or key) elseif key:match("^kp%d$") then self.buffer = self.buffer .. key:sub(3) end end end function Console:update() end function Console:exit() self:stopTrace() end function Console:draw() love.graphics.setColor(1, 1, 1, 0.92) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(0, 0, 0, 1) local first = math.max(1, #self.lines - ROWS + 1 - self.scroll) local row = 0 for i = first, math.min(#self.lines, first + ROWS - 1) do Font.draw(self.lines[i], 4, 2 + row * 9) row = row + 1 end local input = "> " .. self.buffer -- keep the tail visible while typing past the canvas edge if #input > COLS then input = input:sub(#input - COLS + 1) end Font.draw(input, 4, 134) love.graphics.setColor(1, 1, 1, 1) end return Console