Files
gen1recomp/tests/game3_link_relay_union_test.lua
2026-09-25 10:28:21 -04:00

901 lines
37 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_items").install()
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local FakeRelay = require("tests.g3link_fake_relay")
local Plaza = require("src.core.game3.link.union_plaza_map")
local UNION_MAP = Plaza.MAP_ID
local CENTER_MAP = "FR_TEST_POKEMON_CENTER_2F"
local MAPS = {
[UNION_MAP] = { warps = { { x = 12, y = 24, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } },
[CENTER_MAP] = { warps = { { x = 9, y = 1, destMap = "FR_TRADE_CENTER", destWarp = 1 } } },
FR_TRADE_CENTER = { warps = { { x = 5, y = 8, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } },
}
local store = { flags = {}, vars = {} }
local session = {
store = store, map = UNION_MAP, x = 12, y = 24, name = "RED", gender = 0, trainerId = 0x1234,
party = { { species = 1, level = 12 }, { species = 4, level = 9 } },
bag = { pockets = { items = {} } },
}
local input = { pressed = {} }
function input:wasPressed(k) return self.pressed[k] == true end
local game = { data = { maps = MAPS }, session = session, input = input,
save = { player = { name = "RED" }, options = {} } }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
_game = game,
}
local Player = { cellX = 12, cellY = 23, facing = "down" }
package.loaded["src.core.game3.player"] = Player
local mapLoads = {}
package.loaded["src.core.game3.map"] = {
load = function(_mod, _game, mapId, opts)
mapLoads[#mapLoads + 1] = { map = mapId, x = opts and opts.x, y = opts and opts.y }
end,
}
local objectCalls = { added = {}, removed = {} }
local liveObjects = {}
package.loaded["src.core.game3.objects"] = {
addObject = function(id)
objectCalls.added[#objectCalls.added + 1] = id
liveObjects[id] = { localId = id, visible = true, raiseY = 0 }
return true
end,
removeObject = function(id)
objectCalls.removed[#objectCalls.removed + 1] = id
liveObjects[id] = nil
return true
end,
find = function(id) return liveObjects[id] end,
refreshGraphics = function() return 0 end,
}
local VirtualObjects = require("src.core.game3.virtual_objects")
local romBundle = require("tests.game3_cache").bundle()
if not romBundle then
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
ir = function(key) return { { t = "text", s = key } } end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
end
local RomText = require("src.core.game3.rom_text")
local ctx = { specialVars = {}, stringVars = {} }
local adapters = { log = function() end, playSe = function() end }
package.loaded["src.core.game3.scripting.space"] = {
store = store, mapId = UNION_MAP, vm = { ctx = ctx, adapters = adapters },
ensureBundle = function() return romBundle end,
}
local Space = package.loaded["src.core.game3.scripting.space"]
local LIVE = { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla",
version = "firered" }
package.loaded["src.online.ArenaData"] = {
liveProfile3 = function(_, rulesetId)
local p = {}
for k, v in pairs(LIVE) do p[k] = v end
p.rulesetId = rulesetId
return p
end,
}
local Client = FakeRelay.client({ state = "online", id = "0000beef" })
Client._profiles = { LIVE }
package.loaded["src.online.Client"] = Client
local mgCalls = {}
package.loaded["src.core.game3.minigames.common"] = {
GROUP_GAME = { [4] = "jump", [5] = "crush", [6] = "pick" },
partySlot = 1,
arm = function(c, spec)
mgCalls[#mgCalls + 1] = { ctx = c, spec = spec }
return true
end,
}
local Natives = require("src.core.game3.scripting.natives")
local NativesLink = require("src.core.game3.scripting.natives_link")
local Link = require("src.core.game3.link")
local Union = require("src.core.game3.link.union_room")
local Screen = require("src.ui.game3.union_room")
local Game3Link = require("src.link.Game3Link")
local Flags = require("src.core.game3.scripting.flags")
local Message = require("src.ui.game3.message")
local Chat = require("src.core.game3.link.chat")
Union._avatars = FakeRelay.avatars()
local Choice = require("src.ui.game3.choice")
local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end
local function tickMsg() for _ = 1, 400 do Message.tick() end end
local function run(n)
for _ = 1, n or 1 do
Message.tick()
Link.update(1 / 60)
end
end
local function settle(n)
for _ = 1, n or 2000 do
if Message.isOpen() and not Message.isWaiting() then Message.skipReveal() end
Message.tick()
Union.update(1 / 60)
if Choice.isOpen() or Screen.isOpen() then return true end
if Message.isOpen() and Message.isWaiting()
and (not Message._stay or (Message._page or 1) < #(Message._pages or {})) then
Message.advance()
end
end
return false
end
local function answer(yes)
Choice.cursor = yes and 1 or 2
Choice.confirm()
end
local function drain()
for _ = 1, 400 do
if Message.isOpen() and not Message.isWaiting() then Message.skipReveal() end
if Message.isOpen() and Message.isWaiting() and not Choice.isOpen() then Message.advance() end
run(1)
if not Message.isOpen() and not Union.flow then run(1) end
end
end
local function page() return Message.isOpen() and Message.currentPage() or nil end
local function shows(text)
local p = page()
return p ~= nil and p ~= "" and text:find(p, 1, true) == 1
end
local function ascii(key) return RomText.ascii(key) end
local function member(id, slot, name, trainerId, gender)
return { id = id, name = name, slot = slot, online = true, status = "idle",
avatar = { name = name, trainerId = trainerId, gender = gender, version = "firered" } }
end
print("[test] 1. RunUnionRoom joins the union plaza when the adapter is connected")
Link.reset()
Space.mapId = UNION_MAP
session.map = UNION_MAP
Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters)
eq(Union.relay, true, "the union room runs over the relay")
local join = Client.last("joinPlaza")
eq(join and join[1], "union", "joinPlaza union")
eq(join and join[2] and join[2].rulesetId, "g3_link", "with the live g3_link profile")
eq(join and join[3] and join[3].name, "RED", "and the avatar")
eq(join and join[4], 40, "announcing the 40-player plaza cap")
eq(Union.capacity(), Plaza.CAP, "the relay room holds 40 players")
eq(Client.status, "idle", "presence status goes idle in the Union Room")
check(Union.isUnionMap(UNION_MAP) and Union.isUnionMap("FR_UNION_ROOM"), "both rooms are Union Room maps")
check(not Union.isUnionMap(CENTER_MAP), "the Pokemon Center is not")
print("[test] 2. every other trainer stands on the cell of their relay slot, self excluded")
Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 1, you = 2, members = {
member("aaaa0002", 1, "GREEN", 3, 1),
member("0000beef", 2, "RED", 0x1234, 0),
member("aaaa0001", 5, "BLUE", 0x2222, 0),
} }
run(1)
eq(Union.playerCount(), 2, "two other trainers")
eq(Union.players[1] and Union.players[1].name, "GREEN", "relay slot 1 is GREEN")
eq(Union.players[5] and Union.players[5].name, "BLUE", "relay slot 5 keeps its number (slot = cell)")
eq(Union.players[2], nil, "our own slot is never drawn")
eq(Union.vobj(2), nil, "and has no avatar")
eq(Union.players[1] and Union.players[1].id, "aaaa0002", "the slot remembers the relay id")
eq(#objectCalls.added, 0, "no cart map object is used")
local g1 = Union.vobj(1)
local cx1, cy1, cf1 = Plaza.cellFor(1)
check(g1 and g1.visible, "GREEN is a virtual object")
eq(g1 and g1.x, cx1, "on slot 1's cell x")
eq(g1 and g1.y, cy1, "on slot 1's cell y")
eq(g1 and g1.dir, Union.FACE_DIR[cf1], "facing the cell's default")
eq(Union.vobjId(5), Union.VOBJ_BASE + 5, "virtual object ids are VOBJ_BASE + slot")
local b5 = Union.vobj(5)
local cx5, cy5 = Plaza.cellFor(5)
eq(b5 and b5.x, cx5, "BLUE on slot 5's cell x")
eq(b5 and b5.y, cy5, "BLUE on slot 5's cell y")
local rec1 = VirtualObjects.get(Union.vobjId(1))
eq(rec1 and rec1.graphicsId, Union.graphicsIdFor(1, 3), "with the class graphics id")
check(rec1 and rec1.y2 < 0, "flying in")
run(20)
eq(VirtualObjects.get(Union.vobjId(1)).y2, 0, "and landed")
eq(VirtualObjects.count(), 2, "two avatars in the virtual object pool")
print("[test] 3. the plaza rebuilds only on a new (instance, rev)")
local builds = Union.plazaBuilds
run(30)
eq(Union.plazaBuilds, builds, "no rebuild while the rev stands still")
Client._plaza.members[3].status = "battling"
run(1)
eq(Union.players[5].activity, Union.ACTIVITY.NONE + Union.IN_UNION_ROOM, "an unannounced change is not applied")
Client._plaza.rev = 2
run(1)
eq(Union.plazaBuilds, builds + 1, "a new rev rebuilds once")
eq(Union.players[5].activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, "BLUE is battling")
eq(b5.x, cx5, "a busy player stays on its cell")
eq(b5.dir, Union.DIR.EAST, "facing its activity")
Client._plaza.members[3].status = "idle"
Client._plaza.instance, Client._plaza.rev = 2, 2
run(1)
eq(Union.plazaBuilds, builds + 2, "a new instance with the same rev rebuilds too")
eq(b5.dir, Union.FACE_DIR[select(3, Plaza.cellFor(5))], "BLUE is idle again")
local blueRow = table.remove(Client._plaza.members, 3)
Client._plaza.rev = 3
run(1)
eq(Union.players[5], nil, "a leaver is gone on the delta that drops it")
eq(b5.anim, "out", "and flies out")
run(Union.FLY_HEIGHT / Union.FLY_STEP)
eq(VirtualObjects.get(Union.vobjId(5)), nil, "then leaves the pool")
eq(VirtualObjects.count(), 1, "GREEN stays")
table.insert(Client._plaza.members, blueRow)
Client._plaza.rev = 4
run(1)
check(Union.vobjVisible(5), "a rejoin on the same slot comes back to the same cell")
run(20)
print("[test] 3a. a short relay blip keeps the room, a long drop empties it")
local realClock = Union.clock
local fakeNow = 1000
Union.clock = function() return fakeNow end
Client._state = "reconnecting"
run(1)
fakeNow = fakeNow + Union.GONE_GRACE_SECONDS / 2
run(1)
eq(Union.playerCount(), 2, "a blip keeps everyone")
Client._state = "online"
run(1)
eq(Union.playerCount(), 2, "and the resumed plaza (same rev) is applied again")
Client._state = "reconnecting"
run(1)
fakeNow = fakeNow + Union.GONE_GRACE_SECONDS + 1
run(1)
eq(Union.playerCount(), 0, "a drop past the grace empties the room")
Client._state = "online"
run(1)
eq(Union.playerCount(), 2, "the same rev after the drop still rebuilds")
Union.clock = realClock
run(20)
print("[test] 3b. a full 40-player plaza: 39 avatars, no per-frame allocation")
local full = {}
for slot = 1, Plaza.CAP do
if slot == 2 then
full[#full + 1] = member("0000beef", 2, "RED", 0x1234, 0)
else
full[#full + 1] = member(string.format("bbbb%04x", slot), slot, "P" .. slot, slot * 7, slot % 2)
end
end
full[1] = member("aaaa0002", 1, "GREEN", 3, 1)
Client._plaza = { kind = "union", instance = 3, cap = 40, rev = 10, you = 2, members = full }
run(1)
eq(Union.playerCount(), Plaza.CAP - 1, "39 other trainers")
run(25)
eq(VirtualObjects.count(), Plaza.CAP - 1, "39 avatars in the pool")
local cellsOk, seen = true, {}
for slot = 1, Plaza.CAP do
if slot ~= 2 then
local v = Union.vobj(slot)
local x, y = Plaza.cellFor(slot)
if not (v and v.visible and v.x == x and v.y == y) or seen[y * 64 + x] then cellsOk = false end
seen[y * 64 + x] = true
end
end
check(cellsOk, "every avatar on its own slot cell")
builds = Union.plazaBuilds
local okJit, jit = pcall(require, "jit")
if okJit and jit and jit.off then jit.off() jit.flush() end
for _ = 1, 10 do Union.relayTick(1 / 60) end
collectgarbage("collect")
collectgarbage("stop")
local kb = collectgarbage("count")
for _ = 1, 600 do Union.relayTick(1 / 60) end
local grown = (collectgarbage("count") - kb) * 1024
collectgarbage("restart")
if okJit and jit and jit.on then jit.on() end
eq(Union.plazaBuilds, builds, "600 frames on one rev never rebuild")
check(grown == 0, string.format("and allocate nothing (%d bytes)", grown))
Client._plaza.members[40].status = "chatting"
Client._plaza.rev = 11
run(1)
eq(Union.plazaBuilds, builds + 1, "one status change is one rebuild")
eq(Union.vobj(40).dir, Union.DIR.SOUTH, "a chatter faces south on its cell")
local x40, y40 = Plaza.cellFor(40)
Player.cellX, Player.cellY, Player.facing = x40 - 1, y40, "right"
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "recv_join_chat_request", "slot 40's chatter is talkable from the west")
check(settle(), "the join prompt asks YES/NO")
answer(false)
drain()
eq(Union.state, "main", "back to the main loop")
Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 20, you = 2, members = {
member("aaaa0002", 1, "GREEN", 3, 1),
member("0000beef", 2, "RED", 0x1234, 0),
} }
run(25)
eq(Union.playerCount(), 1, "back to GREEN alone")
print("[test] 3c. anyone chatting can be joined, from any side; idle trainers from any side")
local pink = member("aaaa0003", 3, "PINK", 5, 1)
pink.status = "chatting"
pink.group = { leader = "aaaa0003", members = { "aaaa0003", "aaaa0004" }, activity = "chat" }
local yellow = member("aaaa0004", 4, "YELLOW", 6, 0)
yellow.status = "chatting"
yellow.group = pink.group
table.insert(Client._plaza.members, pink)
table.insert(Client._plaza.members, yellow)
Client._plaza.rev = 21
run(21)
eq(Union.playerCount(), 3, "no grouping: the chat member has its own slot")
eq(Union.players[4] and Union.players[4].name, "YELLOW", "YELLOW on slot 4")
eq(Union.players[3].partners, nil, "no partner lists")
local vy = Union.vobj(4)
local x4, y4 = Plaza.cellFor(4)
eq(vy and vy.x, x4, "YELLOW stands on slot 4's cell")
eq(vy and vy.dir, Union.DIR.SOUTH, "facing its chat")
Union.toMain()
Message.reset()
Player.cellX, Player.cellY, Player.facing = x4, y4 + 1, "up"
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "recv_join_chat_request", "A in front of a chat member who is not the host")
eq(vy.dir, Union.DIR.SOUTH, "YELLOW turns to face the player")
check(settle(), "the join prompt asks YES/NO")
if romBundle then
check(shows(RomText.ascii("sText_JoinChatMale")), "gTexts_UR_JoinChat for a male chatter")
end
answer(true)
local jinv = Client.last("invite")
eq(jinv and jinv[1], "aaaa0004", "joining asks YELLOW directly")
eq(jinv and jinv[2], "chat", "for the chat")
eq(jinv and jinv[3] and jinv[3].join, true, "as a join")
eq(Union.state, "send_activity_request", "waiting for the relay")
local jh = Client.handles[#Client.handles]
jh.state, jh.why = "closed", "busy"
run(1)
eq(Union.state, "print_and_exit", "a refused join")
if romBundle then
check(shows(RomText.ascii(RomText.key("gTexts_UR_ChatDeclined", 0))), "reads gTexts_UR_ChatDeclined")
end
drain()
eq(Union.state, "main", "back to the main loop")
eq(vy.dir, Union.DIR.SOUTH, "YELLOW faces its chat again")
local x3, y3 = Plaza.cellFor(3)
pink.group.members = { "aaaa0003", "aaaa0004", "c1", "c2", "c3" }
Client._plaza.rev = 22
run(1)
Player.cellX, Player.cellY, Player.facing = x3 + 1, y3, "left"
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "print_and_exit", "a full chat (5 seats) only gets a reaction")
eq(Union.vobj(3).dir, Union.DIR.EAST, "PINK turns east to the player")
drain()
local xg, yg = Plaza.cellFor(1)
for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }) do
Union.toMain()
Message.reset()
Player.cellX, Player.cellY, Player.facing = xg + side[1], yg + side[2], side[3]
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "do_something_prompt", "idle GREEN is talkable facing " .. side[3])
Union.toMain()
Union.flow = nil
Message.reset()
end
eq(Union.vobj(1).dir, Union.FACE_DIR[cf1], "and turns back to its cell facing")
Player.cellX, Player.cellY, Player.facing = 12, 23, "down"
print("[test] 3d. a relay that refuses this build shows the update prompt once")
Union.toMain()
Message.reset()
Client._state = "error"
Client.upgradeRequired = function() return { type = "upgrade_required", text = "This build is too old for online play. Please update." } end
run(1)
eq(Union.state, "print_and_exit", "upgrade_required prints")
tickMsg()
check(Message.isOpen() and tostring(Message.currentPage()):find("too old", 1, true) ~= nil, "the relay's update text")
drain()
eq(Union.state, "main", "back to the main loop")
run(5)
eq(Union.state, "main", "and it is shown only once")
Client.upgradeRequired = nil
Client._state = "online"
run(25)
eq(Union.playerCount(), 3, "the room comes back when the relay does")
Union.toMain()
Message.reset()
print("[test] 4. talking to a trainer: HiDoSomething, the invite menu, the cart's reject")
run(1)
local held = ctx.stateWait
eq(held, Union.scriptWaitTask, "the Union Room holds the next waitstate")
ctx.stateWait = nil
Flags.setVar(store, ctx, 0x800D, 1)
eq(held(), false, "setvar VAR_RESULT, 1 then waitstate: the script waits")
Flags.setVar(store, ctx, 0x800D, 0)
run(1)
eq(Union.state, "main", "a VAR_RESULT that is not the attendant talks to no slot")
eq(Union.flow, nil, "and opens no flow")
eq(held(), true, "the waitstate is released")
Player.cellX, Player.cellY, Player.facing = xg, yg + 1, "up"
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "do_something_prompt", "talking to GREEN")
Player.cellX, Player.cellY, Player.facing = 12, 23, "down"
check(settle(), "the activity menu opens")
eq(Screen.mode, "activity", "GREETINGS / BATTLE / CHAT / EXIT")
if romBundle then
check(shows(RomText.ascii("sText_HiDoSomethingFemale")), "gTexts_UR_HiDoSomething for a stranger")
end
Screen.cursor = 2
Screen.confirm()
local inv = Client.last("invite")
eq(inv and inv[1], "aaaa0002", "invites the member by relay id")
eq(inv and inv[2], "battle_single", "BATTLE is battle_single")
eq(inv and inv[3] and inv[3].ruleset, "g3_single", "with the g3_single ruleset")
eq(inv and inv[4] and inv[4].rulesetId, "g3_single", "and a g3_single profile")
eq(Union.state, "send_activity_request", "waiting for the answer")
if romBundle then
check(shows(RomText.ascii(RomText.key("gTexts_UR_WaitOrShowCard", 1, 0))), "the partner's wait line")
end
local h = Client.handles[#Client.handles]
Union.update(5)
eq(Union.state, "send_activity_request", "no local timeout: the relay decides")
h.state, h.why = "closed", "declined"
run(1)
eq(Union.lastResult, "declined", "declined")
check(shows(RomText.ascii(RomText.key("gTexts_UR_BattleDeclined", 1))),
"the partner's gendered battle refusal (GetURoomActivityRejectMsg)")
drain()
eq(Union.state, "main", "back to the main loop")
Union.partnerId = 1
Union.chooseActivity(3)
h = Client.handles[#Client.handles]
h.state, h.why = "closed", "busy"
run(1)
check(shows(ascii("gText_UR_TrainerAppearsBusy")), "busy reads The TRAINER appears to be busy")
drain()
Union.partnerId = 1
Union.chooseActivity(3)
h = Client.handles[#Client.handles]
check(h ~= nil and h.state ~= "closed", "an open invite")
Client._state = "reconnecting"
run(30)
eq(Union.state, "send_activity_request", "a reconnecting relay keeps the invite wait")
Client._state = "error"
run(1)
eq(Union.lastResult, "busy", "the relay dropping for good ends the invite")
check(shows(ascii("gText_UR_TrainerBattleBusy")), "with gText_UR_TrainerBattleBusy (!gReceivedRemoteLinkPlayers)")
check(not Message._stay, "the stay-open wait line is gone")
drain()
eq(Union.state, "main", "back to the main loop")
eq(Union.invite, nil, "the dead handle is dropped")
Client._state = "online"
run(1)
Union.partnerId = 1
Union._partner = Union.players[1]
Union.chooseActivity(4)
eq(Union.state, "print_and_exit", "EXIT")
run(1)
if romBundle then
check(shows(RomText.ascii(RomText.key("gTexts_UR_IfYouWantToDoSomething", 1))),
"EXIT reads gTexts_UR_IfYouWantToDoSomething")
end
drain()
eq(Union.state, "main", "back to main")
print("[test] 5. an accepted CHAT starts the chat on the room session")
Union.partnerId = 1
Union.chooseActivity(3)
eq(Client.last("invite")[2], "chat", "CHAT is chat")
h = Client.handles[#Client.handles]
local room = FakeRelay.room({ seats = 2, intent = "chat", names = { "RED", "GREEN" } })
local rs0 = room:session(0)
h.state, h.why, h.room = "accepted", "accepted", room.id
run(1)
eq(Union.state, "await_room", "accepted: waiting for the room")
run(1)
eq(Union.state, "await_room", "still no room")
Client.bindRoom(room, 0)
run(3)
eq(Union.state, "in_activity", "room_state + match_start: the chat started")
eq(Link.link, nil, "no Game3Link handshake for a chat")
check(Chat.isActive(), "chat is live")
local joinMsg = room.inbox[1][1]
eq(joinMsg and joinMsg.type, "game3_union_hello", "RED announced itself to the room")
room:push(1, { type = "game3_union_hello", name = "GREEN", gender = 1, trainerId = 3, activity = 0x45 })
room:push(1, { type = "game3_chat_line", name = "GREEN", text = "HI RED" })
run(1)
eq(#Chat.lines, 2, "GREEN joined and spoke")
eq(Chat.lines[2] and Chat.lines[2].seat, 1, "GREEN's lines use seat 1's colour")
Chat.stop("left")
run(2)
eq(Union.state, "main", "chat over, back in the room")
eq(rs0.closeCalls, 1, "and the private room was left")
eq(Client.status, "idle", "presence back to idle")
print("[test] 6. an incoming invite rings, asks, and answers the relay")
Client.bindRoom(nil, nil)
Client._invites = { { id = "i0000000000000001", from = { id = "aaaa0002", name = "GREEN" },
activity = "card", detail = {}, expiresAt = 0 } }
Union.state = "main"
Union.pollIncoming()
eq(Union.state, "player_contacted_you", "the request lands")
eq(Union.activity, Union.ACTIVITY.CARD, "as GREETINGS")
eq(Union._requestName, "GREEN", "from GREEN")
eq(Union.partnerId, 1, "who is in slot 1")
Union.answerRequest(false)
eq(Client.last("replyInvite")[1], "i0000000000000001", "the reply names the invite")
eq(Client.last("replyInvite")[2], false, "and declines it")
eq(Union.state, "main", "back to main")
Client._invites = { { id = "i0000000000000002", from = { id = "aaaa0002", name = "GREEN" },
activity = "chat", detail = {} } }
Union.state = "do_something_prompt"
Union.pollIncoming()
eq(Client.last("replyInvite")[1], "i0000000000000002", "an invite outside the main loop")
eq(Client.last("replyInvite")[2], false, "is refused at once")
Client._invites = { { id = "i0000000000000003", from = { id = "aaaa0002", name = "GREEN" },
activity = "card", detail = {} } }
Union.state = "main"
Union.pollIncoming()
Union.answerRequest(true)
eq(Client.last("replyInvite")[2], true, "YES accepts")
eq(Union.state, "await_room", "and waits for the room")
local room2 = FakeRelay.room({ seats = 2, intent = "card" })
Client.bindRoom(room2, 1)
Union.update(1 / 60)
local host2 = Game3Link.attach(FakeRelay.transport(room2, 0), { game = game, seat = 0, seats = 2 })
Link.update(1 / 60)
host2:update(0)
Link.update(1 / 60)
eq(Union.state, "await_link", "GREETINGS waits for the partner's card")
host2:send({ type = Link.MSG.CARD, card = { name = "GREEN", trainerId = 3 } })
Link.update(1 / 60)
Link.update(1 / 60)
eq(Link.peerCard and Link.peerCard.name, "GREEN", "the card arrived")
eq(Union.lastResult, "card_shown", "and the card was shown")
Link.reset()
Client.bindRoom(nil, nil)
Union.state = "main"
Union.relay = true
print("[test] 6b. the TRAINER CARD we send")
local lc = Link.localTrainerCard()
eq(type(lc.stars), "number", "the card we send carries the star count")
eq(type(lc.caughtMonsCount), "number", "and the POKeDEX count")
print("[test] 7. the trading board: register at the attendant, browse at (2,1), offer a mon")
local pickSlot = 1
package.loaded["src.ui.game3.party_menu"] = {
show = function(_, _, opts) opts.onSelect(pickSlot) end,
close = function() end,
}
Client.calls = {}
Flags.setVar(store, ctx, 0x800D, Union.INTERACT_ATTENDANT)
run(1)
eq(Union.state, "register_prompt", "the attendant offers the board")
check(settle(), "REGISTER / INFO / EXIT")
eq(Screen.mode, "register", "the register menu")
eq(#Screen.list.items, 3, "three rows")
if romBundle then
local p = page()
check(p ~= nil and RomText.ascii("gText_UR_RegisterMonAtTradingBoard", { playerName = "RED" }):find(p, 1, true) ~= nil,
"gText_UR_RegisterMonAtTradingBoard")
end
Screen.cursor = 1
Screen.confirm()
drain()
check(settle(), "the type list opens after choosing a POKeMON")
eq(Screen.mode, "types", "the requested type list")
eq(#Screen.list.items, 18, "17 types and EXIT")
eq(Screen.list.items[2].id, 10, "row 2 is FIRE (TYPE_FIRE)")
Screen.cursor = 2
Screen.confirm()
local pres = Client.last("setPresence")
local board = pres and pres[1] and pres[1].board
eq(board and board.species, 1, "the board shows the registered species")
eq(board and board.level, 12, "and its level")
eq(board and board.wantType, 10, "wanting a FIRE type")
drain()
eq(Union.state, "main", "registration complete")
local greenMember = Client._plaza.members[1]
eq(greenMember.name, "GREEN", "GREEN's plaza row")
greenMember.board = { species = 4, level = 9, wantType = 12 }
Client._plaza.rev = Client._plaza.rev + 1
run(1)
eq(Union.players[1].board and Union.players[1].board.species, 4, "GREEN's board offer is on the member row")
local offers = Union.boardOffers()
eq(#offers, 1, "one offer on the board")
Player.cellX, Player.cellY, Player.facing = 2, 2, "up"
input.pressed = { a = true }
run(1)
input.pressed = {}
eq(Union.state, "check_trading_board", "A at the board")
check(settle(), "the board list opens")
eq(Screen.mode, "board", "the trading board")
eq(#Screen.list.items, 10, "header, eight offers, EXIT")
check(Screen.list.items[1].disabled, "the header row cannot be picked")
eq(Screen.list.items[1].entry and Screen.list.items[1].entry.species, 1, "and shows our own registration")
eq(Screen.cursor, 2, "the cursor starts on the first offer")
if romBundle then
Screen.confirm()
check(settle(), "asked whether to ask GREEN")
check(shows(RomText.ascii("gText_UR_AskTrainerToMakeTrade", { stringVars = { "GREEN" } })),
"gText_UR_AskTrainerToMakeTrade")
pickSlot = 1
answer(true)
drain()
local tinv = Client.last("invite")
eq(tinv and tinv[1], "aaaa0002", "the offer goes to GREEN")
eq(tinv and tinv[2], "trade", "as a trade")
eq(tinv and tinv[3] and tinv[3].board and tinv[3].board.species, 1, "carrying the offered mon")
eq(Union.state, "send_activity_request", "waiting for GREEN")
local th = Client.handles[#Client.handles]
th.state, th.why = "closed", "declined"
run(1)
check(shows(RomText.ascii("gText_UR_TradeOfferRejected")), "a declined offer reads gText_UR_TradeOfferRejected")
drain()
else
Screen.cancel()
run(1)
end
eq(Union.state, "main", "back from the board")
Player.cellX, Player.cellY, Player.facing = 7, 11, "down"
print("[test] 7b. the board lists every offer in the room and scrolls; EXIT never collides")
for slot = 5, 16 do
local m = member(string.format("cccc%04x", slot), slot, "T" .. slot, slot, 0)
m.board = { species = slot, level = slot, wantType = 12 }
table.insert(Client._plaza.members, m)
end
Client._plaza.rev = Client._plaza.rev + 1
run(1)
eq(#Union.boardOffers(), 13, "13 offers from 40 possible slots")
Union.openTradingBoard()
eq(Screen.mode, "board", "the trading board")
local items = Screen.list.items
eq(#items, 15, "header, 13 offers, EXIT")
eq(items[#items].id, Screen.BOARD_EXIT, "EXIT has its own id")
eq(items[10].id, 8, "the ninth offer keeps id 8")
check(items[10].entry ~= nil and not items[10].disabled, "and is a real, pickable offer")
eq(Screen.list.maxShowed, 5, "five rows at a time")
Screen.select(10)
check(Screen.list.scroll > 0, "the list scrolls to reach it")
Screen.confirm()
check(Union.state ~= "main", "picking offer id 8 opens that offer, not EXIT (" .. tostring(Union.state) .. ")")
Union.toMain()
Message.reset()
Union.openTradingBoard()
Screen.select(#Screen.list.items)
Screen.confirm()
eq(Union.state, "main", "EXIT closes the board")
for i = #Client._plaza.members, 1, -1 do
if tostring(Client._plaza.members[i].id):sub(1, 4) == "cccc" then table.remove(Client._plaza.members, i) end
end
Client._plaza.rev = Client._plaza.rev + 1
run(25)
Union.trade().playerSpecies, Union.trade().playerLevel = 1, 12
Flags.setVar(store, ctx, 0x800D, Union.INTERACT_ATTENDANT)
run(1)
eq(Union.state, "cancel_registration_prompt", "a registered player is asked to cancel")
check(settle(), "YES/NO")
answer(true)
pres = Client.last("setPresence")
eq(pres and pres[1] and pres[1].board, false, "cancelling clears the board")
eq(Union.trade().playerSpecies, 0, "and the registration")
drain()
eq(Union.state, "main", "back to main")
print("[test] 7. leaving the Union Room leaves the plaza and goes busy")
Space.mapId = CENTER_MAP
session.map = CENTER_MAP
Union.update(1 / 60)
eq(Union.state, "off", "the union room stopped")
eq(Client.last("leavePlaza")[1], "union", "leavePlaza union")
eq(Client.status, "busy", "presence busy outside the plazas")
Link.reset()
Client.bindRoom(nil, nil)
print("[test] 8. Direct Corner JOIN queues AUTO, B cancels")
Message.reset()
ctx.nativePoll = nil
Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.SINGLE_BATTLE)
local y = Natives.special(ctx, NativesLink.SPECIAL.TryJoinLinkGroup, adapters)
eq(y, true, "the script waits")
local q = Client.last("queueDirect")[1]
eq(q and q.activity, "battle_single", "queues battle_single")
eq(q and q.ruleset, "g3_single", "ruleset g3_single")
eq(q and q.auto, true, "AUTO")
eq(q and q.pin, nil, "no PIN")
eq(q and q.profile and q.profile.rulesetId, "g3_single", "profile matches the ruleset")
eq(q and q.avatar and q.avatar.name, "RED", "with the avatar")
eq(q and q.preview and q.preview[1], 1, "and a party preview")
check(shows(ascii("CableClub_Text_PleaseWaitBCancel")), "Please wait. B Button: Cancel")
eq(Client.status, "idle", "queued players are invitable")
eq(ctx.nativePoll(), false, "still waiting")
local Direct = require("src.ui.game3.link_menu").Direct
check(Direct.isOpen() and Direct.view == "wait", "the Direct Corner wait layer owns the input")
input.pressed = { b = true }
Direct.handleInput(input)
eq(ctx.nativePoll(), true, "B resumes the script")
input.pressed = {}
check(not Direct.isOpen(), "the wait layer closed")
eq(Client.count("leaveDirect"), 1, "and leaves the queue")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.FAILED, "LINKUP_FAILED goes back to the menu")
check(not Message.isOpen(), "the wait box closed")
print("[test] 9. Direct Corner LEAD hosts, pairs, and walks into the Trade Center")
Space.mapId = CENTER_MAP
session.map = CENTER_MAP
Player.cellX, Player.cellY = 9, 1
Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.TRADE)
ctx.nativePoll, ctx.stateWait = nil, nil
Natives.special(ctx, NativesLink.SPECIAL.TryBecomeLinkLeader, adapters)
q = Client.last("queueDirect")[1]
eq(q and q.activity, "trade", "queues trade")
eq(q and q.auto, false, "LEAD hosts an open room")
local room3 = FakeRelay.room({ seats = 2, intent = "trade" })
local rs3 = room3:session(0)
Client.bindRoom(room3, 0)
eq(ctx.nativePoll(), true, "match_start resumes the script")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "LINKUP_SUCCESS")
check(Link.link ~= nil and Link.link.seat == 0, "the link is attached at seat 0")
eq(Link.link and Link.link.linkType, Game3Link.LINKTYPE.TRADE_SETUP, "as a trade link")
check(type(ctx.stateWait) == "function", "the activity is armed for the script's waitstate")
local first = ctx.stateWait
ctx.stateWait = nil
eq(first(), true, "the waitstate right after the special passes")
check(type(ctx.stateWait) == "function", "and re-arms for EnterWirelessLinkRoom's waitstate")
mapLoads = {}
eq(ctx.stateWait(), true, "the final waitstate warps and finishes")
eq(mapLoads[1] and mapLoads[1].map, "FR_TRADE_CENTER", "into the Trade Center")
eq(mapLoads[1] and mapLoads[1].x, 5, "at x 5")
eq(mapLoads[1] and mapLoads[1].y, 8, "at y 8")
eq(getVar(Link.VAR_0x8004), Link.USING.TRADE_CENTER, "VAR_0x8004 = USING_TRADE_CENTER")
eq(getVar(Link.VAR_CABLE_CLUB_STATE), Link.USING.TRADE_CENTER, "VAR_CABLE_CLUB_STATE too")
eq(session.warpDestination and session.warpDestination.map, "FR_TRADE_CENTER",
"SetCableClubWarp ran at the door")
Link.closeLink("done")
eq(rs3.closeCalls, 1, "closing the link leaves the room")
Link.reset()
Client.bindRoom(nil, nil)
print("[test] 10. SET PIN hosts a locked room")
Union.direct = { mode = "pin", pin = "0420", auto = true }
Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.DOUBLE_BATTLE)
ctx.nativePoll, ctx.stateWait = nil, nil
Natives.special(ctx, NativesLink.SPECIAL.TryBecomeLinkLeader, adapters)
q = Client.last("queueDirect")[1]
eq(q and q.activity, "battle_double", "battle_double")
eq(q and q.pin, "0420", "with the PIN")
eq(q and q.auto, true, "letting AUTO players in")
eq(q and q.ruleset, "g3_double", "ruleset g3_double")
eq(Union.direct.mode, nil, "the mode is consumed")
input.pressed = { b = true }
Direct.handleInput(input)
ctx.nativePoll()
input.pressed = {}
Message.reset()
print("[test] 11. a minigame leader accepts joiners and starts the group")
local Lobby = require("src.ui.game3.minigames.common_lobby")
Screen = Lobby
Screen.reset()
Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.BERRY_CRUSH)
ctx.nativePoll, ctx.stateWait = nil, nil
Natives.special(ctx, NativesLink.SPECIAL.TryBecomeLinkLeader, adapters)
local og = Client.last("openGroup")
eq(og and og[1], "minigame_crush", "openGroup minigame_crush")
eq(og and og[2] and og[2].rulesetId, "g3_link", "with the g3_link profile")
check(Screen.isOpen() and Screen.mode == "leader", "the leader list is up")
eq(Lobby.group and Lobby.group.min, 2, "the lobby knows Berry Crush needs 2")
eq(Lobby.group and Lobby.group.max, 5, "and takes up to 5")
check(Lobby._onTick ~= nil, "the lobby ticks the flow while the VM is paused")
Client._group = { leader = "0000beef", activity = "minigame_crush", min = 2, max = 5,
members = { { id = "0000beef", name = "RED", seat = 0, avatar = { name = "RED", trainerId = 0x1234, gender = 0 } },
{ id = "aaaa0001", name = "BLUE", seat = 1, avatar = { name = "BLUE", trainerId = 0x2222, gender = 0 } } },
pending = { { id = "aaaa0003", name = "GOLD", avatar = { name = "GOLD" } } } }
eq(ctx.nativePoll(), false, "waiting")
check(not Screen.isOpen(), "the list steps aside for the request")
check(Message.isOpen(), "a request prompt is up")
tickMsg()
ctx.nativePoll()
local Choice = require("src.ui.game3.choice")
check(Choice.isOpen(), "YES/NO")
Choice.cursor = 1
Choice.confirm()
eq(Client.last("acceptGroup")[1], "aaaa0003", "the joiner is accepted")
eq(Client.last("acceptGroup")[2], true, "YES")
check(Screen.isOpen() and Screen.mode == "leader", "back on the list")
Client._group.pending = {}
Screen.update(1 / 60)
eq(#Screen.players, 1, "the list shows the accepted member")
check(Screen.confirm(), "START with enough members")
eq(Client.count("startGroup"), 1, "startGroup sent")
local room4 = FakeRelay.room({ seats = 2, intent = "minigame", names = { "RED", "BLUE" } })
room4.players[1].id = "0000beef"
room4.players[2].id = "aaaa0001"
Client.bindRoom(room4, 0)
eq(ctx.nativePoll(), true, "match_start resumes the script")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "LINKUP_SUCCESS")
eq(Link.link, nil, "minigames do not attach a Game3Link")
local arm = ctx.stateWait
ctx.stateWait = nil
check(type(arm) == "function", "MG.arm waits for the script's waitstate")
eq(#mgCalls, 0, "not armed yet")
eq(arm(), true, "the waitstate after the special arms it")
eq(#mgCalls, 1, "MG.arm ran once")
local spec = mgCalls[1] and mgCalls[1].spec or {}
eq(spec.game, "crush", "Berry Crush")
eq(spec.seat, 0, "seat 0")
eq(spec.seats, 2, "two seats")
eq(spec.seed, room4.seed, "the relay seed")
eq(spec.partySlot, 1, "the chosen party slot")
eq(spec.players and spec.players[2] and spec.players[2].trainerId, 0x2222,
"players carry trainer ids from the group avatars")
eq(spec.session, room4:session(0), "and the room session")
Client.bindRoom(nil, nil)
Client._group = nil
print("[test] 12. a minigame joiner picks a group, can back out, and cancel")
Screen.reset()
Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.POKEMON_JUMP)
ctx.nativePoll = nil
Client._groups = { { leader = "aaaa0001", name = "BLUE", avatar = { name = "BLUE" }, joined = 1, min = 2, max = 5 } }
Natives.special(ctx, NativesLink.SPECIAL.TryJoinLinkGroup, adapters)
local gl = Client.last("groupList")
eq(gl and gl[1], "minigame_jump", "subscribes to minigame_jump groups")
check(Screen.isOpen() and Screen.mode == "group", "the group list is up")
eq(#Screen.players, 1, "listing BLUE's group")
Screen.confirm()
eq(Client.last("joinGroup")[1], "aaaa0001", "asks to join BLUE")
check(shows(ascii("CableClub_Text_PleaseWaitBCancel")), "waits")
input.pressed = { b = true }
ctx.nativePoll()
input.pressed = {}
eq(Client.count("leaveGroup"), 1, "B withdraws the request")
check(Screen.isOpen(), "back on the list")
Screen.cancel()
eq(ctx.nativePoll(), true, "cancel resumes the script")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.FAILED, "LINKUP_FAILED")
eq(Client.last("groupList")[1], nil, "and unsubscribes")
Link.reset()
if failed == 0 then
print("[pass] relay union room")
os.exit(0)
end
print("[fail] relay union room: " .. failed)
os.exit(1)