This commit is contained in:
bryanthaboi
2026-08-16 06:41:58 -04:00
34 changed files with 1800 additions and 138 deletions
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(source:find("DisplayManager.DisplayListener", 1, true)
and source:find("registerDisplayListener", 1, true)
and source:find("unregisterDisplayListener", 1, true),
"secondary displays are monitored while the activity is active")
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
position("setupSecondaryDisplay();"),
"secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
+81
View File
@@ -0,0 +1,81 @@
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
--
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
-- and a response status, so the body is staged in a temporary file and curl
-- is opened read-only for its response.
-- luajit tests/engine/host_shell_postlog.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__"
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n"
local realOpen = io.open
local realPopen = io.popen
local realGetenv = os.getenv
local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl
local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end
os.getenv = function(name)
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
return STAGE_DIR
end
return realGetenv(name)
end
os.remove = function(path)
removedPath = path
return true
end
io.open = function(path, mode)
openedPath, openedMode = path, mode
return {
write = function(_, value)
writtenBody = value
return true
end,
close = function() return true end,
}
end
io.popen = function(command, mode)
popenCommand, popenMode = command, mode
return {
read = function() return MARK .. "200" end,
close = function() return true end,
}
end
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
io.open = realOpen
io.popen = realPopen
os.getenv = realGetenv
os.remove = realRemove
HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary")
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
"curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line")
eq(removedPath, openedPath, "the staged request body is removed")
T.finish("host shell postlog")
+246
View File
@@ -0,0 +1,246 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local S = require("tests.harness").suite("mod battle snapshot")
local check, eq = S.check, S.eq
local Gen1BattleState = require("src.battle.BattleState")
check(Gen1BattleState.isBattleState == true,
"Gen 1 battle states carry the discovery marker")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load({ type_chart = {
types = { NORMAL = { name = "NORMAL", category = "physical" } },
matchups = {},
} })
local Damage = require("src.battle.Damage")
local attacker, defender = { stages = {} }, { stages = {} }
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
attacker, defender), 256, "clean accuracy exposes a certain hit")
local Catching = require("src.battle.Catching")
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }), 100, "Master Ball preview is certain")
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
"custom ball logic does not receive a guessed preview")
local mon = { species = "TESTMON", level = 5, hp = 18,
stats = { hp = 20 }, moves = {} }
local game = {
data = {
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
accuracy = 95, pp = 35 } },
items = { POTION = { name = "POTION" },
POKE_BALL = { name = "POKE BALL" } },
},
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
stack = { states = {} },
}
local battle = {
isBattleState = true, phase = "menu", queue = {},
ruleset = { oneIn256Miss = true },
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
curMoves = { { id = "TACKLE", pp = 35 } } },
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
}
function battle:battleKind() return "wild" end
function battle:effectRecord() return { accuracyChecked = true } end
function battle:visibleText() return { "Wild TESTMON appeared!" } end
function battle:menuLockedAction() return nil end
function battle:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moveSelect" end
return true
end
function battle:chooseMove(slot)
self.chosenMove = slot
self.phase = "messages"
return true
end
function battle:cancelMove() self.phase = "menu" return true end
function battle:catchChance(ball)
return require("src.battle.Catching").chance(ball, self.enemy.mon,
game.data.pokemon[self.enemy.mon.species])
end
game.stack.states = { battle }
local api = require("src.battle.BattleAPI").new(game)
local snapshot = api:snapshot()
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
"Gen 1 battle is exposed")
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
eq(#snapshot.items, 2, "medicine and balls are exposed")
check(type(snapshot.items[1].catchChance) == "number"
or type(snapshot.items[2].catchChance) == "number",
"stock catch chance is available")
snapshot.player.hp = 0
snapshot.moves[1].pp = 0
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
eq(battle.player.curMoves[1].pp, 35,
"changing a snapshot cannot change a move")
local same = api:snapshot()
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
battle.enemy.mon.hp = 5
check(api:snapshot().revision > same.revision,
"observable battle changes advance the revision")
game.stack.states = {}
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
game.stack.states = { battle }
local menu = api:snapshot()
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gen 1 rejects a stale intent")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gen 1 rejects an unknown menu choice")
check(api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" })
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
local moveMenu = api:snapshot()
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gen 1 rejects an invalid move slot")
check(api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
battle.phase = "moveSelect"
local back = api:snapshot()
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
"Gen 1 accepts move-menu back")
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
moves = {} }
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
wild = true, turn = 0 }
function battle2:moveDisabled() return false end
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
phase = "menu", menuIndex = 1, moveIndex = 1 }
function screen2:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moves" end
return true
end
function screen2:chooseMove(slot)
self.chosenMove = slot
self.phase = "resolving"
return true
end
function screen2:cancelMove() self.phase = "menu" return true end
local game2 = {
data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } },
},
save = { party = { player2 } }, stack = { states = { screen2 } },
}
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
local snapshot2 = api2:snapshot()
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
"Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
snapshot2.player.hp = 0
snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
eq(player2.moves[1].pp, 35,
"changing a snapshot cannot change a Gold move")
screen2.message = "A wild RATTATA appeared!"
screen2.phase = "resolving"
local message2 = api2:snapshot()
eq(message2.prompt, "advance", "Gold message state is exposed")
check(message2.revision > snapshot2.revision,
"Gold battle changes advance the revision")
game2.stack.states = {}
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
game2.stack.states = { screen2 }
screen2.message = nil
screen2.phase = "menu"
local menu2 = api2:snapshot()
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gold rejects a stale intent")
ok, err = api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gold rejects an unknown menu choice")
check(api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
local moveMenu2 = api2:snapshot()
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gold rejects an invalid move slot")
check(api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 1 }), "Gold accepts a valid move")
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
screen2.phase = "moves"
local back2 = api2:snapshot()
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
"Gold accepts move-menu back")
eq(screen2.phase, "menu", "Gold back restores the command menu")
do
local Data = require("tests.modkit").fixtures.fresh()
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local pressed = {}
local game3 = { data = Data, save = save, input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function() return false end,
}, stack = { states = {} } }
function game3.stack:top() return self.states[#self.states] end
function game3.stack:push(state) self.states[#self.states + 1] = state end
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
real.phase, real.queue, real.introSlide = "menu", {}, nil
game3.stack.states = { real }
pressed.a = true
real:update(1 / 60)
pressed.a = nil
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
pressed.b = true
real:update(1 / 60)
pressed.b = nil
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
end
local Loader = require("src.mods.Loader")
local fs = { read = function() end, getInfo = function() end,
getDirectoryItems = function() return {} end }
local mod = { path = "mods/snapshot_test", manifest = {
id = "snapshot_test", version = "1.0.0", permissionSet = {},
} }
local loader1 = Loader.new({ fs = fs, generation = 1 })
loader1.game = game
check(loader1:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 1 facade")
local loader2 = Loader.new({ fs = fs, generation = 2 })
loader2.game = game2
check(loader2:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 2 facade")
S.finish()
+45
View File
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
version = "1.0.0",
entry = "main.lua",
}, "mods/colorlib")
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
nil, nil, { testTargetManifest, installedColorlib })
check(unconditionalConflict.hasIssues == true,
"dependency resolver reports an unversioned conflict")
local function rangeTarget(version, conflicts)
return Manifest.validate({
id = "range_target",
name = "Range Target",
version = version,
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_target")
end
local function rangeSource(conflicts)
return Manifest.validate({
id = "range_source",
name = "Range Source",
version = "1.0.0",
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_source")
end
local forwardSource = rangeSource({ "range_target@<2.0.0" })
local matchingTarget = rangeTarget("1.4.0")
local nonmatchingTarget = rangeTarget("2.0.0")
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, matchingTarget })
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
"dependency resolver applies a matching forward conflict range")
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, nonmatchingTarget })
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
"dependency resolver ignores a nonmatching forward conflict range")
local reverseSource = rangeSource({ "range_target@<2.0.0" })
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
nil, nil, { reverseSource, matchingTarget })
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
"dependency resolver applies a matching reverse conflict range")
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
nil, nil, { reverseSource, nonmatchingTarget })
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
"dependency resolver ignores a nonmatching reverse conflict range")
-- ------- scoped dependency tests
local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({
+21
View File
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
and loader.modOptions.okmod.startMoney == 3000
and loader.modOptions.okmod.tag == "BLUE",
"RESET DEFAULTS restores every schema default")
-- optional conditions keep mode-specific rows compact and refresh in place
local conditionalSchema = {
{ key = "mode", label = "MODE", type = "choice", default = "one",
choices = { { "ONE", "one" }, { "TWO", "two" } } },
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "one" } },
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "two" } },
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
visible_if = { key = "mode", not_equals = "one" } },
}
loader.modOptions.condmod = {}
ms.cursor = 1
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
"visible_if uses the controlling row default")
ms.optionRows[1].step(mgame, 1)
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
"editing a controller refreshes conditions without moving the cursor")
press(ms, "b")
check(ms.screen == "list", "B leaves the options screen")
+169
View File
@@ -0,0 +1,169 @@
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
-- The things this pins are the strict ones -- https-only destination that
-- lives in the manifest (not per-call), a closed list of format switches,
-- a body ceiling, opaque per-mod handles, and refusal without the network
-- permission.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Net = require("src.mods.Net")
local Fetch = require("src.net.Fetch")
-- Stand in for the worker pool: jobs resolve when the test says so, so no
-- test here touches a socket.
local submitted, nextId, states = {}, 0, {}
Fetch.post = function(url, body, opts)
nextId = nextId + 1
submitted[nextId] = { url = url, body = body, opts = opts }
states[nextId] = { status = "pending", progress = 0 }
return nextId
end
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
Fetch.release = function(id) states[id] = nil end
Fetch.cancel = function(id)
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
end
Fetch.available = function() return true end
local LOGGER = {
["mods/log_sender/manifest.json"] = [[{
"id": "log_sender",
"name": "Log Sender",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["network"],
"log_url": "https://logs.example.com/logs"
}]],
["mods/log_sender/main.lua"] = [[
local mod = ...
mod.exports.send = function(body, opts)
local handle, err = mod:postLog(body, opts)
if not handle then return nil, err end
return handle
end
mod.exports.poll = function(h) return mod.fetch:poll(h) end
mod.exports.release = function(h) return mod.fetch:release(h) end
]],
}
local function manifest(id, extra)
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
.. '"api": 2%s}'):format(id, extra or "")
end
local NO_URL = {
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
["mods/log_no_url/main.lua"] = [[
local mod = ...
mod.exports.try = function()
local ok, err = pcall(function() return mod:postLog("body") end)
return ok, err
end
]],
}
-- ------------------------------------------------ the closed opts list
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
local api = run.loader.exports.log_sender
local bad, badErr = api.send("body", { format = "xml" })
T.eq(bad, nil, "an unknown format is refused")
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
local badKey, keyErr = api.send("body", { envelope = true })
T.eq(badKey, nil, "an unknown opt key is refused")
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
-- ------------------------------------------------ body validation
local empty, emptyErr = api.send("")
T.eq(empty, nil, "an empty body is refused")
local big = string.rep("x", Net.MAX_BODY + 1)
local bigH, bigErr = api.send(big)
T.eq(bigH, nil, "an oversized body is refused")
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
-- ------------------------------------------------- text format (default)
local handle, err = api.send("hello log")
T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")")
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending")
local sent
for _, job in pairs(submitted) do
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
end
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
T.check(sent.opts.userAgent:find("log_sender", 1, true),
"the request identifies the calling mod")
-- -------------------------------------------------- json format
local jh, jerr = api.send("line one", { format = "json" })
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
local jsent
for _, job in pairs(submitted) do
if job.opts.contentType == "application/json" then jsent = job end
end
T.check(jsent ~= nil, "json posts as application/json")
local decoded = require("src.link.Json").decode(jsent.body)
T.eq(type(decoded), "table", "the json body is a table")
T.eq(decoded.format, "json", "the envelope names its format")
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
T.eq(decoded.body, "line one", "the payload survives the envelope")
-- completion flows through poll, like get
states[1] = { status = "ok", progress = 1 }
local got = api.poll(handle)
T.eq(got.status, "ok", "a completed post polls ok")
api.release(handle)
run.release()
-- --------------------------------------- manifest without log_url refuses
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
local okCall, callErr = nurl.loader.exports.log_no_url.try()
T.check(not okCall and callErr:find("log_url", 1, true),
"postLog without log_url names the missing manifest field")
nurl.release()
-- ------------------------------------------- manifest validation: the gate
-- log_url without the network permission is a load violation in a strict
-- manifest: the mod declares a network capability it did not opt in to. The
-- violation fires inside manifest validation, so the mod never enters
-- loader.mods at all.
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
["mods/log_bad/manifest.json"] = manifest("log_bad",
', "log_url": "https://logs.example.com/logs"'),
["mods/log_bad/main.lua"] = "local mod = ...",
}) })
T.eq(badManifest.mods.log_bad, nil,
"log_url without network: the mod is refused before load")
-- a non-https log_url is refused even with the permission
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
["mods/log_http/manifest.json"] = manifest("log_http",
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
["mods/log_http/main.lua"] = "local mod = ...",
}) })
T.eq(httpManifest.mods.log_http, nil,
"an http log_url: the mod is refused before load")
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
-- mod loads (its postLog call still refuses -- there is no log_url to use)
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
["mods/log_api1/manifest.json"] = [[{
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
"api": 1, "log_url": "https://logs.example.com/logs"
}]],
["mods/log_api1/main.lua"] = "local mod = ...",
}) })
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
.. tostring(api1.errors[1]) .. ")")
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
T.finish("mod_postlog")
+14 -1
View File
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
T.eq(reason, "no overworld", "Red reports a missing overworld")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function"
@@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable",
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
actions, err = red:availableFieldActions()
T.eq(#actions, 0, "Red hides actions while moving")
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
@@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return {
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
T.eq(reason, "no overworld", "Gold reports a missing overworld")
actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
@@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"),
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
goldWorld.acceptsMenuInput = function() return false end
actions, err = gold:availableFieldActions()
T.eq(#actions, 0, "Gold hides actions while busy")
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
T.finish()