From 697e064fd2a59eccd5359611dd4ce73ec326f3e7 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 6 Oct 2026 15:48:54 -0400 Subject: [PATCH] Gen 3 online: all versions on the relay, event catalog, link room presence --- docs/new-features.md | 8 +- src/box/Events.lua | 47 ++- src/core/game3/field_view.lua | 8 + src/core/game3/link/battle.lua | 133 +++++--- src/core/game3/link/cable_entry.lua | 330 ++++++++++++++++++ src/core/game3/link/contest_link.lua | 18 + src/core/game3/link/link_players.lua | 16 + src/core/game3/link/plaza_wander.lua | 171 ++++++++++ src/core/game3/link/record_mix.lua | 26 ++ src/core/game3/link/union_room.lua | 170 +++++++++- src/core/game3/mystery_gift.lua | 249 +++++++++++--- src/core/game3/rs/link_entry.lua | 151 +-------- src/core/game3/rse/event_islands.lua | 64 +++- src/core/game3/scripting/natives_contest.lua | 11 +- src/link/Game3Link.lua | 4 + src/online/ArenaData.lua | 84 ++++- src/ui/game3/link_tags.lua | 336 +++++++++++++++++++ src/ui/game3/mystery_gift.lua | 169 +++++++++- src/ui/game3/option_rows.lua | 5 +- src/ui/game3/rs/cable_lobby.lua | 9 +- src/ui/game3/rs/mystery_events.lua | 205 ++++++----- tests/drivers/em_event_islands.lua | 7 + tests/drivers/g3link_room_presence.lua | 246 ++++++++++++++ tests/emerald_f4_test.lua | 11 +- tests/engine/box_events_test.lua | 25 ++ tests/g3link_rs_mix_side.lua | 45 +++ tests/game3_gift_catalog_test.lua | 153 +++++++++ tests/game3_gift_feed_test.lua | 2 +- tests/game3_gift_menu_test.lua | 146 +++++++- tests/game3_gift_rse_test.lua | 15 +- tests/game3_gift_versions_test.lua | 91 +++++ tests/game3_link_battle_test.lua | 90 +++-- tests/game3_link_room_presence_test.lua | 184 ++++++++++ tests/game3_link_rs_cross_test.lua | 262 +++++++++++++++ tests/game3_link_rs_entry_test.lua | 304 +++++++++++++++++ tests/game3_link_translation_mods_test.lua | 121 +++++++ tests/game3_rs_mystery_event_feed_test.lua | 192 +++++++++++ tools/gen3_event_catalog.py | 256 ++++++++++++++ tools/rs_mystery_event_mons.lua | 107 ++++++ tools/rs_mystery_event_payloads.py | 97 ++++++ 40 files changed, 4141 insertions(+), 427 deletions(-) create mode 100644 src/core/game3/link/cable_entry.lua create mode 100644 src/core/game3/link/plaza_wander.lua create mode 100644 src/ui/game3/link_tags.lua create mode 100644 tests/drivers/g3link_room_presence.lua create mode 100644 tests/g3link_rs_mix_side.lua create mode 100644 tests/game3_gift_catalog_test.lua create mode 100644 tests/game3_gift_versions_test.lua create mode 100644 tests/game3_link_room_presence_test.lua create mode 100644 tests/game3_link_rs_cross_test.lua create mode 100644 tests/game3_link_rs_entry_test.lua create mode 100644 tests/game3_link_translation_mods_test.lua create mode 100644 tests/game3_rs_mystery_event_feed_test.lua create mode 100644 tools/gen3_event_catalog.py create mode 100644 tools/rs_mystery_event_mons.lua create mode 100644 tools/rs_mystery_event_payloads.py diff --git a/docs/new-features.md b/docs/new-features.md index 4a0e5550..b2f67315 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -16,6 +16,10 @@ Features intentionally added to the games themselves, beyond what the original c * **Pokédex diploma and printer image exports** * **Fast-forward locks to 1X in link play**, every link or online battle, link session and the FireRed/LeafGreen Union Room on every game * **40-player Union Room** on FireRed and LeafGreen online, a larger square room where every trainer keeps a fixed spot and anyone can be talked to +* **Name badges over trainers** in Gen 3 link rooms (Union Room, Trade Center, Colosseum, Record Corner, Berry Blender), your own included +* **Chat, trade and battle icons** on those badges, so a busy trainer is visible at a glance +* **Idle Union Room trainers wander**, a few steps around their spot and back +* **Translation mods stay online** on Gen 3: mods that only change text, fonts, names or visuals can still link, trade and battle * **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons) ## Gen 2 Specifics @@ -24,4 +28,6 @@ Features intentionally added to the games themselves, beyond what the original c * **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround) * **Widescreen battle layout** on Gold, Silver and Crystal: BATTLE LAYOUT -> WIDE spreads the HUDs and bottom windows across a 304-wide screen * **Extended battle HUD** on Gold, Silver and Crystal: BATTLE HUD -> EXTENDED docks the WIDE layout's foe panel to the top of the window and the text area and player panel to the bottom -* **Event tickets** on Emerald: OPTION -> EXTRAS -> EVENT TICKETS lets the Lilycove Mystery Gift man hand out the Eon, Aurora and Mystic tickets and the Old Sea Map +* **Event tickets** on Emerald: OPTION -> EXTRAS -> EVENT TICKETS lets the Lilycove Mystery Gift man hand out whichever of the Eon, Aurora and Mystic tickets and the Old Sea Map the relay is currently distributing +* **Relay Mystery Events** on Ruby/Sapphire: MYSTERY EVENTS loads signed e-Reader events (Eon Ticket, gift ribbon) from the relay instead of a link partner +* **Wonder News on Ruby/Sapphire**: relay Wonder News is listed and saved under MYSTERY EVENTS, so every Gen 3 game reads the same news diff --git a/src/box/Events.lua b/src/box/Events.lua index 4f4c16dc..a8a6d1fe 100644 --- a/src/box/Events.lua +++ b/src/box/Events.lua @@ -14,6 +14,44 @@ Events.ROWS = { { id="oldSeaMap",name="Old Sea Map",item="ITEM_OLD_SEA_MAP",versions={emerald={[1]=true}}, card="rse_old_sea_map",route="Faraway Island",method="Wonder Card" }, } +Events.FAMILY={firered="frlg",leafgreen="frlg",emerald="rse",ruby="rs",sapphire="rs"} +Events._feeds,Events._jobs={},{} +function Events.setFeed(version,list) Events._feeds[version]=list end +function Events.poll(version,opts) + local family=Events.FAMILY[version] + if not family then return "error","no_family" end + if Events._feeds[version] then return "ok",Events._feeds[version] end + local Gift=require("src.core.game3.mystery_gift") + local job=Events._jobs[version] + if not job then + opts=opts or {} + job=Gift.fetchOnline({family=family,version=version,transport=opts.transport,client=opts.client}) + Events._jobs[version]=job + end + local status,result=Gift.pollOnline(job) + if status=="pending" then return status end + Events._jobs[version]=nil + if status=="ok" then Events._feeds[version]=result end + return status,result +end +local function relayOffer(save,row) + local status,list=Events.poll(save.version) + if status=="pending" then return nil,"Checking the relay for tickets. Try again in a moment." end + if status~="ok" then return nil,"The relay could not be reached, so no tickets can be received." end + local Gift=require("src.core.game3.mystery_gift") + local item=require("src.core.game3.constants").of(save.version):require("items",row.item) + if Events.FAMILY[save.version]=="rs" then + for _,ev in ipairs(list.events or {}) do + if Gift.eventRecordMixingItem(ev.bytes)==item then return ev end + end + else + for _,entry in ipairs(list.cards or {}) do + if entry.card.gift and entry.card.gift.kind=="item" and entry.card.gift.item==item then return entry end + end + end + return nil,"The relay is not offering this ticket right now." +end +Events.relayOffer=relayOffer function Events.allowed(id,version,language) for _,row in ipairs(Events.ROWS) do if row.id==id then return row.versions[version] and row.versions[version][language] and row or nil end @@ -22,6 +60,7 @@ end function Events.list(version,language) local out={} for _,row in ipairs(Events.ROWS) do if Events.allowed(row.id,version,language) then out[#out+1]=row end end + if #out>0 and Events.autoFetch~=false then pcall(Events.poll,version) end return out end local function session(save) @@ -67,6 +106,8 @@ function Events.receive(save,id,language) if not status then return nil,why end if status~="Available" then return nil,"This event has already been received." end return Context.withItems(save.version,function() + local offer,offerWhy=relayOffer(save,row) + if not offer then return nil,offerWhy end local out=Store.copy(save) local s=session(out) local C=require("src.core.game3.constants").of(save.version) @@ -85,11 +126,7 @@ function Events.receive(save,id,language) local flag=Gift.receivedGiftFlag(existing.flagId,s) if flag and not Gift.getFlag(s,flag) then return nil,"Collect your existing Wonder Card gift before receiving another card." end end - local family=save.version=="emerald" and "rse" or "frlg" - local key=family=="rse" and (row.rseCard or row.card) or row.card - local card - for _,candidate in ipairs(Gift.builtins(family)) do if candidate.key==key then card=candidate.card end end - if not card or not Gift.receiveCard(s,card) then return nil,"The game's ticket delivery card could not be prepared." end + if not Gift.receiveCard(s,offer.card) then return nil,"The game's ticket delivery card could not be prepared." end end apply(s,out);return out end) diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index be861f62..187a1c1c 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -1812,6 +1812,14 @@ function FieldView.draw(game, canvasW, canvasH, opts) end end + if underActors or overActors then + local LinkTags = package.loaded["src.ui.game3.link_tags"] + if LinkTags and LinkTags.draw then + LinkTags.draw(underActors, overActors, camX, camY, canvasW, canvasH, + FieldView._billboard and pushBillboard or nil) + end + end + -- pokefirered/src/field_effect.c:1024 if not opts.actorsOnly then local Heal = modHeal() diff --git a/src/core/game3/link/battle.lua b/src/core/game3/link/battle.lua index e9ef3426..1322891c 100644 --- a/src/core/game3/link/battle.lua +++ b/src/core/game3/link/battle.lua @@ -29,6 +29,9 @@ LB.LINKTYPE = { SINGLE_BATTLE = 0x2233, DOUBLE_BATTLE = 0x2244, MULTI_BATTLE = 0x2255, + -- pokeemerald/include/link.h:95 + BATTLE_TOWER_50 = 0x2266, + BATTLE_TOWER_OPEN = 0x2277, RECORD_MIX_BEFORE = 0x3311, BERRY_BLENDER_SETUP = 0x4411, } @@ -1619,9 +1622,59 @@ function LB.playerCount() return 2 end --- pokefirered/src/cable_club.c:222 CreateLinkupTask +local function relayPeers(live) + local out = {} + for seat, hello in pairs(type(live.peerHellos) == "table" and live.peerHellos or {}) do + local g3 = type(hello) == "table" and type(hello.game3) == "table" and hello.game3 or {} + out[#out + 1] = { seat = seat, linkType = tonumber(g3.linkType), version = tonumber(g3.gameVersion), + progressFlags = tonumber(g3.progressFlags) or 0 } + end + table.sort(out, function(a, b) return a.seat < b.seat end) + return out +end + +-- pokeemerald/src/link.c:817 GetLinkPlayerDataExchangeStatusTimed +function LB.linkupStatus(ctx, spec, peers, relay) + local L = link() + local Family = require("src.core.game3.link.family") + local CableEntry = require("src.core.game3.link.cable_entry") + local players = LB.playerCount() + -- pokeemerald/src/link.c:830 + if players < spec.min or players > spec.max then return L.LINKUP.WRONG_NUM_PLAYERS end + local want = spec.linkType + if relay then want = CableEntry.classOf(spec.linkType) end + for _, peer in ipairs(peers) do + local got = tonumber(peer.linkType) + if relay then got = CableEntry.classOf(peer.linkType) end + if (relay and want ~= nil and got ~= nil and got ~= want) or (not relay and got ~= want) then + -- pokeemerald/src/link.c:876 + local towers = { battle_tower = true, battle_tower_open = true } + if towers[CableEntry.classOf(spec.linkType)] and towers[CableEntry.classOf(peer.linkType)] then + L.setVar(ctx, LB.VAR_0x8005, 3) + end + return L.LINKUP.DIFF_SELECTIONS + end + end + local peer = peers[1] + if spec.linkType == require("src.link.Game3Link").LINKTYPE.TRADE_SETUP and peer and peer.version ~= nil then + local mine = Family.localLinkPlayer(L.session()) + -- pokeemerald/src/link.c:853 + local code = Family.gameProgressForLinkTrade(mine.family, mine, peer) + if code == Family.TRADE.PLAYER_NOT_READY then return L.LINKUP.PLAYER_NOT_READY end + if code == Family.TRADE.PARTNER_NOT_READY then return L.LINKUP.PARTNER_NOT_READY end + end + return L.LINKUP.SUCCESS +end + +-- pokefirered/src/cable_club.c:76 function LB.createLinkupTask(ctx, adapters, spec) local L = link() + local lk = L.link + if not (lk and lk.isOpen and lk:isOpen()) then + return require("src.core.game3.link.cable_entry").run(ctx, adapters, spec, function(c, a) + return LB.createLinkupTask(c, a, spec) + end) + end local function report(code) LB.linkup = code L.setResult(ctx, code) @@ -1630,22 +1683,13 @@ function LB.createLinkupTask(ctx, adapters, spec) report(L.LINKUP.ONGOING) LB.state = "linkup" LB.seed = nil - local announced = false - local lk = L.link - local Family = require("src.core.game3.link.family") - local mine = Family.localLinkPlayer(L.session()) - local function linkupMsg() - return { type = LB.MSG.LINKUP, linkType = spec.linkType, players = spec.min, - version = mine.version, progressFlags = mine.progressFlags } - end - if lk then - lk.linkType = spec.linkType - -- pokefirered/src/cable_club.c:318 Task_LinkupExchangeDataWithLeader - lk:send(linkupMsg()) - announced = true - else - -- pokefirered/src/cable_club.c:222 CreateLinkupTask waits for the other machine - L.beginConnect({ linkType = spec.linkType }) + local relay = LB.onRelay() + lk.linkType = spec.linkType + if not relay then + local mine = require("src.core.game3.link.family").localLinkPlayer(L.session()) + -- pokefirered/src/cable_club.c:318 + lk:send({ type = LB.MSG.LINKUP, linkType = spec.linkType, players = spec.min, + version = mine.version, progressFlags = mine.progressFlags }) end local ticks = 0 local Natives = natives() @@ -1658,41 +1702,20 @@ function LB.createLinkupTask(ctx, adapters, spec) ctx.nativePoll = function() ticks = ticks + 1 local live = L.link - if live and not announced then - live.linkType = spec.linkType - live:send(linkupMsg()) - announced = true - end - if not announced then - -- pokefirered/src/cable_club.c:482 TryLinkTimeout - if ticks > LB.LINKUP_TICKS then - report(L.LINKUP.CONNECTION_ERROR) - LB.state = "off" - return true - end - return false - end - local peer = live and live:isReady() and live:take(LB.MSG.LINKUP) or nil - if peer then - local players = LB.playerCount() - if tonumber(peer.linkType) ~= spec.linkType then - -- pokefirered/src/cable_club.c:122 EXCHANGE_DIFF_SELECTIONS - report(L.LINKUP.DIFF_SELECTIONS) - LB.state = "off" - elseif players < spec.min or players > spec.max then - -- pokefirered/src/cable_club.c:127 EXCHANGE_WRONG_NUM_PLAYERS - report(L.LINKUP.WRONG_NUM_PLAYERS) - LB.state = "off" - elseif spec.linkType == require("src.link.Game3Link").LINKTYPE.TRADE_SETUP and peer.version ~= nil - and Family.gameProgressForLinkTrade(mine.family, mine, peer) ~= Family.TRADE.BOTH_PLAYERS_READY then - -- pokeemerald/src/link.c:853 - local code = Family.gameProgressForLinkTrade(mine.family, mine, peer) - report(code == Family.TRADE.PLAYER_NOT_READY and L.LINKUP.PLAYER_NOT_READY or L.LINKUP.PARTNER_NOT_READY) - LB.state = "off" + local peers + if live and live:isOpen() and live:isReady() then + if relay then + peers = relayPeers(live) else - report(L.LINKUP.SUCCESS) - LB.state = "seat" + local peer = live:take(LB.MSG.LINKUP) + if peer then + peers = { { linkType = tonumber(peer.linkType), version = peer.version, progressFlags = peer.progressFlags } } + end end + end + if peers then + local code = report(LB.linkupStatus(ctx, spec, peers, relay)) + LB.state = code == L.LINKUP.SUCCESS and "seat" or "off" return true end if not (live and live:isOpen()) then @@ -1718,9 +1741,19 @@ function LB.tryBattleLinkup(ctx, adapters) local mode = L.getVar(ctx, L.VAR_0x8004) LB.mode = mode LB.unionRoom = false + if mode == L.USING.BATTLE_TOWER then return LB.createLinkupTask(ctx, adapters, LB.towerSpec()) end return LB.createLinkupTask(ctx, adapters, LB.PLAYERS[mode] or LB.PLAYERS[1]) end +-- pokeemerald/src/cable_club.c:591 +function LB.towerSpec() + local okU, Util = pcall(require, "src.core.game3.rse.frontier.util") + local okD, D = pcall(require, "src.core.game3.rse.frontier.trainers") + local f = okU and Util.frontier and Util.frontier(session()) or nil + local open = okD and type(f) == "table" and D.LVL and f.lvlMode == D.LVL.OPEN + return { min = 2, max = 2, linkType = open and LB.LINKTYPE.BATTLE_TOWER_OPEN or LB.LINKTYPE.BATTLE_TOWER_50 } +end + -- pokefirered/src/script_pokemon_util.c:90 HasEnoughMonsForDoubleBattle function LB.hasEnoughMonsForDoubleBattle(ctx) local Party = require("src.core.game3.party") diff --git a/src/core/game3/link/cable_entry.lua b/src/core/game3/link/cable_entry.lua new file mode 100644 index 00000000..8ff1e663 --- /dev/null +++ b/src/core/game3/link/cable_entry.lua @@ -0,0 +1,330 @@ +local Link = require("src.core.game3.link.init") +local Strings = require("src.core.Strings") + +local CableEntry = {} + +-- pokeemerald/include/link.h:86 +CableEntry.WIRE = { + [0x1133] = "trade", + [0x2233] = "battle_single", + [0x2244] = "battle_double", + [0x2255] = "battle_multi", + [0x2266] = "battle_tower", + [0x2277] = "battle_tower_open", + [0x3311] = "record_corner", + [0x4411] = "berry_blender", + [0x5501] = "mystery_event", +} +-- pokeemerald/include/link.h:105 +CableEntry.CONTEST = { [0x6601] = true, [0x6602] = true } +-- pokeemerald/include/constants/union_room.h:73 +CableEntry.CONTEST_WIRES = { [0] = "contest_cool", "contest_beauty", "contest_cute", "contest_smart", "contest_tough" } +CableEntry.DIRECT = { + battle_single = true, battle_double = true, battle_multi = true, trade = true, + record_corner = true, berry_blender = true, contest_cool = true, contest_beauty = true, + contest_cute = true, contest_smart = true, contest_tough = true, +} +CableEntry.CONNECT_TICKS = 1200 +-- pokefirered/src/cable_club.c:483 +CableEntry.READY_TICKS = 600 +CableEntry.LOST_TICKS = 120 +CableEntry.AWAY_TICKS = 600 + +local function contestCategory(ctx) + local ok, id = pcall(function() + return require("src.core.game3.constants").active(Link.session()):var("VAR_CONTEST_CATEGORY") + end) + return Link.getVar(ctx, ok and id or 0x8011) +end + +function CableEntry.classOf(linkType) + linkType = tonumber(linkType) + if CableEntry.CONTEST[linkType] then return "contest" end + return CableEntry.WIRE[linkType] +end + +function CableEntry.wireFor(spec, ctx) + if type(spec) ~= "table" then return nil end + if spec.wire then return spec.wire end + local linkType = tonumber(spec.linkType) + if CableEntry.CONTEST[linkType] then + return CableEntry.CONTEST_WIRES[contestCategory(ctx)] + end + return CableEntry.WIRE[linkType] +end + +function CableEntry.matched(room, stale) + return type(room) == "table" and room.stage == "battling" and room.match ~= nil + and (stale == nil or room.match ~= stale) +end + +local function protocol() + return require("src.online.Protocol2") +end + +-- pokefirered/src/cable_club.c:76 +function CableEntry.run(ctx, adapters, spec, handshake) + assert(type(spec) == "table", "cable service missing") + local live = Link.link + if live and live.isOpen and live:isOpen() then return handshake(ctx, adapters) end + local wire = CableEntry.wireFor(spec, ctx) + if not wire then + Link.setResult(ctx, Link.LINKUP.FAILED) + return false, Link.LINKUP.FAILED + end + local N = require("src.core.game3.scripting.natives") + local UI = require("src.ui.game3.rs.cable_lobby") + local P = protocol() + local watchDirect = CableEntry.DIRECT[wire] == true + local ruleset = P.ACTIVITY_RULESET[wire] + local min, max = tonumber(spec.min) or 2, tonumber(spec.max) or 2 + local autoLead = min == max + local phase, ticks, done, role, pending, answered = "connect", 0, false, nil, nil, {} + local seenGroup, goneTicks, awayTicks, joining = false, 0, 0, nil + local avatar = spec.avatar or Link.avatar() + local profile = spec.profile + local room0 = Link.clientCall("room") + local stale = type(room0) == "table" and room0.match or nil + local function unwatch() + Link.clientCall("groupList", nil) + if watchDirect then Link.clientCall("directList", nil) end + end + local function finish(code) + if joining then Link.clientCall("leaveRoom") end + Link.clientCall("leaveGroup") + unwatch() + Link.closeLink("cable_entry_failed") + UI.close() + Link.setStatus("busy") + Link.setResult(ctx, code) + done = true + end + local function offline(member) + if type(member) ~= "table" then return false end + if member.online == false then return true end + for _, entry in ipairs(Link.clientCall("lobby") or {}) do + if type(entry) == "table" and entry.id ~= nil and entry.id == member.id then return entry.online == false end + end + return false + end + local function liveMembers(group) + local members = type(group) == "table" and type(group.members) == "table" and group.members or {} + local out = {} + for _, member in ipairs(members) do + if not offline(member) then out[#out + 1] = member end + end + return out, #out == #members + end + local function joinRows() + local out = {} + for _, g in ipairs(Link.clientCall("groups", wire) or {}) do + if type(g) == "table" and g.leader ~= nil then + local av = g.avatar or {} + out[#out + 1] = { kind = "group", id = g.leader, label = av.name or g.name or "", + disabled = (tonumber(g.joined) or 0) >= (tonumber(g.max) or max) } + end + end + if watchDirect then + for _, e in ipairs(Link.clientCall("directEntries", wire) or {}) do + if type(e) == "table" and e.kind == "room" and e.room ~= nil then + local av = type(e.avatar) == "table" and e.avatar or {} + out[#out + 1] = { kind = "room", id = e.room, label = av.name or e.name or "", + disabled = e.locked == true } + end + end + end + return out + end + local function rows() + if phase == "mode" then + return { { id = "leader", label = Strings("CREATE GROUP") }, { id = "join", label = Strings("JOIN GROUP") } } + end + if phase == "ask" then return { { id = "yes", label = Strings("YES") }, { id = "no", label = Strings("NO") } } end + if role == "leader" then + local out = {} + local members, all = liveMembers(Link.clientCall("group")) + for _, member in ipairs(members) do + local av = member.avatar or {} + out[#out + 1] = { label = av.name or member.name or "", disabled = true } + end + out[#out + 1] = { id = "start", label = Strings("START"), disabled = not all or #members < min or #members > max } + return out + end + if phase == "list" then return joinRows() end + return {} + end + local function select(row) + if phase == "mode" then + role, phase = row.id, "list" + if role == "leader" then + Link.clientCall("openGroup", wire, profile, avatar) + else + Link.clientCall("groupList", wire, profile) + if watchDirect then Link.clientCall("directList", wire, profile, avatar) end + end + elseif phase == "ask" then + answered[pending.id] = true + Link.clientCall("acceptGroup", pending.id, row.id == "yes") + pending, phase = nil, "list" + elseif role == "leader" and row.id == "start" then + Link.clientCall("startGroup") + phase, ticks = "starting", 0 + elseif role == "join" and row.kind == "room" then + joining = Link.clientCall("joinRoom", row.id, "player", profile, nil) + phase, ticks = "joining", 0 + elseif role == "join" then + Link.clientCall("joinGroup", row.id, profile, avatar) + phase, ticks, seenGroup, goneTicks = "waiting", 0, false, 0 + end + end + local function enter() + profile = profile or Link.liveProfile(ruleset) + Link.setStatus("idle") + phase, ticks = "mode", 0 + UI.cursor = 1 + end + local function leaderTick(g) + local requests = type(g) == "table" and type(g.pending) == "table" and g.pending or {} + if phase == "ask" then + local still = false + for _, request in ipairs(requests) do + if pending and request.id == pending.id and not offline(request) then still = true end + end + if not still then + if pending then answered[pending.id] = true end + pending, phase = nil, "list" + UI.cursor = 1 + end + elseif phase == "list" then + local members, all = liveMembers(g) + for _, request in ipairs(requests) do + if not answered[request.id] and not offline(request) then + if autoLead then + -- pokeemerald/src/cable_club.c:225 + answered[request.id] = true + Link.clientCall("acceptGroup", request.id, #members < max) + else + pending, phase = request, "ask" + end + break + end + end + if phase == "list" and autoLead and all and #members >= max then + Link.clientCall("startGroup") + phase, ticks = "starting", 0 + end + elseif phase == "starting" and type(g) == "table" then + local members, all = liveMembers(g) + if not all or #members < min then phase, ticks = "list", 0 end + end + end + local function joinTick(g) + if phase == "joining" then + local p = joining + if type(p) ~= "table" or (p.done and p.reason ~= nil) then + joining, phase = nil, "list" + UI.cursor = 1 + end + return + end + if phase ~= "waiting" then return end + if type(g) == "table" then + seenGroup, goneTicks = true, 0 + local leader + for _, member in ipairs(type(g.members) == "table" and g.members or {}) do + if member.id == g.leader then leader = member end + end + awayTicks = offline(leader) and awayTicks + 1 or 0 + if awayTicks > CableEntry.AWAY_TICKS then return finish(Link.LINKUP.CONNECTION_ERROR) end + ticks = 0 + elseif seenGroup then + goneTicks = goneTicks + 1 + if goneTicks > CableEntry.LOST_TICKS then + phase, seenGroup, goneTicks = "list", false, 0 + UI.cursor = 1 + end + end + end + if not Link.online() then + local ok = (spec.connect or Link.connect)() + if not ok then + finish(Link.LINKUP.CONNECTION_ERROR) + return false, Link.LINKUP.CONNECTION_ERROR + end + else + enter() + end + N.yieldHost(ctx, adapters, function() end) + Link.setResult(ctx, Link.LINKUP.ONGOING) + local poll + poll = function() + if done then return true end + ticks = ticks + 1 + if phase == "connect" then + if Link.online() then + enter() + elseif ticks > CableEntry.CONNECT_TICKS or Link.connectState() == "error" then + finish(Link.LINKUP.CONNECTION_ERROR) + end + return done + end + if not Link.online() then finish(Link.LINKUP.CONNECTION_ERROR); return true end + if phase == "ready" then + local lk = Link.link + if not (lk and lk:isOpen()) then finish(Link.LINKUP.CONNECTION_ERROR); return true end + if lk:isReady() then + UI.close() + Link.setStatus("busy") + local yielded, value = handshake(ctx, adapters) + if not yielded then + Link.setResult(ctx, value or Link.getVar(ctx, Link.VAR_RESULT)) + return true + end + return false + elseif ticks > CableEntry.READY_TICKS then + finish(Link.LINKUP.CONNECTION_ERROR) + return true + end + return false + end + if CableEntry.matched(Link.clientCall("room"), stale) then + UI.close() + unwatch() + joining = nil + if not Link.openRelay({ linkType = spec.linkType, game = spec.game, hello = spec.hello }) then + finish(Link.LINKUP.CONNECTION_ERROR) + return true + end + phase, ticks = "ready", 0 + return false + end + local g = Link.clientCall("group") + if role == "leader" then leaderTick(g) elseif role == "join" then joinTick(g) end + if not done and phase == "starting" and ticks > CableEntry.CONNECT_TICKS then + finish(Link.LINKUP.CONNECTION_ERROR) + end + return done + end + ctx.nativePoll = poll + UI.show({ + rows = rows, + select = select, + tick = function() if ctx.nativePoll == poll then poll() end end, + title = function() + if phase == "ask" then return Strings("ADD %s?", (pending.avatar or {}).name or pending.name or "") end + return Strings((wire:upper():gsub("_", " "))) + end, + cancel = function() + if phase == "waiting" or phase == "joining" then + if phase == "joining" then Link.clientCall("leaveRoom") else Link.clientCall("leaveGroup") end + joining, phase = nil, "list" + UI.cursor = 1 + return + end + finish(Link.LINKUP.FAILED) + end, + }) + return true +end + +return CableEntry diff --git a/src/core/game3/link/contest_link.lua b/src/core/game3/link/contest_link.lua index b298ea85..8e9606d4 100644 --- a/src/core/game3/link/contest_link.lua +++ b/src/core/game3/link/contest_link.lua @@ -38,6 +38,18 @@ local function linkSeat(link) return tonumber(link.seat) or 0 end +-- pokeemerald/src/contest_link.c:88 +function ContestLink.flagsFor(link, wireless) + local flags = ContestLink.FLAG.IS_LINK + (wireless and ContestLink.FLAG.IS_WIRELESS or 0) + local ok, players = false, nil + if type(link) == "table" and type(link.players) == "function" then ok, players = pcall(link.players, link) end + local Rs = require("src.core.game3.link.rs") + for _, p in ipairs(ok and type(players) == "table" and players or {}) do + if type(p) == "table" and Rs.is(p.version) then return flags + ContestLink.FLAG.HAS_RS_PLAYER end + end + return flags +end + local function linkCount(link) if type(link.players) == "function" then local ok, list = pcall(link.players, link) @@ -153,8 +165,14 @@ function Session:fromLeader(key, data) return self:waiting(key) end +function Session:hasRsPlayer() + return math.floor(self.flags / ContestLink.FLAG.HAS_RS_PLAYER) % 2 == 1 +end + -- pokeemerald/src/contest_util.c:2718 function Session:standby() + -- pokeemerald/src/contest_link.c:100 + if self:hasRsPlayer() then return true end if not self.standbyKey then self.standbyN = self.standbyN + 1 self.standbyKey = "standby:" .. self.standbyN diff --git a/src/core/game3/link/link_players.lua b/src/core/game3/link/link_players.lua index 54235d90..5f62f7a9 100644 --- a/src/core/game3/link/link_players.lua +++ b/src/core/game3/link/link_players.lua @@ -225,9 +225,24 @@ local function spawnAll(lk, own) snap(r, M._base.x + seat, M._base.y) M._remotes[seat] = r end + local r = M._remotes[seat] + if r then r.name = type(player.name) == "string" and player.name or r.name end end end +local BUSY_KIND = { tradeCenter = "trade", colosseum2P = "battle", colosseum4P = "battle", recordCorner = "busy" } + +function M.statusKind(seat) + local r = M._remotes[seat] + if not (r and r.busy) then return nil end + return BUSY_KIND[M._mapId] or "busy" +end + +function M.nameOf(seat) + local r = M._remotes[seat] + return r and r.name or nil +end + function M.update() local lk = liveLink() if not lk then @@ -254,6 +269,7 @@ function M.update() end if warping then return true end M._mapId = mapId + if not package.loaded["src.ui.game3.link_tags"] then pcall(require, "src.ui.game3.link_tags") end spawnAll(lk, own) sendLocal(lk, mapId) for seat, r in pairs(M._remotes) do diff --git a/src/core/game3/link/plaza_wander.lua b/src/core/game3/link/plaza_wander.lua new file mode 100644 index 00000000..701a2dd7 --- /dev/null +++ b/src/core/game3/link/plaza_wander.lua @@ -0,0 +1,171 @@ +local Wander = {} + +Wander.CELL = 16 +Wander.STEP_FRAMES = 32 +Wander.FIRST_REST = 360 +Wander.REST_MIN, Wander.REST_MAX = 240, 600 +Wander.PAUSE_MIN, Wander.PAUSE_MAX = 40, 120 +Wander.MAX_STEPS = 3 +Wander.RADIUS = 1 +Wander.GIVE_UP = 240 + +local ORDER = { "down", "up", "left", "right" } +local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } +local OPPOSITE = { down = "up", up = "down", left = "right", right = "left" } + +Wander.DELTA = DELTA +Wander.OPPOSITE = OPPOSITE + +local function nextRand(w) + w.seed = (w.seed * 16807) % 2147483647 + return w.seed +end + +local function randRange(w, lo, hi) + return lo + nextRand(w) % (hi - lo + 1) +end + +function Wander.new(seed, homeX, homeY) + local s = math.floor(math.abs(tonumber(seed) or 1)) % 2147483647 + if s == 0 then s = 1 end + local w = { + seed = s, homeX = homeX, homeY = homeY, x = homeX, y = homeY, + prevX = homeX, prevY = homeY, path = {}, mode = "rest", want = 0, + moving = false, progress = 0, animClock = 0, stepFlip = false, blocked = 0, + facing = false, px = false, py = false, timer = 0, + } + w.timer = Wander.FIRST_REST + randRange(w, 0, Wander.REST_MAX - Wander.REST_MIN) + return w +end + +function Wander.atHome(w) + return w ~= nil and not w.moving and w.x == w.homeX and w.y == w.homeY +end + +function Wander.away(w) + return w ~= nil and not Wander.atHome(w) +end + +local function inRange(w, x, y) + return math.abs(x - w.homeX) <= Wander.RADIUS and math.abs(y - w.homeY) <= Wander.RADIUS +end + +local function beginStep(w, dir) + local d = DELTA[dir] + w.prevX, w.prevY = w.x, w.y + w.x, w.y = w.x + d[1], w.y + d[2] + w.facing = dir + w.moving, w.progress, w.animClock = true, 0, 0 + w.px, w.py = w.prevX * Wander.CELL, w.prevY * Wander.CELL +end + +local function advance(w) + w.progress = w.progress + 1 + w.animClock = w.animClock + 1 + local off = math.floor(Wander.CELL * math.min(w.progress, Wander.STEP_FRAMES) / Wander.STEP_FRAMES) + w.px = w.prevX * Wander.CELL + (w.x - w.prevX) * off + w.py = w.prevY * Wander.CELL + (w.y - w.prevY) * off + if w.progress >= Wander.STEP_FRAMES then + w.moving, w.progress, w.px, w.py = false, 0, false, false + w.prevX, w.prevY = w.x, w.y + w.stepFlip = not w.stepFlip + end +end + +local function clearPath(w) + for i = #w.path, 1, -1 do w.path[i] = nil end +end + +local function rest(w) + clearPath(w) + w.mode, w.want, w.blocked = "rest", 0, 0 + w.facing = false + w.timer = randRange(w, Wander.REST_MIN, Wander.REST_MAX) +end + +local function pickStep(w, free) + local last = w.path[#w.path] + local start = randRange(w, 1, #ORDER) + for i = 0, #ORDER - 1 do + local dir = ORDER[(start + i - 1) % #ORDER + 1] + if dir ~= (last and OPPOSITE[last]) then + local d = DELTA[dir] + local tx, ty = w.x + d[1], w.y + d[2] + if inRange(w, tx, ty) and free(tx, ty, w.x, w.y, dir) then return dir end + end + end + return nil +end + +function Wander.tick(w, env) + if w.moving then + advance(w) + return true + end + env = env or {} + if env.frozen then return false end + local free = env.free or function() return false end + if not env.canWander and w.mode ~= "rest" then w.mode = "back" end + if w.mode == "rest" then + if not Wander.atHome(w) then + w.mode = "back" + return false + end + if not env.canWander then return false end + w.timer = w.timer - 1 + if w.timer > 0 then return false end + clearPath(w) + w.mode, w.want = "out", randRange(w, 1, Wander.MAX_STEPS) + end + if w.mode == "out" then + local dir = #w.path < w.want and pickStep(w, free) or nil + if not dir and #w.path == 0 then + rest(w) + return false + end + if not dir then + w.mode = "pause" + w.timer = randRange(w, Wander.PAUSE_MIN, Wander.PAUSE_MAX) + local turn = ORDER[randRange(w, 1, #ORDER)] + if turn ~= w.facing then + w.facing = turn + return true + end + return false + end + w.path[#w.path + 1] = dir + beginStep(w, dir) + return true + end + if w.mode == "pause" then + w.timer = w.timer - 1 + if w.timer <= 0 then w.mode = "back" end + return false + end + local last = w.path[#w.path] + if not last then + if w.x ~= w.homeX or w.y ~= w.homeY then + if not free(w.homeX, w.homeY) then return false end + w.x, w.y, w.prevX, w.prevY = w.homeX, w.homeY, w.homeX, w.homeY + end + rest(w) + return true + end + local dir = OPPOSITE[last] + local d = DELTA[dir] + if free(w.x + d[1], w.y + d[2], w.x, w.y, dir) then + w.path[#w.path] = nil + w.blocked = 0 + beginStep(w, dir) + return true + end + w.blocked = w.blocked + 1 + if w.blocked >= Wander.GIVE_UP and free(w.homeX, w.homeY) then + w.x, w.y, w.prevX, w.prevY = w.homeX, w.homeY, w.homeX, w.homeY + rest(w) + return true + end + return false +end + +return Wander diff --git a/src/core/game3/link/record_mix.lua b/src/core/game3/link/record_mix.lua index 5d7ccfd9..eb50404c 100644 --- a/src/core/game3/link/record_mix.lua +++ b/src/core/game3/link/record_mix.lua @@ -58,8 +58,28 @@ local function deep(v) return out end +RecordMix.BYTES_KEY = "$b" +RecordMix.BYTES_MIN, RecordMix.BYTES_MAX = 16, 256 + +local function byteRun(v) + local n = #v + if n < RecordMix.BYTES_MIN or n > RecordMix.BYTES_MAX then return nil end + local count = 0 + for _ in pairs(v) do count = count + 1 end + if count ~= n then return nil end + local hex = {} + for i = 1, n do + local b = v[i] + if type(b) ~= "number" or b < 0 or b > 255 or b % 1 ~= 0 then return nil end + hex[i] = string.format("%02x", b) + end + return table.concat(hex) +end + function RecordMix.toWire(v) if type(v) ~= "table" then return v end + local hex = byteRun(v) + if hex then return { [RecordMix.BYTES_KEY] = hex } end local out = {} for k, x in pairs(v) do if type(k) == "number" then out["#" .. tostring(k)] = RecordMix.toWire(x) @@ -70,6 +90,12 @@ end function RecordMix.fromWire(v) if type(v) ~= "table" then return v end + local hex = v[RecordMix.BYTES_KEY] + if type(hex) == "string" and next(v, next(v)) == nil and #hex % 2 == 0 and not hex:find("[^%x]") then + local out = {} + for i = 1, #hex / 2 do out[i] = tonumber(hex:sub(i * 2 - 1, i * 2), 16) end + return out + end local out = {} for k, x in pairs(v) do local n = type(k) == "string" and tonumber(k:match("^#(%-?%d+)$")) or nil diff --git a/src/core/game3/link/union_room.lua b/src/core/game3/link/union_room.lua index f5668258..0f2de809 100644 --- a/src/core/game3/link/union_room.lua +++ b/src/core/game3/link/union_room.lua @@ -30,8 +30,6 @@ Union.LINK_GROUP = { POKEMON_JUMP = 4, BERRY_CRUSH = 5, BERRY_PICKING = 6, - WONDER_CARD = 7, - WONDER_NEWS = 8, UNION_ROOM_RESUME = 9, UNION_ROOM_INIT = 10, -- pokeemerald/include/constants/union_room.h:70 @@ -77,15 +75,20 @@ Union.INVITE_ITEMS = { { key = "EXIT", activity = Union.ACTIVITY.NONE, union = true }, } --- pokefirered/src/data/union_room.h:1 sLinkGroupActivityNameTexts -Union.ACTIVITY_NAMES = RomText.lazy({ - [1] = "sLinkGroupActivityNameTexts[1]", - [2] = "sLinkGroupActivityNameTexts[2]", - [3] = "sLinkGroupActivityNameTexts[3]", - [4] = "sLinkGroupActivityNameTexts[4]", - [5] = "sLinkGroupActivityNameTexts[5]", - [8] = "sLinkGroupActivityNameTexts[8]", - [12] = "sLinkGroupActivityNameTexts[12]", +Union.ACTIVITY_NAME_IDS = { + -- pokefirered/src/data/union_room.h:1 + frlg = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 21, 22 }, + -- pokeemerald/src/data/union_room.h:592 + rse = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 22, 23, 24, 25, 26, 27, 28 }, +} +Union.ACTIVITY_NAMES = setmetatable({}, { + __index = function(_, raw) + raw = tonumber(raw) + for _, id in ipairs(Union.ACTIVITY_NAME_IDS[Family.of()] or {}) do + if id == raw then return RomText.plain(RomText.key("sLinkGroupActivityNameTexts", id)) end + end + return nil + end, }) Union.AVATARS_FILE = "data/generated/gba/union_room/avatars.lua" @@ -395,10 +398,113 @@ function Union.retryWaitingAvatars() Union._avatarWaiting = waiting end +Union.WANDER = true +Union.WANDER_NEAR_PLAYER = 2 + +local function wanderMod() + return require("src.core.game3.link.plaza_wander") +end + +local STATUS_KIND = { chatting = "chat", trading = "trade", battling = "battle" } + +function Union.memberStatusKind(p) + if type(p) ~= "table" then return nil end + local kind = STATUS_KIND[p.status] + if kind or p.status ~= nil then return kind end + local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM + local A = Union.ACTIVITY + if raw == A.CHAT or raw == A.CARD then return "chat" end + if raw == A.TRADE or raw == A.SPIN_TRADE then return "trade" end + if raw == A.BATTLE_SINGLE or raw == A.BATTLE_DOUBLE or raw == A.BATTLE_MULTI then return "battle" end + return nil +end + +function Union.memberIdle(p) + if type(p) ~= "table" or p.gone then return false end + if p.status ~= nil and p.status ~= "idle" then return false end + return idleActivity(p.activity) +end + +local ENTER_OPTS = { surfing = false, elevation = 3 } + +function Union.wanderWalkable(x, y, fromX, fromY, dir) + local Coll = package.loaded["src.core.game3.collision"] + if not (Coll and Coll._mapId == plazaMap().MAP_ID and Coll.canEnter) then return false end + if Coll.inBounds and not Coll.inBounds(x, y) then return false end + if Coll.isWater and Coll.isWater(x, y) then return false end + ENTER_OPTS.fromX, ENTER_OPTS.fromY, ENTER_OPTS.dir = fromX, fromY, dir + local ok = Coll.canEnter(link().game(), x, y, ENTER_OPTS) + return ok == true +end + +local function wanderCellFree(x, y, fromX, fromY, dir) + if playerOn(x, y) then return false end + local Obj = objects() + if Obj and Obj.blocks and Obj.blocks(x, y) then return false end + return Union.wanderWalkable(x, y, fromX or x, fromY or y, dir) +end + +local function playerNear(x, y) + local P = playerMod() + if not P then return false end + local near = Union.WANDER_NEAR_PLAYER + local px, py = tonumber(P.cellX), tonumber(P.cellY) + if px and py and math.abs(px - x) <= near and math.abs(py - y) <= near then return true end + if not P.moving then return false end + px, py = tonumber(P.targetX), tonumber(P.targetY) + return px ~= nil and py ~= nil and math.abs(px - x) <= near and math.abs(py - y) <= near +end + +local WANDER_ENV = { free = wanderCellFree } + +function Union.tickWander() + local dirty = false + local onPlaza = Union.WANDER and link().currentMap() == plazaMap().MAP_ID + for slot = 1, Union.capacity() do + local v = Union.vobj(slot) + local p = Union.players[slot] + if v and v.visible then + local w = v.wander + if not w or w.homeX ~= v.x or w.homeY ~= v.y then + w = wanderMod().new(slot * 7919 + (tonumber(p and p.trainerId) or 0) + 1, v.x, v.y) + v.wander = w + end + if v.anim == nil then + WANDER_ENV.frozen = Union._talkSlot == slot or Union.partnerId == slot + WANDER_ENV.canWander = onPlaza and Union.memberIdle(p) and not playerNear(v.x, v.y) + if wanderMod().tick(w, WANDER_ENV) then dirty = true end + end + elseif v then + v.wander = nil + end + end + return dirty +end + +function Union.avatarCell(slot) + local v = Union.vobj(slot) + if not v then return nil end + local w = v.wander + if w then return w.x, w.y, w.moving end + return v.x, v.y, false +end + +function Union.slotAtCell(x, y) + for slot = 1, Union.capacity() do + local v = Union.vobj(slot) + if v and v.visible then + local cx, cy, moving = Union.avatarCell(slot) + if cx == x and cy == y and not moving then return slot end + end + end + return nil +end + -- pokefirered/src/event_object_movement.c:9354 function Union.animateVobjs() local dirty = Union._vobjDirty local vobjs = Union._vobjs + if Union.tickWander() then dirty = true end for _, v in pairs(vobjs) do if v.anim == "in" then v.y2 = math.min(0, v.y2 + Union.FLY_STEP) @@ -431,9 +537,22 @@ function Union.animateVobjs() if v.visible then local rec = VirtualObjects.get(id) or VirtualObjects.spawn(id, v.gfx, v.x, v.y, 3, v.dir) if rec then + local w = v.wander rec.graphicsId = v.gfx rec.x, rec.y = v.x, v.y rec.direction = v.dir + rec.prevX, rec.prevY, rec.px, rec.py, rec.moving = nil, nil, nil, nil, nil + if w then + rec.x, rec.y = w.x, w.y + if w.facing and Union._talkSlot ~= v.slot then rec.direction = Union.FACE_DIR[w.facing] or v.dir end + if w.moving then + rec.prevX, rec.prevY, rec.px, rec.py, rec.moving = w.prevX, w.prevY, w.px, w.py, true + rec.targetX, rec.targetY = w.x, w.y + else + rec.targetX, rec.targetY = nil, nil + end + rec.animClock, rec.stepFrames, rec.stepFlip = w.animClock, wanderMod().STEP_FRAMES, w.stepFlip + end rec.y2 = v.y2 rec.solid = v.anim ~= "out" end @@ -469,7 +588,7 @@ function Union.tryInteractWithMember() local d = P and FACING_DELTA[P.facing or "down"] if not d then return nil end local fx, fy = (tonumber(P.cellX) or 0) + d[1], (tonumber(P.cellY) or 0) + d[2] - local slot = plazaMap().slotAt(fx, fy) + local slot = Union.slotAtCell(fx, fy) if not slot then return nil end local p = Union.players[slot] local v = Union.vobj(slot) @@ -1603,6 +1722,7 @@ end function Union.relayTick(_dt) local L = link() + if not package.loaded["src.ui.game3.link_tags"] then pcall(require, "src.ui.game3.link_tags") end if not L.online() then Union.plazaOffline() Union.animateAll() @@ -2583,6 +2703,8 @@ function Union.roomPartnerAvatar(room) return nil end +Union.GROUP_JOIN_TICKS = 600 + function Union.directRows(wire) local out = {} for _, e in ipairs(link().clientCall("directEntries", wire) or {}) do @@ -2596,6 +2718,11 @@ function Union.directRows(wire) out[#out + 1] = { key = "p:" .. tostring(e.id), kind = "player", id = e.id, name = av.name or e.name, trainerId = av.trainerId, gender = av.gender, version = av.version, canLinkNationally = av.canLinkNationally == true } + elseif e.kind == "group" and e.leader ~= nil + and (tonumber(e.joined) or 0) < (tonumber(e.max) or 2) then + out[#out + 1] = { key = "g:" .. tostring(e.leader), kind = "group", id = e.leader, leader = e.leader, + name = av.name or e.name, trainerId = av.trainerId, gender = av.gender, + version = av.version or e.version, canLinkNationally = av.canLinkNationally == true } end end end @@ -2698,6 +2825,11 @@ function Union.chooseDirect(ctx, adapters, group, _direct) if D then D.setFrozen(true) end st.handle = L.clientCall("invite", row.id, wire, { ruleset = ruleset }, profile) if M then M.show(Union.askedToJoinText(wire, row.name), { stay = true }) end + elseif row.kind == "group" then + st.phase, st.row, st.sawGroup, st.waited = "grouping", row, false, 0 + if D then D.setFrozen(true) end + L.clientCall("joinGroup", row.leader, profile, L.avatar()) + if M then M.show(Union.askedToJoinText(wire, row.name), { stay = true }) end elseif row.locked then askPin(row) else @@ -2723,6 +2855,15 @@ function Union.chooseDirect(ctx, adapters, group, _direct) local row = st.row or {} notice(h.why == "declined" and Union.rejectText(Union.GROUP_ACTIVITY[group].activity, row.gender) or busy) end + elseif st.phase == "grouping" then + local g = L.clientCall("group") + st.waited = st.waited + 1 + if type(g) == "table" and g.leader == st.row.leader then + st.sawGroup = true + elseif st.sawGroup or st.waited > Union.GROUP_JOIN_TICKS then + L.clientCall("leaveGroup") + notice(st.sawGroup and Union.rejectText(Union.GROUP_ACTIVITY[group].activity, st.row.gender) or busy) + end elseif st.phase == "joining" then local p = st.pending if type(p) ~= "table" then @@ -2806,7 +2947,10 @@ function Union.directFlow(ctx, adapters, group, role) L.setStatus("idle") local _, _, linkType = Union.directDest(group) return Union.waitForMatch(ctx, adapters, { - cancel = function() L.clientCall("leaveDirect") end, + cancel = function() + L.clientCall("leaveDirect") + if type(L.clientCall("group")) == "table" then L.clientCall("leaveGroup") end + end, refuse = function(room) if wire ~= "trade" then return nil end local ready = Union.tradeReadyWith(Union.roomPartnerAvatar(room)) diff --git a/src/core/game3/mystery_gift.lua b/src/core/game3/mystery_gift.lua index 8818012f..f960ab7a 100644 --- a/src/core/game3/mystery_gift.lua +++ b/src/core/game3/mystery_gift.lua @@ -238,6 +238,27 @@ local function copyFlagList(list, version, bad) return out end +MysteryGift.VERSIONS = { "firered", "leafgreen", "ruby", "sapphire", "emerald" } +MysteryGift.FAMILY_VERSIONS = { frlg = { "firered", "leafgreen" }, rse = { "emerald" }, rs = { "ruby", "sapphire" } } + +local function copyVersions(list) + if type(list) ~= "table" then return nil end + local known, out = {}, {} + for _, v in ipairs(MysteryGift.VERSIONS) do known[v] = true end + for _, v in ipairs(list) do + if known[v] then out[#out + 1] = v end + end + return out +end + +function MysteryGift.offeredIn(list, version) + if type(list) ~= "table" or version == nil then return true end + for _, v in ipairs(list) do + if v == version then return true end + end + return false +end + local function resolved(version, kind, value, bad) if value == nil then return nil end local n = MysteryGift.resolveId(version, kind, value) @@ -289,8 +310,22 @@ function MysteryGift.normalizeCard(card, version) requireStat = tonumber(gift.requireStat), requireValue = tonumber(gift.requireValue), script = type(gift.script) == "string" and gift.script:match("^[%w_]+$") and gift.script or nil, + metLocation = tonumber(gift.metLocation), + metLevel = tonumber(gift.metLevel), + metGame = tonumber(gift.metGame), + ball = tonumber(gift.ball), + language = tonumber(gift.language), + otNames = type(gift.otNames) == "table" and copyList(gift.otNames, math.min(#gift.otNames, 6), 7) or nil, + otIdMax = tonumber(gift.otIdMax), + secretIdRandom = gift.secretIdRandom == true or nil, + otGender = tonumber(gift.otGender), + shiny = (gift.shiny == "never" or gift.shiny == "always" or gift.shiny == "random") and gift.shiny or nil, + nationalRibbon = gift.nationalRibbon == true or nil, + movesOnly = gift.movesOnly == true or nil, }, + versions = copyVersions(card.versions), } + if gift.fateful == false then out.gift.fateful = false end if bad.any then return nil, "unresolved" end return out end @@ -331,12 +366,19 @@ local function cardBytes(card) tostring(gift.requireStat or 0), tostring(gift.requireValue or 0), } if gift.script then parts[#parts + 1] = "R" .. tostring(gift.script) end + for _, k in ipairs({ "metLocation", "metLevel", "metGame", "ball", "language", "otIdMax", "secretIdRandom", + "otGender", "shiny", "nationalRibbon", "movesOnly" }) do + if gift[k] ~= nil then parts[#parts + 1] = k .. "=" .. tostring(gift[k]) end + end + if gift.fateful == false then parts[#parts + 1] = "fateful=false" end + for _, n in ipairs(type(gift.otNames) == "table" and gift.otNames or {}) do parts[#parts + 1] = "O" .. n end for _, slot in ipairs(moveKeys) do parts[#parts + 1] = tostring(slot) .. "=" .. tostring(gift.moves[slot]) end for _, id in ipairs(gift.setFlags or {}) do parts[#parts + 1] = "S" .. tostring(id) end for _, id in ipairs(gift.haveFlags or {}) do parts[#parts + 1] = "H" .. tostring(id) end if gift.heldItem then parts[#parts + 1] = "I" .. tostring(gift.heldItem) end + for _, v in ipairs(type(card.versions) == "table" and card.versions or {}) do parts[#parts + 1] = "V" .. tostring(v) end return table.concat(parts, "\2") end @@ -651,20 +693,6 @@ function MysteryGift.isNewsSameAsSaved(session, news) return newsBytes(MysteryGift.ensure(session).news) == newsBytes(other) end --- pokefirered/src/mystery_gift.c:120 IsSendingSavedWonderNewsAllowed -function MysteryGift.isSendingNewsAllowed(session) - local news = MysteryGift.ensure(session).news - if not news then return false end - return num(news.sendType) ~= MysteryGift.SEND_TYPE_DISALLOWED -end - --- pokefirered/src/mystery_gift.c:208 IsSendingSavedWonderCardAllowed -function MysteryGift.isSendingCardAllowed(session) - local card = MysteryGift.ensure(session).card - if not card then return false end - return num(card.sendType) ~= MysteryGift.SEND_TYPE_DISALLOWED -end - -- pokefirered/src/mystery_gift.c:235 DisableWonderCardSending function MysteryGift.disableCardSending(card) if type(card) == "table" and num(card.sendType) == MysteryGift.SEND_TYPE_ALLOWED then @@ -694,8 +722,30 @@ function MysteryGift.receivedGiftFlag(flagId, session) return first + (flagId - MysteryGift.WONDER_CARD_FLAG_OFFSET) end +local function tracksById(card) + local gift = type(card) == "table" and type(card.gift) == "table" and card.gift or {} + return (gift.kind == "mon" or gift.kind == "egg") and not gift.doneFlag +end + +local function deliveredIds(session) + local rec = MysteryGift.ensure(session) + rec.deliveredIds = type(rec.deliveredIds) == "table" and rec.deliveredIds or {} + return rec.deliveredIds +end + +function MysteryGift.wasDelivered(session, card) + local id = num(type(card) == "table" and card.idNumber) + for _, v in ipairs(deliveredIds(session)) do + if num(v) == id then return true end + end + return false +end + -- pokefirered/src/mystery_gift.c:248 IsSavedWonderCardGiftNotReceived function MysteryGift.isGiftNotReceived(session) + if MysteryGift.validateSavedCard(session) and tracksById(MysteryGift.ensure(session).card) then + return not MysteryGift.wasDelivered(session, MysteryGift.ensure(session).card) + end local flagId = MysteryGift.getCardFlagId(session) local giftFlag = MysteryGift.receivedGiftFlag(flagId, session) if not giftFlag then return false end @@ -954,13 +1004,22 @@ function MysteryGift.getNewsRewardInfo(session) return rewardType, item end +-- pokefirered/src/mystery_gift_client.c:210 CLI_SAVE_NEWS +function MysteryGift.saveNewsIfNew(session, news) + if MysteryGift.isNewsSameAsSaved(session, news) then return false, "had" end + if not MysteryGift.saveNews(session, news) then return false, "invalid" end + return true +end + -- pokefirered/src/mystery_gift_client.c:213 function MysteryGift.receiveNews(session, news, newsType) - if not MysteryGift.saveNews(session, news) then return false end + local ok, why = MysteryGift.saveNewsIfNew(session, news) + if not ok then return false, why end MysteryGift.setNewsReward(session, newsType or MysteryGift.WONDER_NEWS_RECV_WIRELESS) return true end + -- pokefirered/data/mystery_event_msg.s:69 SurfPichu_GiveEgg local function createEventMon(session, gift) local Party = require("src.core.game3.party") @@ -980,8 +1039,43 @@ local function createEventMon(session, gift) ok, code, mon = Party.giveMon(session, species, level, gift.nickname) end if not (ok and mon) then return nil, code end - if gift.personality then - mon.personality = num(gift.personality) + local Rng = require("src.core.game3.rng") + local otName = gift.otName + if type(gift.otNames) == "table" and #gift.otNames > 0 then + otName = gift.otNames[(Rng.Random() % #gift.otNames) + 1] + end + if otName then + mon.ot = otName + mon.otName = otName + end + if gift.otIdMax then + mon.otId = (Rng.Random() % num(gift.otIdMax)) + 1 + mon.otSecretId = 0 + elseif gift.otId then + mon.otId = num(gift.otId) % 65536 + mon.otSecretId = math.floor(num(gift.otId) / 65536) % 65536 + end + if gift.secretIdRandom then mon.otSecretId = Rng.Random() end + if gift.otGender == 2 then mon.otGender = Rng.Random() % 2 + elseif gift.otGender then mon.otGender = gift.otGender end + local personality = gift.personality and num(gift.personality) or mon.personality + -- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality + local function shiny(p) + local bit = require("bit") + local tid, sid = num(mon.otId) % 65536, num(mon.otSecretId) % 65536 + return bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) < 8 + end + if not gift.personality and gift.shiny == "never" then + while shiny(personality) do personality = Rng.Random32() % 0x100000000 end + elseif not gift.personality and gift.shiny == "always" and not shiny(personality) then + local bit = require("bit") + local hi = math.floor(personality / 65536) + local lo = bit.band(bit.bxor(bit.bxor(num(mon.otId) % 65536, num(mon.otSecretId) % 65536), hi), 0xFFF8) + + personality % 8 + personality = hi * 65536 + lo + end + if personality ~= mon.personality then + mon.personality = personality if Pokemon.natureId then mon.nature = Pokemon.natureId(mon.personality) end if Pokemon.abilityId then mon.ability = Pokemon.abilityId(species, mon.personality) @@ -990,25 +1084,24 @@ local function createEventMon(session, gift) if Pokemon.gender then mon.gender = Pokemon.gender(species, mon.personality) end if Pokemon.applyStats then Pokemon.applyStats(mon) end end - if gift.otName then - mon.ot = gift.otName - mon.otName = gift.otName - end - if gift.otId then - -- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality - mon.otId = num(gift.otId) % 65536 - mon.otSecretId = math.floor(num(gift.otId) / 65536) % 65536 - end if gift.heldItem and num(gift.heldItem) > 0 then mon.item, mon.heldItem = num(gift.heldItem), num(gift.heldItem) end -- pokefirered/src/scrcmd.c:2244 ScrCmd_setmonmodernfatefulencounter - mon.modernFatefulEncounter = true + mon.modernFatefulEncounter = gift.fateful ~= false + if gift.nationalRibbon then + require("src.core.game3.rse.ribbons").set(mon, "national", 1) + end -- pokefirered/data/mystery_event_msg.s:72 setmonmetlocation METLOC_FATEFUL_ENCOUNTER - mon.metLocation = METLOC_FATEFUL_ENCOUNTER + mon.metLocation = gift.metLocation or METLOC_FATEFUL_ENCOUNTER + if gift.metLevel then mon.metLevel = gift.metLevel end + if gift.metGame then mon.metGame = gift.metGame end + if gift.ball then mon.pokeball = gift.ball end + if gift.language then mon.language = gift.language end mon.moves = mon.moves or {} mon.pp = mon.pp or {} mon.maxPp = mon.maxPp or {} + if gift.movesOnly then mon.moves, mon.pp, mon.maxPp = {}, {}, {} end for slot, move in pairs(gift.moves or {}) do mon.moves[slot] = move local pp = Pokemon.movePp and Pokemon.movePp(move) or nil @@ -1034,12 +1127,14 @@ function MysteryGift.deliverGift(session, card) card = card or MysteryGift.getSavedCard(session) if type(card) ~= "table" then return MysteryGift.DELIVER_NOTHING end local gift = type(card.gift) == "table" and card.gift or {} - local receivedFlag = MysteryGift.receivedGiftFlag(card.flagId, session) + local byId = tracksById(card) + local receivedFlag = not byId and MysteryGift.receivedGiftFlag(card.flagId, session) or nil -- pokefirered/src/mystery_gift.c:248 IsSavedWonderCardGiftNotReceived if receivedFlag and getFlag(session, receivedFlag) then return MysteryGift.DELIVER_ALREADY end + if byId and MysteryGift.wasDelivered(session, card) then return MysteryGift.DELIVER_ALREADY end if gift.doneFlag and getFlag(session, gift.doneFlag) then return MysteryGift.DELIVER_ALREADY end @@ -1085,6 +1180,7 @@ function MysteryGift.deliverGift(session, card) end if gift.doneFlag then setFlag(session, gift.doneFlag, true) end if receivedFlag then setFlag(session, receivedFlag, true) end + if byId then table.insert(deliveredIds(session), num(card.idNumber)) end return MysteryGift.DELIVER_GIVEN end @@ -1229,6 +1325,7 @@ function MysteryGift.builtins(family) Strings("It is for use at VERMILION CITY port.")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, gift = { kind = "item", item = MysteryGift.ITEM_MYSTIC_TICKET, @@ -1261,6 +1358,7 @@ function MysteryGift.builtins(family) Strings("It is for use at VERMILION CITY port.")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, gift = { kind = "item", item = MysteryGift.ITEM_AURORA_TICKET, @@ -1294,6 +1392,7 @@ function MysteryGift.builtins(family) Strings("kindness.")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, gift = { kind = "egg", species = 172, @@ -1326,6 +1425,7 @@ function MysteryGift.builtins(family) Strings("your STAMP CARD.")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, gift = { kind = "none" }, }, }, @@ -1350,6 +1450,7 @@ function MysteryGift.builtins(family) Strings("Look for them and battle!")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, -- pokefirered/data/mystery_event_msg.s:173 giveitem ITEM_POTION gift = { kind = "item", @@ -1382,6 +1483,7 @@ function MysteryGift.builtins(family) Strings("on SIX ISLAND.")), footerLine1Text = Strings("Speak to the deliveryman"), footerLine2Text = Strings("at a POKéMON CENTER."), + versions = { "firered", "leafgreen" }, -- pokefirered/data/mystery_event_msg.s:325 gift = { kind = "var", @@ -1403,6 +1505,7 @@ local function rseCard(key, label, t) t.subtitleText = t.subtitleText or Strings("MYSTERY GIFT") t.footerLine1Text = t.footerLine1Text or Strings("Speak to the deliveryman") t.footerLine2Text = t.footerLine2Text or Strings("at a POKéMON CENTER.") + t.versions = t.versions or { "emerald" } return { key = key, family = "rse", label = label, card = t } end @@ -1579,10 +1682,60 @@ end local function familyVersion(family) if family == MysteryGift.familyOf(nil) then return versionOf(nil) end - return family == "rse" and "emerald" or "firered" + return MysteryGift.FAMILY_VERSIONS[family] and MysteryGift.FAMILY_VERSIONS[family][1] or "firered" end -function MysteryGift.parseFeed(payload, family) +-- pokeruby/src/mystery_event_script.c:46 CheckCompatibility +function MysteryGift.eventHeaderOk(bytes, version) + local Event = require("src.core.game3.rs.mystery_event") + if type(bytes) ~= "string" or #bytes < 17 or #bytes > Event.MAX_BYTES or bytes:byte(1) ~= 1 then return false end + local bit = require("bit") + local function u16(at) return bytes:byte(at) + bytes:byte(at + 1) * 256 end + local function u32(at) return u16(at) + u16(at + 2) * 65536 end + local mask = ({ ruby = 0x80, sapphire = 0x100 })[version] or 0x180 + return bit.band(u16(6), 2) ~= 0 and bit.band(u32(8), 2) ~= 0 and bit.band(u16(12), 4) ~= 0 + and bit.band(u32(14), mask) ~= 0 +end + +local EVENT_OP_SIZE = { [0] = 1, 17, 1, 6, 2, 5, 12, 5, 3, 1, 2, 5, 5, 5, 1, 13, 13 } + +-- pokeruby/src/mystery_event_script.c:356 MEScrCmd_setrecordmixinggift +function MysteryGift.eventRecordMixingItem(bytes) + if type(bytes) ~= "string" then return nil end + local pos = 1 + while pos <= #bytes do + local op = bytes:byte(pos) + local size = EVENT_OP_SIZE[op] + if not size or op == 2 then return nil end + if op == 11 and pos + 4 <= #bytes then + local unk, qty = bytes:byte(pos + 1), bytes:byte(pos + 2) + local item = bytes:byte(pos + 3) + bytes:byte(pos + 4) * 256 + if unk ~= 0 and qty ~= 0 and item ~= 0 then return item end + end + pos = pos + size + end + return nil +end + +local function parseEvents(data, version) + local out = {} + if type(data.events) ~= "table" then return out end + local Base64 = require("src.core.Base64") + for _, raw in ipairs(data.events) do + local ok, bytes = pcall(Base64.decode, type(raw) == "table" and raw.payload or nil) + if ok and rowFamily(raw) == "rs" and MysteryGift.offeredIn(copyVersions(raw.versions), version) + and MysteryGift.eventHeaderOk(bytes, version) then + out[#out + 1] = { + key = type(raw.key) == "string" and raw.key or tostring(#out + 1), + label = clampText(raw.title, WONDER_CARD_TEXT_LENGTH), + bytes = bytes, + } + end + end + return out +end + +function MysteryGift.parseFeed(payload, family, gameVersion) family = family or MysteryGift.familyOf(nil) local version = familyVersion(family) local Json = require("src.link.Json") @@ -1590,9 +1743,11 @@ function MysteryGift.parseFeed(payload, family) if type(data) ~= "table" or data.v ~= MysteryGift.FEED_VERSION then return nil, "bad_feed" end if type(data.cards) ~= "table" or type(data.news) ~= "table" then return nil, "bad_feed" end local out = { issued = num(data.issued), cards = {}, news = {} } + if family == "rs" then out.events = parseEvents(data, gameVersion) end for _, raw in ipairs(data.cards) do - local card = rowFamily(raw) == family and MysteryGift.normalizeCard(raw, version) or nil - if card and validateCard(card) and isFlagIdInValidRange(card.flagId) and card.idNumber ~= 0 then + local card = rowFamily(raw) == family and MysteryGift.normalizeCard(raw, gameVersion or version) or nil + if card and validateCard(card) and isFlagIdInValidRange(card.flagId) and card.idNumber ~= 0 + and MysteryGift.offeredIn(card.versions, gameVersion) then out.cards[#out.cards + 1] = { key = type(raw.key) == "string" and raw.key or tostring(card.idNumber), label = card.titleText, @@ -1601,7 +1756,7 @@ function MysteryGift.parseFeed(payload, family) end end for _, raw in ipairs(data.news) do - local news = rowFamily(raw) == family and MysteryGift.normalizeNews(raw) or nil + local news = MysteryGift.normalizeNews(raw) if news and validateNews(news) then out.news[#out.news + 1] = { key = type(raw.key) == "string" and raw.key or tostring(news.id), @@ -1613,7 +1768,7 @@ function MysteryGift.parseFeed(payload, family) return out end -function MysteryGift.verifyFeed(feed, family) +function MysteryGift.verifyFeed(feed, family, gameVersion) if type(feed) ~= "table" or type(feed.payload) ~= "string" or type(feed.sig) ~= "string" then return nil, "bad_feed" end @@ -1621,17 +1776,25 @@ function MysteryGift.verifyFeed(feed, family) if not Ed25519.verify(MysteryGift.GIFT_PUBKEY, feed.payload, feed.sig) then return nil, "bad_signature" end - return MysteryGift.parseFeed(feed.payload, family) + return MysteryGift.parseFeed(feed.payload, family, gameVersion) end -function MysteryGift.feedPath(family) - if family == "frlg" then return MysteryGift.FEED_PATH end - return MysteryGift.FEED_PATH .. "?family=" .. tostring(family) +function MysteryGift.feedPath(family, gameVersion) + local q = {} + if family ~= "frlg" then q[#q + 1] = "family=" .. tostring(family) end + if gameVersion then q[#q + 1] = "version=" .. tostring(gameVersion) end + if #q == 0 then return MysteryGift.FEED_PATH end + return MysteryGift.FEED_PATH .. "?" .. table.concat(q, "&") end function MysteryGift.fetchOnline(opts) opts = opts or {} local family = opts.family or MysteryGift.familyOf(opts.session) + local gameVersion = opts.version + if gameVersion == nil then + gameVersion = versionOf(opts.session) + if not MysteryGift.offeredIn(MysteryGift.FAMILY_VERSIONS[family], gameVersion) then gameVersion = nil end + end local client = opts.client if not client then local okS, SyncClient = pcall(require, "src.sync.SyncClient") @@ -1640,12 +1803,12 @@ function MysteryGift.fetchOnline(opts) if not okN then return { status = "error", reason = "offline" } end client = made end - local okR, handle = pcall(client.send, client, "GET", MysteryGift.feedPath(family), nil, + local okR, handle = pcall(client.send, client, "GET", MysteryGift.feedPath(family, gameVersion), nil, { noAuth = true, maxSeconds = MysteryGift.FETCH_SECONDS }) if not okR or handle == nil then - return { status = "error", reason = "offline", client = client, family = family } + return { status = "error", reason = "offline", client = client, family = family, version = gameVersion } end - return { status = "pending", client = client, handle = handle, family = family } + return { status = "pending", client = client, handle = handle, family = family, version = gameVersion } end function MysteryGift.pollOnline(job) @@ -1662,7 +1825,7 @@ function MysteryGift.pollOnline(job) job.reason = (num(res.code) >= 400) and "server" or "offline" return "error", job.reason end - local list, why = MysteryGift.verifyFeed(res.data, job.family) + local list, why = MysteryGift.verifyFeed(res.data, job.family, job.version) if not list then job.status, job.reason = "error", why return "error", why diff --git a/src/core/game3/rs/link_entry.lua b/src/core/game3/rs/link_entry.lua index b4df8d77..1f5c1fe9 100644 --- a/src/core/game3/rs/link_entry.lua +++ b/src/core/game3/rs/link_entry.lua @@ -1,7 +1,6 @@ -local Link = require("src.core.game3.link.init") -local Strings = require("src.core.Strings") +local CableEntry = require("src.core.game3.link.cable_entry") local M = {} --- pokeruby/cable_club.c:558 +-- pokeruby/src/cable_club.c:558 M.SERVICES = { [1] = {wire = "battle_single", linkType = 0x2233, min = 2, max = 2}, [2] = {wire = "battle_double", linkType = 0x2244, min = 2, max = 2}, @@ -10,149 +9,9 @@ M.SERVICES = { records = {wire = "record_corner", linkType = 0x3311, min = 2, max = 4}, blender = {wire = "berry_blender", linkType = 0x4411, min = 2, max = 4}, } +-- pokeruby/src/cable_club.c:651 function M.contest(category) - local names = {"cool", "beauty", "cute", "smart", "tough"} - return {wire = "contest_" .. assert(names[(tonumber(category) or 0) + 1]), linkType = 0x6601, min = 4, max = 4} -end -function M.run(ctx, adapters, spec, handshake) - assert(spec, "RS cable service missing") - local live = Link.link - if live and live.isOpen and live:isOpen() then return handshake(ctx, adapters) end - local N = require("src.core.game3.scripting.natives") - local UI = require("src.ui.game3.rs.cable_lobby") - local phase, ticks, done, role, pending, answered = "connect", 0, false, nil, nil, {} - local seenGroup, goneTicks, awayTicks = false, 0, 0 - local profile = assert(spec.profile or Link.liveProfile(), "RS link profile missing") - local avatar = spec.avatar or Link.avatar() - local function finish(code) - Link.clientCall("leaveGroup"); Link.closeLink("rs_cable_failed") - UI.close(); Link.clientCall("groupList", nil); Link.setStatus("busy") - Link.setResult(ctx, code); done = true - end - local function offline(member) - if type(member) ~= "table" then return false end - if member.online == false then return true end - for _, entry in ipairs(Link.clientCall("lobby") or {}) do - if type(entry) == "table" and entry.id ~= nil and entry.id == member.id then return entry.online == false end - end - return false - end - local function liveMembers(group) - local members = type(group) == "table" and type(group.members) == "table" and group.members or {} - local live = {} - for _, member in ipairs(members) do - if not offline(member) then live[#live + 1] = member end - end - return live, #live == #members - end - local function rows() - if phase == "mode" then return {{id = "leader", label = Strings("CREATE GROUP")}, {id = "join", label = Strings("JOIN GROUP")}} end - if phase == "ask" then return {{id = "yes", label = Strings("YES")}, {id = "no", label = Strings("NO")}} end - local out, group = {}, Link.clientCall("group") - if role == "leader" then - local members, all = liveMembers(group) - for _, member in ipairs(members) do - local av = member.avatar or {} - out[#out + 1] = {label = av.name or member.name or "", disabled = true} - end - out[#out + 1] = {id = "start", label = Strings("START"), disabled = not all or #members < spec.min or #members > spec.max} - elseif phase == "list" then - for _, groupRow in ipairs(Link.clientCall("groups", spec.wire) or {}) do - local av = groupRow.avatar or {} - out[#out + 1] = {id = groupRow.leader, label = av.name or groupRow.name or "", - disabled = (tonumber(groupRow.joined) or 0) >= (tonumber(groupRow.max) or spec.max)} - end - end - return out - end - local poll - local function select(row) - if phase == "mode" then - role, phase = row.id, "list" - if role == "leader" then Link.clientCall("openGroup", spec.wire, profile, avatar) - else Link.clientCall("groupList", spec.wire, profile) end - elseif phase == "ask" then - answered[pending.id] = true - Link.clientCall("acceptGroup", pending.id, row.id == "yes"); pending, phase = nil, "list" - elseif role == "leader" and row.id == "start" then - Link.clientCall("startGroup"); phase, ticks = "starting", 0 - elseif role == "join" then Link.clientCall("joinGroup", row.id, profile, avatar); phase, ticks = "waiting", 0 end - end - N.yieldHost(ctx, adapters, function() end) - Link.setResult(ctx, 0) - if not Link.online() then - local ok = (spec.connect or Link.connect)() - if not ok then finish(Link.LINKUP.CONNECTION_ERROR); return false, Link.LINKUP.CONNECTION_ERROR end - else phase = "mode" end - poll = function() - if done then return true end - ticks = ticks + 1 - if phase == "connect" then - if Link.online() then phase, ticks = "mode", 0 - elseif ticks > 1200 or Link.connectState() == "error" then finish(Link.LINKUP.CONNECTION_ERROR) end - return done - end - if not Link.online() then finish(Link.LINKUP.CONNECTION_ERROR); return true end - if phase == "ready" then - local lk = Link.link - if not lk or not lk:isOpen() then finish(Link.LINKUP.CONNECTION_ERROR); return true end - if lk:isReady() then - UI.close(); Link.clientCall("groupList", nil); Link.setStatus("busy") - local yielded, value = handshake(ctx, adapters) - if not yielded then Link.setResult(ctx, value or Link.getVar(ctx, Link.VAR_RESULT)); return true end - return false - elseif ticks > 600 then finish(Link.LINKUP.CONNECTION_ERROR); return true end - return false - end - local room = Link.clientCall("room") - if type(room) == "table" and room.stage == "battling" and room.match ~= nil then - UI.close() - if not Link.openRelay({linkType = spec.linkType, game = spec.game, hello = spec.hello}) then finish(Link.LINKUP.CONNECTION_ERROR); return true end - phase, ticks = "ready", 0 - return false - end - local g = Link.clientCall("group") - if role == "leader" and phase == "ask" then - local still = false - for _, request in ipairs(type(g) == "table" and type(g.pending) == "table" and g.pending or {}) do - if pending and request.id == pending.id and not offline(request) then still = true end - end - if not still then - if pending then answered[pending.id] = true end - pending, phase = nil, "list" - UI.cursor = 1 - end - elseif role == "leader" and phase == "list" then - for _, request in ipairs(type(g) == "table" and type(g.pending) == "table" and g.pending or {}) do - if not answered[request.id] and not offline(request) then pending, phase = request, "ask"; break end - end - elseif role == "leader" and phase == "starting" and type(g) == "table" then - local members, all = liveMembers(g) - if not all or #members < spec.min then phase, ticks = "list", 0 end - elseif role == "join" and phase == "waiting" then - if type(g) == "table" then - seenGroup, goneTicks = true, 0 - local leader - for _, member in ipairs(type(g.members) == "table" and g.members or {}) do - if member.id == g.leader then leader = member end - end - awayTicks = offline(leader) and awayTicks + 1 or 0 - if awayTicks > 600 then finish(Link.LINKUP.CONNECTION_ERROR); return true end - ticks = 0 - elseif seenGroup then - goneTicks = goneTicks + 1 - if goneTicks > 120 then finish(Link.LINKUP.CONNECTION_ERROR); return true end - end - end - if not done and (phase == "starting" or phase == "waiting") and ticks > 1200 then finish(Link.LINKUP.CONNECTION_ERROR); return true end - return false - end - ctx.nativePoll = poll - UI.show({rows = rows, select = select, tick = function() - if ctx.nativePoll == poll then poll() end - end, - title = function() return phase == "ask" and Strings("ADD %s?", (pending.avatar or {}).name or pending.name or "") or Strings(spec.wire:upper():gsub("_", " ")) end, - cancel = function() finish(Link.LINKUP.FAILED) end}) - return true + return {wire = assert(CableEntry.CONTEST_WIRES[tonumber(category) or -1]), linkType = 0x6601, min = 4, max = 4} end +M.run = CableEntry.run return M diff --git a/src/core/game3/rse/event_islands.lua b/src/core/game3/rse/event_islands.lua index 86e84838..282a8a06 100644 --- a/src/core/game3/rse/event_islands.lua +++ b/src/core/game3/rse/event_islands.lua @@ -26,6 +26,11 @@ EventIslands.GIFT_FLAGS = { local cache = {} +function EventIslands.available() + local ok, man = pcall(EventIslands.manifest) + return ok and type(man) == "table" and type(man.gifts) == "table" +end + function EventIslands.manifest() local GameVersion = require("src.core.GameVersion") local key = tostring(GameVersion.get()) @@ -70,28 +75,75 @@ local function hasItem(sess, name) return Bag.has(sess.bag, C(sess):require("items", name), 1) end +local TICKET_IDS = { eon = true, aurora = true, mystic = true, oldSeaMap = true } +EventIslands.REFRESH_SECONDS = 600 + +function EventIslands.relayTickets(sess) + local MysteryGift = require("src.core.game3.mystery_gift") + local rec = MysteryGift.ensure(sess) + local out = {} + for _, id in ipairs(type(rec.relayTickets) == "table" and rec.relayTickets or {}) do out[id] = true end + return out +end + +function EventIslands.applyFeed(sess, list) + local MysteryGift = require("src.core.game3.mystery_gift") + local ids, seen = {}, {} + for _, entry in ipairs(type(list) == "table" and list.cards or {}) do + local id = MysteryGift.ramScriptId(sess, entry.card) + if TICKET_IDS[id] and not seen[id] then + seen[id] = true + ids[#ids + 1] = id + end + end + MysteryGift.ensure(sess).relayTickets = ids + return ids +end + +function EventIslands.refresh(sess, opts) + local MysteryGift = require("src.core.game3.mystery_gift") + opts = opts or {} + if not EventIslands.job then + local now = os.time() + if EventIslands.fetchedAt and not opts.force and now - EventIslands.fetchedAt < EventIslands.REFRESH_SECONDS then + return "cached" + end + EventIslands.fetchedAt = now + EventIslands.job = MysteryGift.fetchOnline({ family = "rse", session = sess, transport = opts.transport, + client = opts.client }) + end + local status, result = MysteryGift.pollOnline(EventIslands.job) + if status == "pending" then return "pending" end + EventIslands.job = nil + if status == "ok" then EventIslands.applyFeed(sess, result) end + return status, result +end + function EventIslands.eonPending(sess) - return not hasItem(sess, "ITEM_EON_TICKET") and not Rse.flag("FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", sess) + return EventIslands.relayTickets(sess).eon == true + and not hasItem(sess, "ITEM_EON_TICKET") and not Rse.flag("FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", sess) end -- pokeemerald/src/mystery_gift.c:156 function EventIslands.pendingGift(sess) sess = sess or Rse.session() - if not EventIslands.enabled(sess) then return nil end + if not (sess and EventIslands.enabled(sess)) then return nil end + local relay = EventIslands.relayTickets(sess) for _, g in ipairs(EventIslands.manifest().gifts) do local flag = EventIslands.GIFT_FLAGS[g.id] - if flag and not Rse.flag(flag, sess) then return g end + if flag and relay[g.id] and not Rse.flag(flag, sess) then return g end end return nil end -- pokeemerald/src/field_specials.c:3630 -function EventIslands.sync(sess) +function EventIslands.sync(sess, opts) sess = sess or Rse.session() if not (sess and Rse.isRse(sess) and Rse.store()) then return false end if not EventIslands.enabled(sess) then return false end + if not (opts and opts.offline) then pcall(EventIslands.refresh, sess, opts) end local pending = EventIslands.eonPending(sess) or EventIslands.pendingGift(sess) ~= nil - Rse.setVar("VAR_DISTRIBUTE_EON_TICKET", pending and 1 or 0, sess) + if pending then Rse.setVar("VAR_DISTRIBUTE_EON_TICKET", 1, sess) end return pending end @@ -106,7 +158,7 @@ function EventIslands.optionRow() step = function(c) local o = require("src.core.game3.options").block(c.options) o[EventIslands.OPTION_KEY] = (tonumber(o[EventIslands.OPTION_KEY]) or 0) ~= 0 and 0 or 1 - pcall(EventIslands.sync, c.session) + pcall(EventIslands.sync, c.session, { force = true }) return true end, } diff --git a/src/core/game3/scripting/natives_contest.lua b/src/core/game3/scripting/natives_contest.lua index cbbac92e..32e8e7aa 100644 --- a/src/core/game3/scripting/natives_contest.lua +++ b/src/core/game3/scripting/natives_contest.lua @@ -258,8 +258,7 @@ function NativesContest.contestLinkTransfer(vm) if not job then if live.isReady and not live:isReady() then return false end -- pokeemerald/src/contest_link.c:74 - local flags = CL.FLAG.IS_LINK + (CL.wireless and CL.FLAG.IS_WIRELESS or 0) - local s = CL.newSession(live, { flags = flags }) + local s = CL.newSession(live, { flags = CL.flagsFor(live, CL.wireless) }) local contestant = Util().contestantFromMon(mon, sess) if contestant.nickname == nil or contestant.nickname == "" then local Pokemon = require("src.core.game3.pokemon") @@ -317,7 +316,8 @@ end -- pokeemerald/src/contest_util.c:2718 function NativesContest.linkContestWaitForConnection(ctx, adapters) local s = linkSession() - if not (s and isWireless()) then return false, 0 end + -- pokeemerald/src/contest_link.c:100 + if not (s and isWireless()) or s:hasRsPlayer() then return false, 0 end local Natives = Natives() if not Natives.yieldHost(ctx, adapters, function() end) then return false, 1 end ctx.nativePoll = function() @@ -517,7 +517,10 @@ NativesContest.BY_NAME = { -- pokeemerald/src/contest_util.c:2754 LinkContestTryHideWirelessIndicator = function() return false end, -- pokeemerald/src/contest_util.c:2763 - IsContestWithRSPlayer = function() return false, 0 end, + IsContestWithRSPlayer = function() + local s = linkSession() + return false, s and s:hasRsPlayer() and 1 or 0 + end, -- pokeemerald/src/contest_util.c:2771 ClearLinkContestFlags = function() NativesContest.linkFlags = 0 diff --git a/src/link/Game3Link.lua b/src/link/Game3Link.lua index f8335c9c..3db7835a 100644 --- a/src/link/Game3Link.lua +++ b/src/link/Game3Link.lua @@ -92,6 +92,10 @@ end local function helloFor(game, linkType, player) local hello = Handshake.hello(handshakeView(game), nil) + local okA, ArenaData = pcall(require, "src.online.ArenaData") + if okA and type(ArenaData) == "table" and ArenaData.stripCosmeticMods then + ArenaData.stripCosmeticMods(hello, game) + end hello.generation = Game3Link.GENERATION hello.type = Game3Link.HELLO hello.ruleset = Handshake.DEFAULT_RULESET diff --git a/src/online/ArenaData.lua b/src/online/ArenaData.lua index 228c7d74..1adc90b6 100644 --- a/src/online/ArenaData.lua +++ b/src/online/ArenaData.lua @@ -326,17 +326,95 @@ local function liveVersion(game) return GameVersion.get() end +ArenaData.COSMETIC_REGISTRIES = { + text = true, rom_text = true, strings = true, font = true, + sprites = true, palettes = true, icons = true, battle_anims = true, + render_pipelines = true, battle_sprite_scales = true, transitions = true, + music = true, sfx = true, cries = true, audio = true, map_songs = true, +} + +ArenaData.NAME_FIELDS = { + pokemon = { id = true, name = true, description = true, dexEntry = true, category = true }, + moves = { id = true, name = true, description = true }, + items = { id = true, name = true, description = true }, +} + +local function cosmeticOp(registry, entry) + if ArenaData.COSMETIC_REGISTRIES[registry] then return true end + local fields = ArenaData.NAME_FIELDS[registry] + if not fields or entry.op ~= "patch" or type(entry.value) ~= "table" then return false end + for key in pairs(entry.value) do + if not fields[key] then return false end + end + return true +end + +local function owns(list, id) + for _, entry in ipairs(list or {}) do + if entry.owner == id then return true end + end + return false +end + +function ArenaData.modTouchesGameplay(loader, id) + if type(loader) ~= "table" then return false end + local record = loader.mods and loader.mods[id] + local manifest = record and record.manifest + if manifest and #(manifest.permissions or {}) > 0 then return true end + for name, registry in pairs(loader.content or {}) do + for _, list in pairs(registry.ops or {}) do + for _, entry in ipairs(list) do + if entry.owner == id and not cosmeticOp(name, entry) then return true end + end + end + end + for _, chain in pairs((loader.hooks and loader.hooks.chains) or {}) do + if owns(chain, id) then return true end + end + for _, list in pairs((loader.events and loader.events.listeners) or {}) do + if owns(list, id) then return true end + end + local migrations = loader.migrations and loader.migrations[id] + if type(migrations) == "table" and next(migrations) ~= nil then return true end + local input = loader.modInput and loader.modInput[id] + if type(input) == "table" and (tonumber(input.seq) or 0) > 0 then return true end + if loader.stepsQueues and loader.stepsQueues[id] ~= nil then return true end + return false +end + +function ArenaData.onlineBlockers3(game) + local out = {} + local loader = type(game) == "table" and game.mods or nil + if not loader then return out end + for _, entry in ipairs(Handshake.mods(game)) do + if entry.affectsLink or ArenaData.modTouchesGameplay(loader, entry.id) then out[#out + 1] = entry end + end + return out +end + +local NO_MODS = {} + +function ArenaData.stripCosmeticMods(hello, game) + if type(hello) ~= "table" or type(game) ~= "table" or not game.mods then return hello end + local blockers = ArenaData.onlineBlockers3(game) + if #blockers == #(hello.mods or NO_MODS) then return hello end + hello.mods = blockers + if #blockers == 0 then hello.linkModified = false end + local ok, fingerprint = pcall(Fingerprint.compute, game.data, blockers, 3) + if ok then hello.fingerprint = fingerprint end + return hello +end + function ArenaData.liveProfile3(game, rulesetId) if type(game) ~= "table" then return nil, "no game" end - if Handshake.linkModified(game) then return nil, "mods" end + if #ArenaData.onlineBlockers3(game) > 0 then return nil, "mods" end local version = liveVersion(game) if not GameVersion.VERSIONS[version] or GameVersion.generation(version) ~= 3 then return nil, "unknown game" end rulesetId = rulesetId or G3_DEFAULT_RULESET if not isG3Ruleset(rulesetId) then return nil, "unknown ruleset" end - local ok, fingerprint = pcall(Fingerprint.compute, game.data, - Handshake.mods(game), 3) + local ok, fingerprint = pcall(Fingerprint.compute, game.data, NO_MODS, 3) if not ok then return nil, tostring(fingerprint) end return copyProfile({ engine = 3, diff --git a/src/ui/game3/link_tags.lua b/src/ui/game3/link_tags.lua new file mode 100644 index 00000000..27c1641a --- /dev/null +++ b/src/ui/game3/link_tags.lua @@ -0,0 +1,336 @@ +local LinkTags = {} + +LinkTags.PLATE_H = 11 +LinkTags.PAD = 2 +LinkTags.ICON_GAP = 1 +LinkTags.MAX_TEXT_W = 44 +LinkTags.TEXT_DY = -2 +LinkTags.HEAD_OVERLAP = 2 +LinkTags.EDGE = 1 +LinkTags.BLENDER_EM_BASE = 0x7F00 +LinkTags.BLENDER_RS_BASE = 0xF0 +LinkTags.MAX_SEATS = 5 + +LinkTags.PLATE = { 0.06, 0.07, 0.12, 0.62 } +LinkTags.PLATE_SELF = { 0.08, 0.2, 0.46, 0.7 } + +LinkTags.COLORS = { + ["#"] = { 0.12, 0.12, 0.16, 1 }, + w = { 0.97, 0.97, 0.97, 1 }, + b = { 0.3, 0.6, 1, 1 }, + o = { 1, 0.58, 0.18, 1 }, + r = { 0.92, 0.22, 0.2, 1 }, + y = { 1, 0.86, 0.3, 1 }, +} + +LinkTags.ICONS = { + chat = { + ".#######.", + "#wwwwwww#", + "#w#w#w#w#", + "#wwwwwww#", + ".#ww####.", + ".#w#.....", + ".##......", + }, + trade = { + "......#...", + "#######b#.", + "#bbbbbbbb#", + "#######b#.", + "...#..#...", + ".#o#######", + "#oooooooo#", + ".#o#######", + "...#......", + }, + battle = { + "##.....##", + "#w#...#w#", + ".#w#.#w#.", + "..#w#w#..", + "...#w#...", + "..##.##..", + ".rr#.#rr.", + "#r#...#r#", + "##.....##", + }, + busy = { + "#######", + "#yyyyy#", + ".#yyy#.", + "..#y#..", + ".#y.y#.", + "#yyyyy#", + "#######", + }, +} + +LinkTags._images = nil +LinkTags._widths = {} +LinkTags._widthCount = 0 +LinkTags._src = { byVobj = {}, player = nil } +LinkTags._rows = {} +LinkTags._self = {} + +function LinkTags.iconSize(kind) + local rows = LinkTags.ICONS[kind] + if not rows then return 0, 0 end + return #rows[1], #rows +end + +local function union() + return package.loaded["src.core.game3.link.union_room"] +end + +local function linkPlayers() + return package.loaded["src.core.game3.link.link_players"] +end + +local function link() + return package.loaded["src.core.game3.link"] +end + +local function tagRow(i) + local row = LinkTags._rows[i] + if not row then + row = {} + LinkTags._rows[i] = row + end + return row +end + +local function localKind() + local LT = package.loaded["src.core.game3.link.trade"] + if LT and LT.isActive and LT.isActive() then return "trade" end + local LB = package.loaded["src.core.game3.link.battle"] + if LB and LB.isActive and LB.isActive() then return "battle" end + local Chat = package.loaded["src.core.game3.link.chat"] + if Chat and Chat.isActive and Chat.isActive() then return "chat" end + local U = union() + if U and U.isActive and U.isActive() and U.state == "in_activity" then + return U.memberStatusKind({ activity = U.activity }) + end + return nil +end + +local function blenderPartners(src, n) + local L = link() + local lk = L and L.link + if not (lk and lk.isOpen and lk:isOpen() and lk.players) then return n, false end + local V = package.loaded["src.core.game3.virtual_objects"] + if not V then return n, false end + local ok, players = pcall(lk.players, lk) + if not ok or type(players) ~= "table" then return n, false end + local any = false + for i, player in ipairs(players) do + local seat = tonumber(player.seat) or (i - 1) + if seat >= 0 and seat < LinkTags.MAX_SEATS and type(player.name) == "string" then + for _, id in ipairs({ LinkTags.BLENDER_EM_BASE + seat, LinkTags.BLENDER_RS_BASE - seat }) do + if V.get(id) then + n = n + 1 + local row = tagRow(n) + row.name, row.kind = player.name, nil + src.byVobj[id] = row + any = true + end + end + end + end + return n, any +end + +function LinkTags.sources() + local src = LinkTags._src + for k in pairs(src.byVobj) do src.byVobj[k] = nil end + src.player = nil + local active, n = false, 0 + local U = union() + if U and U.isActive and U.isActive() and U.onUnionRoomMap and U.onUnionRoomMap() then + active = true + for slot = 1, U.capacity() do + local p = U.players[slot] + if p and not p.gone and type(p.name) == "string" then + n = n + 1 + local row = tagRow(n) + row.name, row.kind = p.name, U.memberStatusKind(p) + src.byVobj[U.vobjId(slot)] = row + end + end + end + local LP = linkPlayers() + if LP and LP._mapId then + active = true + for seat in pairs(LP.remotes()) do + local name = LP.nameOf(seat) + if name then + n = n + 1 + local row = tagRow(n) + row.name, row.kind = name, LP.statusKind(seat) + src.byVobj[LP.VOBJ_BASE + seat] = row + end + end + end + local blender + n, blender = blenderPartners(src, n) + active = active or blender + if not active then return nil end + local L = link() + local s = L and L.session and L.session() + local me = LinkTags._self + me.name = type(s) == "table" and tostring(s.name or s.playerName or ""):sub(1, 7) or nil + me.kind = localKind() + if me.name and me.name ~= "" then src.player = me end + return src +end + +function LinkTags.tagFor(src, a) + if not (src and a) then return nil end + if a.kind == "player" then return src.player end + local eo = a.eventObject + local id = eo and eo.virtualId + return id and src.byVobj[id] or nil +end + +function LinkTags.textWidth(name, measure) + local w = LinkTags._widths[name] + if w then return w end + if measure then + w = measure(name) + else + local FrlgFont = require("src.ui.game3.frlg_font") + local ok, got = pcall(FrlgFont.measure, name, { small = true }) + w = ok and tonumber(got) or (#name * 6) + end + w = math.min(LinkTags.MAX_TEXT_W, math.max(0, w)) + if LinkTags._widthCount > 256 then + LinkTags._widths, LinkTags._widthCount = {}, 0 + end + LinkTags._widths[name] = w + LinkTags._widthCount = LinkTags._widthCount + 1 + return w +end + +function LinkTags.layout(tag, headX, headY, viewW, measure, out) + local textW = LinkTags.textWidth(tag.name, measure) + local iconW = tag.kind and LinkTags.iconSize(tag.kind) or 0 + local lead = iconW > 0 and (iconW + LinkTags.ICON_GAP) or 0 + local w = LinkTags.PAD * 2 + textW + lead + local x = math.floor(headX - w / 2 + 0.5) + if viewW then + x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x)) + end + out = out or {} + out.x, out.y, out.w, out.h = x, math.floor(headY - LinkTags.PLATE_H + LinkTags.HEAD_OVERLAP), w, LinkTags.PLATE_H + out.textW, out.iconW, out.textX = textW, iconW, x + LinkTags.PAD + lead + return out +end + +local LAYOUT = {} +local TEXT_OPTS = { small = true, colors = nil, maxWidth = 0 } + +local function iconImages() + if LinkTags._images then return LinkTags._images end + local out = {} + for kind, rows in pairs(LinkTags.ICONS) do + local w, h = #rows[1], #rows + local data = love.image.newImageData(w, h) + for y = 1, h do + for x = 1, w do + local c = LinkTags.COLORS[rows[y]:sub(x, x)] + if c then data:setPixel(x - 1, y - 1, c[1], c[2], c[3], c[4]) end + end + end + local img = love.graphics.newImage(data) + img:setFilter("nearest", "nearest") + out[kind] = img + end + LinkTags._images = out + return out +end + +local function plate(x, y, w, h, color) + love.graphics.setColor(color) + love.graphics.rectangle("fill", x + 1, y, w - 2, h) + love.graphics.rectangle("fill", x, y + 1, 1, h - 2) + love.graphics.rectangle("fill", x + w - 1, y + 1, 1, h - 2) +end + +local opaqueTops = setmetatable({}, { __mode = "k" }) + +local function opaqueTop(spr) + local top = opaqueTops[spr] + if top then return top end + top = 0 + local data = spr.imageData + local w, h = tonumber(spr.width), tonumber(spr.height) + if data and data.getPixel and w and h then + pcall(function() + for y = 0, h - 1 do + for x = 0, w - 1 do + local _, _, _, alpha = data:getPixel(x, y) + if alpha > 0 then + top = y + return + end + end + end + end) + end + opaqueTops[spr] = top + return top +end + +local function headTop(a, camY) + local h, offY, top = 32, 0, 0 + local Ow = package.loaded["src.core.game3.ow_sprites"] + if Ow and Ow.getDraw and a.graphicsId ~= nil then + local ok, spr = pcall(Ow.getDraw, a.graphicsId) + if ok and type(spr) == "table" then + h = tonumber(spr.height) or h + offY = tonumber(spr.drawOffY) or 0 + top = opaqueTop(spr) + end + end + return a.y - camY + 16 - h + offY + top +end + +local function drawTag(a, tag, camX, camY, viewW, isSelf) + local L = LinkTags.layout(tag, a.x - camX + 8, headTop(a, camY), viewW, nil, LAYOUT) + plate(L.x, L.y, L.w, L.h, isSelf and LinkTags.PLATE_SELF or LinkTags.PLATE) + if tag.kind then + local img = iconImages()[tag.kind] + if img then + local _, ih = LinkTags.iconSize(tag.kind) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, L.x + LinkTags.PAD, L.y + math.floor((L.h - ih) / 2)) + end + end + local FrlgFont = require("src.ui.game3.frlg_font") + TEXT_OPTS.colors, TEXT_OPTS.maxWidth = FrlgFont.COLOR.WHITE, L.textW + pcall(FrlgFont.draw, tag.name, L.textX, L.y + LinkTags.TEXT_DY, TEXT_OPTS) +end + +local function drawList(src, list, camX, camY, viewW, billboard) + if not list then return end + for _, a in ipairs(list) do + local tag = LinkTags.tagFor(src, a) + if tag and tag.name and tag.name ~= "" then + local pushed = billboard and billboard(a.x, a.y, camX, camY) + drawTag(a, tag, camX, camY, viewW, a.kind == "player") + if pushed then love.graphics.pop() end + end + end +end + +function LinkTags.draw(under, over, camX, camY, viewW, _viewH, billboard) + if not (love and love.graphics) then return false end + local src = LinkTags.sources() + if not src then return false end + drawList(src, under, camX, camY, viewW, billboard) + drawList(src, over, camX, camY, viewW, billboard) + love.graphics.setColor(1, 1, 1, 1) + return true +end + +return LinkTags diff --git a/src/ui/game3/mystery_gift.lua b/src/ui/game3/mystery_gift.lua index e2a61e79..673f5e8a 100644 --- a/src/ui/game3/mystery_gift.lua +++ b/src/ui/game3/mystery_gift.lua @@ -29,6 +29,9 @@ Ui.STATE = { SAVE_DONE = "save_done", GIFT_INPUT = "gift_input", NEWS_VIEW = "news_view", + SAVED_VIEW = "saved_view", + GIFT_MENU = "gift_menu", + ASK_TOSS = "ask_toss", EXIT = "exit", } @@ -40,6 +43,13 @@ local THREE_WIN = Window.template(8, 5, 14, 5) local OFFER_WIN = Window.template(1, 3, 17, 10) -- pokefirered/src/mystery_gift_menu.c:157 sWindowTemplate_YesNoBox local YESNO_WIN = Window.template(23, 15, 6, 4) +-- pokefirered/src/mystery_gift_menu.c:137 sWindowTemplate_GiftSelect +local GIFT_SELECT_WIN = Window.template(1, 15, 19, 4) +-- pokefirered/src/mystery_gift_menu.c:177 sWindowTemplate_GiftSelect_2Options +local GIFT_SELECT_OPTIONS = { + [3] = Window.template(22, 14, 7, 5), + [2] = Window.template(22, 15, 7, 4), +} -- pokefirered/src/mystery_gift_show_card.c:67 sWindowTemplates local CARD_HEADER = Window.template(1, 1, 25, 4) local CARD_BODY = Window.template(1, 6, 28, 8) @@ -264,7 +274,8 @@ function Ui.news(st) end -- pokefirered/src/mystery_gift_menu.c:855 SaveOnMysteryGiftMenu -local function beginSave(st) +local function beginSave(st, afterSave) + st.afterSave = afterSave st.state = Ui.STATE.SAVE -- pokefirered/src/strings.c:1321 gText_DataWillBeSaved say(st, RomText.plain("gText_DataWillBeSaved"), nil, true) @@ -302,11 +313,12 @@ local function toMainMenuOffline(st) end -- pokefirered/src/mystery_gift_menu.c:1352 -local function clientResult(st, textKey, success) +local function clientResult(st, textKey, success, onShown) st.state = Ui.STATE.RESULT_MSG if success then fanfare(Song.MUS_OBTAIN_ITEM) say(st, RomText.plain(textKey), function(s) + if onShown then onShown(s) end beginSave(s) end, true, Ui.SUCCESS_FRAMES) else @@ -315,10 +327,29 @@ local function clientResult(st, textKey, success) end -- pokefirered/src/mystery_gift_menu.c:1344 -local function commCompleted(st, textKey, success) +local function commCompleted(st, textKey, success, onShown) iconOff() closeOffers(st) - clientResult(st, textKey, success) + clientResult(st, textKey, success, onShown) +end + +-- pokefirered/src/mystery_gift_client.c:210 CLI_SAVE_NEWS +local function receiveNewsFrom(st, entry) + local sess = session(st) + st.viewNews = nil + local ok, why = MysteryGift.saveNewsIfNew(sess, entry.news) + if not ok then + -- pokefirered/src/mystery_gift_menu.c:921 + commCompleted(st, why == "had" and "gText_AlreadyHadNews" or "gText_CommunicationError", false) + return false + end + MysteryGift.claimNews(sess, entry.news) + -- pokefirered/src/mystery_gift_menu.c:905 + commCompleted(st, "gText_WonderNewsReceived", true, function(s) + -- pokefirered/src/mystery_gift_menu.c:1369 + MysteryGift.setNewsReward(session(s), MysteryGift.WONDER_NEWS_RECV_WIRELESS) + end) + return true end -- pokefirered/src/mystery_gift_client.c:208 CLI_SAVE_CARD @@ -368,7 +399,9 @@ function Ui.offerRows(st) local ListMenu = require("src.ui.game3.list_menu") local items = {} for i, entry in ipairs(st.offers or {}) do - local claimed = not st.isNews and MysteryGift.hasClaimedCard(sess, entry.card) + local claimed + if st.isNews then claimed = MysteryGift.hasClaimedNews(sess, entry.news) + else claimed = MysteryGift.hasClaimedCard(sess, entry.card) end items[i] = { label = entry.label, id = i, @@ -450,6 +483,76 @@ local function pickOffer(st, entry) deliverOffer(st, entry) end +-- pokefirered/src/mystery_gift_menu.c:765 ValidateCardOrNews +local function hasSaved(st) + if st.isNews then return MysteryGift.validateSavedNews(session(st)) end + return MysteryGift.validateSavedCard(session(st)) +end + +-- pokefirered/src/mystery_gift_menu.c:1390 MG_STATE_LOAD_GIFT +local function openSaved(st) + st.viewCard, st.viewNews, st.viewOffer = nil, nil, nil + st.newsScroll = 0 + st.prompt = nil + st.state = Ui.STATE.SAVED_VIEW +end + +-- pokefirered/src/mystery_gift_menu.c:1147 MG_STATE_DONT_HAVE_ANY +local function openBranch(st) + if hasSaved(st) then return openSaved(st) end + st.state = Ui.STATE.RESULT_MSG + say(st, RomText.plain(st.isNews and "gText_DontHaveNewsNewOneInput" or "gText_DontHaveCardNewOneInput"), + startSearch) +end +Ui.openBranch = openBranch + +-- pokefirered/src/mystery_gift_menu.c:1417 MG_STATE_HANDLE_GIFT_SELECT +function Ui.giftMenuRows(st) + local list, actions + if st.isNews then + list, actions = "sListMenuItems_Receive", { "receive", "cancel" } + else + list, actions = "sListMenuItems_ReceiveToss", { "receive", "toss", "cancel" } + end + st.giftActions = actions + return RomText.list(list) +end + +local function openGiftMenu(st) + st.state = Ui.STATE.GIFT_MENU + -- pokefirered/src/mystery_gift_menu.c:716 + st.prompt = RomText.plain(st.isNews and "gText_WhatToDoWithNews" or "gText_WhatToDoWithCards") + setRows(st, Ui.giftMenuRows(st), 1) +end + +-- pokefirered/src/mystery_gift_menu.c:1484 MG_STATE_TOSS +local function tossSaved(st) + st.prompt = nil + if st.isNews then MysteryGift.clearNewsAndRelated(session(st)) + else MysteryGift.clearCardAndRelated(session(st)) end + beginSave(st, function(s) + s.state = Ui.STATE.RESULT_MSG + -- pokefirered/src/mystery_gift_menu.c:847 PrintThrownAway + say(s, RomText.plain(s.isNews and "gText_WonderNewsThrownAway" or "gText_WonderCardThrownAway"), toMainMenu) + end) +end + +-- pokefirered/src/mystery_gift_menu.c:1454 MG_STATE_ASK_TOSS +local function askToss(st) + st.state = Ui.STATE.ASK_TOSS + st.prompt = nil + -- pokefirered/src/mystery_gift_menu.c:839 AskDiscardGift + ask(st, RomText.plain(st.isNews and "gText_OkayToDiscardNews" or "gText_IfThrowAwayCardEventWontHappen"), + function(s) + if not s.isNews and MysteryGift.isGiftNotReceived(session(s)) then + -- pokefirered/src/mystery_gift_menu.c:1470 MG_STATE_ASK_TOSS_UNRECEIVED + ask(s, RomText.plain("gText_HaventReceivedGiftOkayToDiscard"), tossSaved, openGiftMenu) + return + end + tossSaved(s) + end, openGiftMenu) +end + function Ui.close(st) if type(st) == "table" and st.job then MysteryGift.cancelOnline(st.job) @@ -562,10 +665,10 @@ function Ui.update(st, pressed, dt) local action = pick == -1 and "exit" or (st.mainActions or {})[pick] if action == "news" then st.isNews = true - startSearch(st) + openBranch(st) elseif action == "cards" then st.isNews = false - startSearch(st) + openBranch(st) elseif action == "exit" then st.state = S.EXIT Ui.close(st) @@ -615,6 +718,7 @@ function Ui.update(st, pressed, dt) local item = list:selected() if item and item.entry and st.isNews then st.viewNews = item.entry.news + st.viewNewsEntry = item.entry st.newsScroll = 0 st.prompt = nil st.state = S.NEWS_VIEW @@ -634,22 +738,59 @@ function Ui.update(st, pressed, dt) if st.state == S.NEWS_VIEW then tickNewsScroll(st, pressed) - if pressed("a") or pressed("b") then + if pressed("a") and st.viewNewsEntry then + se(Song.SE_SELECT) + local entry = st.viewNewsEntry + st.viewNewsEntry = nil + receiveNewsFrom(st, entry) + elseif pressed("a") or pressed("b") then se(Song.SE_SELECT) st.viewNews = nil + st.viewNewsEntry = nil st.state = S.OFFER_LIST st.prompt = Ui.listPrompt(st) end return nil end + -- pokefirered/src/mystery_gift_menu.c:1394 MG_STATE_HANDLE_GIFT_INPUT + if st.state == S.SAVED_VIEW then + if st.isNews then tickNewsScroll(st, pressed) end + if pressed("a") then + se(Song.SE_SELECT) + openGiftMenu(st) + elseif pressed("b") then + se(Song.SE_SELECT) + toMainMenu(st) + end + return nil + end + + if st.state == S.GIFT_MENU then + local pick = tickList(st, pressed) + if pick == nil then return nil end + local action = pick == -1 and "cancel" or (st.giftActions or {})[pick] + if action == "receive" then + -- pokefirered/src/mystery_gift_menu.c:1506 MG_STATE_RECEIVE + st.prompt = nil + startSearch(st) + elseif action == "toss" then + askToss(st) + else + openSaved(st) + end + return nil + end + if st.state == S.SAVE then local ok = true if st.onSave then ok = st.onSave(session(st)) ~= false end + local after = st.afterSave + st.afterSave = nil st.state = S.SAVE_DONE -- pokefirered/src/strings.c:1322 gText_SaveCompletedPressA say(st, ok and RomText.plain("gText_SaveCompletedPressA") - or Strings("Save failed."), toMainMenu) + or Strings("Save failed."), ok and after or toMainMenu) return nil end @@ -824,9 +965,11 @@ function Ui.drawNews(st) end end +local CARD_STATES = { gift_input = true, news_view = true, saved_view = true, gift_menu = true, ask_toss = true } + function Ui.draw(st) local S = Ui.STATE - if st.state == S.GIFT_INPUT or st.state == S.NEWS_VIEW then + if CARD_STATES[st.state] then if st.isNews then Ui.drawNews(st) else Ui.drawCard(st) end else drawMenuBg() @@ -843,6 +986,12 @@ function Ui.draw(st) drawYesNo(st) elseif st.msg then drawMessage(st.msg.text, math.min(st.msg.revealed, st.msg.total)) + elseif st.state == S.GIFT_MENU then + -- pokefirered/src/mystery_gift_menu.c:719 + Window.fixedStdFrame(GIFT_SELECT_WIN) + Window.printPx(st.prompt or "", GIFT_SELECT_WIN.left * T, GIFT_SELECT_WIN.top * T + 2, + { colors = TEXT, maxWidth = GIFT_SELECT_WIN.width * T }) + drawList(st, GIFT_SELECT_OPTIONS[#(st.rows or {})] or GIFT_SELECT_OPTIONS[3]) elseif st.prompt then drawMessage(st.prompt, nil) end diff --git a/src/ui/game3/option_rows.lua b/src/ui/game3/option_rows.lua index a8b4d375..283fc7bd 100644 --- a/src/ui/game3/option_rows.lua +++ b/src/ui/game3/option_rows.lua @@ -359,9 +359,8 @@ function Rows.build(ctx, skip) return true end, }) - if require("src.core.game3.profile").family(ctx and ctx.session) == "rse" then - add(require("src.core.game3.rse.event_islands").optionRow()) - end + local EventIslands = require("src.core.game3.rse.event_islands") + if EventIslands.available() then add(EventIslands.optionRow()) end add({ id = "touchControls", label = Strings("TOUCH PAD"), diff --git a/src/ui/game3/rs/cable_lobby.lua b/src/ui/game3/rs/cable_lobby.lua index 9f37c9b3..af45a1ec 100644 --- a/src/ui/game3/rs/cable_lobby.lua +++ b/src/ui/game3/rs/cable_lobby.lua @@ -4,6 +4,8 @@ local Font = require("src.ui.game3.frlg_font") local Text = require("src.core.game3.rom_text") local Strings = require("src.core.Strings") local M = {ID = "rs_cable_lobby", open = false} +-- pokeemerald/src/strings.c:1242 +M.HEADERS = {"gOtherText_LinkStandby", "gText_CommunicationStandby"} function M.isOpen() return M.open end function M.show(opts) M.opts, M.cursor, M.open = opts, 1, true @@ -38,8 +40,11 @@ end function M.draw() if not M.open then return end Window.stdFrame(Window.template(1, 1, 28, 18)) - local rows, opts = M.rows(), {font = "native_3"} - Font.draw(Text.plain("gOtherText_LinkStandby"), 16, 16, opts) + local Family = require("src.core.game3.link.family") + local rows, opts = M.rows(), Family.isRubySapphire(Family.activeVersion()) and {font = "native_3"} or {} + for _, key in ipairs(M.HEADERS) do + if Text.has(key) then Font.draw(Text.plain(key), 16, 16, opts); break end + end local title = M.opts.title and M.opts.title() or "" Font.draw(title, 16, 40, opts) for i, row in ipairs(rows) do diff --git a/src/ui/game3/rs/mystery_events.lua b/src/ui/game3/rs/mystery_events.lua index 64eafe1d..5b24e473 100644 --- a/src/ui/game3/rs/mystery_events.lua +++ b/src/ui/game3/rs/mystery_events.lua @@ -1,27 +1,18 @@ local Kit = require("src.ui.game3.rse.scene_kit") local Pal = require("src.core.game3.pal_fade") local Chrome = require("src.ui.game3.chrome") -local Link = require("src.core.game3.link.init") local Event = require("src.core.game3.rs.mystery_event") -local M = {MESSAGE = "rs_mystery_event", REQUEST = "rs_mystery_event_request"} +local MysteryGift = require("src.core.game3.mystery_gift") +local Strings = require("src.core.Strings") +local M = {VISIBLE = 4, NEWS_LINES = 8} M.__index = M -function M.send(link, bytes) - if type(bytes) == "string" then bytes = {bytes:byte(1, #bytes)} end - if type(bytes) ~= "table" or #bytes == 0 or #bytes > Event.MAX_BYTES then return false end - local copy = {} - for i, b in ipairs(bytes) do - if type(b) ~= "number" or b < 0 or b > 255 or b % 1 ~= 0 then return false end - copy[i] = b - end - return link:send({type = M.MESSAGE, bytes = copy}) -end function M.new(opts) opts = opts or {} - local saved = opts.save or Kit.loadRawSave() + local saved = opts.save or (not opts.session and Kit.loadRawSave()) local session = opts.session or (saved and require("src.core.game3.save_schema_firered").fromSaveTable(saved)) assert(session, "RS Mystery Events require an existing save") local self = setmetatable({game = opts.game, session = session, opts = opts, state = "fade_in", - pal = Pal.new(), ticks = 0, step = Kit.stepper()}, M) + pal = Pal.new(), ticks = 0, step = Kit.stepper(), cursor = 1, scroll = 0}, M) self.pal:beginFade(Pal.ALL, 0, 16, 0, Pal.BLACK) return self end @@ -29,8 +20,7 @@ function M:message(source, vars) self.printer = Kit.printer(source, {speed = 2, canSpeedUp = false, ctx = {stringVars = vars or {}}}) end function M:close() - require("src.ui.game3.rs.cable_lobby").close() - Link.clientCall("leaveGroup"); Link.closeLink("rs_mystery_event_exit") + if self.job then MysteryGift.cancelOnline(self.job); self.job = nil end end function M:exit() self:close(); self.state = "exit" @@ -40,83 +30,108 @@ function M:failed(why) self.error = why; self.loading = false self:close(); self:message("gSystemText_LoadingError"); self.state = "result_print" end -function M:startEntry() - local profile = self.opts.profile - if not profile then profile = require("src.online.ArenaData").liveProfile3(self.game, "g3_link") end +function M:startFetch() local s = self.session - local avatar = {name = s.name or s.playerName or "", gender = s.gender or 0, version = s.version, - trainerId = require("src.core.game3.link.rs").trainerId(s)} - local spec = {wire = "mystery_event", linkType = 0x5501, min = 2, max = 2, profile = profile, - avatar = avatar, game = self.game, - hello = require("src.link.Game3Link").hello(self.game, 0x5501, {session = s, version = s.version, - name = avatar.name, gender = avatar.gender, trainerId = avatar.trainerId}), - connect = function() - return require("src.online.Connect").start({source = "game", version = s.version, - trainerName = avatar.name, profiles = {assert(profile, "Mystery Event live profile missing")}, - presence = {where = "game", status = "busy", version = s.version}}) - end} - self.ctx = require("src.core.game3.scripting.ctx").new() - local function ready() - Kit.playSe("SE_PIN") - self.state = "ready_print"; self:message("gSystemText_LoadEventPressA") - return false, 1 + self.job = MysteryGift.fetchOnline({family = "rs", version = s.version, session = s, + transport = self.opts.transport, client = self.opts.client}) + self.state = "fetching" +end +function M:selected() + return self.rows and self.rows[self.cursor] +end +local function plainIr(text) + return require("src.core.game3.scripting.text_ir").fromAscii(text) +end +function M:writeSave() + local save = require("src.core.game3.save_schema_firered").toSaveTable(self.session) + local writer = self.opts.writeSave or require("src.core.SaveData").save + local ok, written = pcall(writer, save) + if not ok or written == false then return false end + self.saved = save; if self.game then self.game.save = save end + return true +end +function M:buildRows(result) + local rows = {} + if MysteryGift.validateSavedNews(self.session) then + rows[#rows + 1] = {kind = "news", saved = true, label = MysteryGift.getSavedNews(self.session).titleText, + news = MysteryGift.getSavedNews(self.session)} end - local yielded, result = require("src.core.game3.rs.link_entry").run(self.ctx, {}, spec, ready) - if self.state == "ready_print" then return end - if yielded then self.state = "lobby" else self:failed(result) end + for _, ev in ipairs(result.events or {}) do rows[#rows + 1] = {kind = "event", label = ev.label, bytes = ev.bytes} end + for _, n in ipairs(result.news or {}) do + if not MysteryGift.hasClaimedNews(self.session, n.news) then + rows[#rows + 1] = {kind = "news", label = n.label, news = n.news} + end + end + return rows +end +function M:receiveNews(row) + local ok, why = MysteryGift.saveNewsIfNew(self.session, row.news) + if not ok then + self:message(plainIr(why == "had" and Strings("You already have this WONDER NEWS.") or Strings("This WONDER NEWS can't be read."))) + self.state = "result_print" + return + end + MysteryGift.claimNews(self.session, row.news) + if not self:writeSave() then return self:failed("save_failed") end + self:message(plainIr(Strings("The WONDER NEWS was saved."))) + self.state = "result_print" end function M:frame(inp) self.ticks = self.ticks + 1 - local lk = Link.link - if lk then lk:update(1 / 60) end - if self.opts.payload and lk and lk:isReady() and lk:take(M.REQUEST) then M.send(lk, self.opts.payload) end if self.state == "fade_in" then + -- pokeruby/src/mystery_event_menu.c:98 if not self.pal:fadeActive() then self:message("gSystemText_LinkStandby"); self.state = "standby_print" end elseif self.state == "standby_print" then self.printer:run(inp) - if not self.printer:isActive() then self:startEntry() end - elseif self.state == "lobby" then - require("src.ui.game3.rs.cable_lobby").update(inp) - if self.ctx.nativePoll and self.ctx.nativePoll() and self.state == "lobby" then - local result = self.ctx.specialVars[0x800D] - if result == 5 then self:exit() else self:failed(result) end + if not self.printer:isActive() then self:startFetch() end + elseif self.state == "fetching" then + if inp.new.b then Kit.playSe("SE_SELECT"); self:exit(); return end + local status, result = MysteryGift.pollOnline(self.job) + if status == "pending" then return end + self.job = nil + if status ~= "ok" then return self:failed(result) end + self.rows = self:buildRows(result) + if #self.rows == 0 then return self:failed("no_events") end + self.cursor, self.scroll, self.state = 1, 0, "list" + elseif self.state == "list" then + if inp.new.b then Kit.playSe("SE_SELECT"); self:exit() + elseif inp.new.up and self.cursor > 1 then + Kit.playSe("SE_SELECT"); self.cursor = self.cursor - 1 + if self.cursor <= self.scroll then self.scroll = self.cursor - 1 end + elseif inp.new.down and self.cursor < #self.rows then + Kit.playSe("SE_SELECT"); self.cursor = self.cursor + 1 + if self.cursor > self.scroll + M.VISIBLE then self.scroll = self.cursor - M.VISIBLE end + elseif inp.new.a and self:selected().kind == "news" then + Kit.playSe("SE_SELECT"); self.newsScroll, self.state = 0, "news_view" + elseif inp.new.a then + -- pokeruby/src/mystery_event_menu.c:112 + Kit.playSe("SE_PIN"); self:message("gSystemText_LoadEventPressA"); self.state = "ready_print" end + elseif self.state == "news_view" then + local row = self:selected() + local last = 0 + for i, line in ipairs(row.news.bodyText or {}) do if line ~= "" then last = i end end + if inp.new.up and self.newsScroll > 0 then self.newsScroll = self.newsScroll - 1 + elseif inp.new.down and self.newsScroll < math.max(0, last - M.NEWS_LINES) then self.newsScroll = self.newsScroll + 1 + elseif inp.new.a and not row.saved then Kit.playSe("SE_SELECT"); self:receiveNews(row) + elseif inp.new.a or inp.new.b then Kit.playSe("SE_SELECT"); self.state = "list" end elseif self.state == "ready_print" then self.printer:run(inp); if not self.printer:isActive() then self.state = "ready" end elseif self.state == "ready" then - if inp.new.b then Kit.playSe("SE_SELECT"); self:exit() - elseif not lk or not lk:isOpen() then self:failed("peer_closed") + if inp.new.b then Kit.playSe("SE_SELECT"); self.printer = nil; self.state = "list" elseif inp.new.a then - Kit.playSe("SE_SELECT") - local players = lk:players() - if #players ~= 2 or players[1].language ~= players[2].language then self:failed("peer_language_or_count") - else self:message("gSystemText_DontCutLink"); self.state = "receive_print"; self.loading = true end + -- pokeruby/src/mystery_event_menu.c:168 + Kit.playSe("SE_SELECT"); self:message("gSystemText_DontCutLink"); self.state = "receive_print"; self.loading = true end elseif self.state == "receive_print" then - if not lk or not lk:isOpen() then self:failed("peer_closed") - else self.printer:run(inp) end - if self.state == "receive_print" and not self.printer:isActive() then - lk:send({type = M.REQUEST}); self.state, self.ticks = "receive", 0 - end - elseif self.state == "receive" then - if not lk or not lk:isOpen() then self:failed("peer_closed") - else - local block = lk:take(M.MESSAGE) - if block then self.block, self.state = block.bytes, "received" - elseif self.ticks > 1200 then self:failed("receive_timeout") end - end - elseif self.state == "received" then - self:close(); self.state = "execute" + self.printer:run(inp) + if not self.printer:isActive() then self.state = "execute" end elseif self.state == "execute" then - self.result = Event.run(self.block, self.session, {adapters = self.opts.adapters, + -- pokeruby/src/mystery_event_menu.c:291 RunMysteryEventScript + local picked = self:selected() + self.result = Event.run(picked.bytes, self.session, {adapters = self.opts.adapters, saveBlock1Address = self.opts.saveBlock1Address}) - self.block = nil - if self.result.save then - local save = require("src.core.game3.save_schema_firered").toSaveTable(self.session) - local ok, written = pcall(require("src.core.SaveData").save, save) - if not ok or written == false then return self:failed("save_failed") end - self.saved = save; if self.game then self.game.save = save end - end + if self.result.save and not self:writeSave() then return self:failed("save_failed") end self.loading = false self:message(self.result.message, self.result.stringVars); self.state = "result_print" elseif self.state == "result_print" then @@ -130,17 +145,41 @@ function M:update(input, dt) self.step:collect(input) return self.step:run(dt, function(inp) return self:frame(inp) end) end +function M:drawList() + local FrlgFont = require("src.ui.game3.frlg_font") + local RomText = require("src.core.game3.rom_text") + local colors = Kit.messageColors() + local rows = math.min(M.VISIBLE, #self.rows) + Chrome.stdFrame(1, 1, 28, rows * 2) + for i = 1, rows do + local ev = self.rows[i + self.scroll] + FrlgFont.draw(ev.label or "", 16, 8 + (i - 1) * 16, {colors = colors}) + end + FrlgFont.draw(RomText.plain("gText_SelectorArrow3"), 8, 8 + (self.cursor - self.scroll - 1) * 16, {colors = colors}) +end +function M:drawNews() + local FrlgFont = require("src.ui.game3.frlg_font") + local colors = Kit.messageColors() + local news = self:selected().news + Chrome.stdFrame(1, 1, 28, 2) + FrlgFont.draw(news.titleText or "", 8, 8, {colors = colors}) + Chrome.stdFrame(1, 5, 28, M.NEWS_LINES * 2) + for i = 1, M.NEWS_LINES do + FrlgFont.draw((news.bodyText or {})[i + self.newsScroll] or "", 8, 40 + (i - 1) * 16, {colors = colors}) + end +end function M:draw() love.graphics.clear(0, 0, 0, 1) - if self.state == "lobby" then require("src.ui.game3.rs.cable_lobby").draw() - else + if self.state == "news_view" then self:drawNews() + elseif (self.state == "list" or self.state == "ready_print" or self.state == "ready") and self.rows then self:drawList() end + if self.printer and self.state ~= "list" then Chrome.stdFrame(1, 15, 28, 4) - if self.printer then self.printer:draw(8, 120, {colors = Kit.messageColors()}) end - if self.loading then - Chrome.stdFrame(7, 6, 16, 2) - require("src.ui.game3.frlg_font").draw(require("src.core.game3.rom_text").plain("gSystemText_LoadingEvent"), - 56, 48, {colors = Kit.messageColors()}) - end + self.printer:draw(8, 120, {colors = Kit.messageColors()}) + end + if self.loading then + Chrome.stdFrame(7, 6, 16, 2) + require("src.ui.game3.frlg_font").draw(require("src.core.game3.rom_text").plain("gSystemText_LoadingEvent"), + 56, 48, {colors = Kit.messageColors()}) end Kit.drawFade(self.pal, 0) end diff --git a/tests/drivers/em_event_islands.lua b/tests/drivers/em_event_islands.lua index 83b85907..89ff3645 100644 --- a/tests/drivers/em_event_islands.lua +++ b/tests/drivers/em_event_islands.lua @@ -70,6 +70,13 @@ return function(game) local row = EI.optionRow() row.step({ options = game.options, session = session }) check(EI.enabled(session), "the EVENT TICKETS option row turns the distribution on") + check(EI.pendingGift(session) == nil, "no ticket is handed out until the relay publishes one") + local feedCards = {} + for _, b in ipairs(require("src.core.game3.mystery_gift").builtins("rse")) do + feedCards[#feedCards + 1] = { key = b.key, card = b.card } + end + EI.applyFeed(session, { cards = feedCards }) + EI.sync(session, { offline = true }) check(S.var("VAR_DISTRIBUTE_EON_TICKET") == 1, "VAR_DISTRIBUTE_EON_TICKET raised for the Mystery Gift man") if not check(teleport("EM_LILYCOVE_CITY_POKEMON_CENTER_2F", 1, 6, "up"), "Lilycove Pokemon Center 2F loads") then diff --git a/tests/drivers/g3link_room_presence.lua b/tests/drivers/g3link_room_presence.lua new file mode 100644 index 00000000..beb68031 --- /dev/null +++ b/tests/drivers/g3link_room_presence.lua @@ -0,0 +1,246 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link_presence" + +local VERSION = os.getenv("POKEPORT_VERSION") or "firered" +local PER = { + firered = { center = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", trade = "FR_TRADE_CENTER" }, + leafgreen = { center = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", trade = "FR_TRADE_CENTER" }, + emerald = { center = "EM_OLDALE_TOWN_POKEMON_CENTER_2F", trade = "EM_TRADE_CENTER" }, +} +local CENTER_2F = PER[VERSION].center +local Plaza = require("src.core.game3.link.union_plaza_map") + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + local Link = package.loaded["src.core.game3.link"] + if Link then pcall(Link.reset) end + local Client = package.loaded["src.online.Client"] + if Client then pcall(Client.disconnect) end + if failures == 0 then + print("PASS g3link_room_presence") + love.event.quit(0) + else + print("FAIL g3link_room_presence failures=" .. failures) + love.event.quit(1) + end +end + +local function now() return love.timer.getTime() end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local SaveMenu = require("src.ui.game3.save_menu") + local Party = require("src.core.game3.party") + local Link = require("src.core.game3.link") + local Union = require("src.core.game3.link.union_room") + local Client = require("src.online.Client") + local Relay = require("tests.support.fake_relay") + local VirtualObjects = require("src.core.game3.virtual_objects") + local Family = require("src.core.game3.link.family") + local UNION_ROOM = Plaza.MAP_ID + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + Party.giveMon(session, 25, 12) + Party.giveMon(session, 1, 10) + + local relay = Relay.new({ clock = function() return now() end }) + local me = relay:seat("a0000001", "RED") + Client.configure({ relayAddress = "fake:1", connect = function() return me.transport end }) + + local function ctx() return Space.vm and Space.vm.ctx end + local function place(x, y, facing) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + game.session.x, game.session.y, game.session.facing = x, y, facing + end + local function wait(n) + for _ = 1, n do + relay:pump() + U.wait(1) + end + end + local function waitFor(cond, seconds, frames) + local t0, n = now(), 0 + while not cond() do + relay:pump() + U.wait(1) + n = n + 1 + if now() - t0 > (seconds or 5) and n > (frames or 60) then return false end + end + return true + end + local function drive(cond, seconds) + local t0 = now() + while not cond() and now() - t0 < (seconds or 10) do + if Choice.active or SaveMenu.isOpen() or Message.isOpen() then U.tap(game, "a") end + wait(6) + end + return cond() + end + + local live = Link.liveProfile() + if not result(live ~= nil, "the live g3 profile computes") then return finish() end + local function peer(id, name, trainerId, gender, version, status) + local s = relay:seat(id, name) + relay:handle(s, { type = "lobby_hello", protocol = 3, name = name, profiles = { live }, + presence = { where = "launcher", status = "idle", version = version } }) + s.avatar = { name = name, trainerId = trainerId, gender = gender, version = version } + s.status = status + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + return s + end + + Link.connect() + result(waitFor(function() return Client.state() == "online" end, 5, 120), "online on the relay") + result(Link.adapterConnected(), "the adapter reads connected") + Map.load(nil, game, CENTER_2F, { x = 6, y = 4, facing = "up" }) + wait(60) + Flags.setFlag(Space.store, ctx(), Family.flag(VERSION, "FLAG_SYS_POKEDEX_GET"), true) + if VERSION == "emerald" then + -- pokeemerald/data/scripts/cable_club.inc:104 + Flags.setVar(Space.store, ctx(), Family.var(VERSION, "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2) + end + place(6, 4, "up") + wait(12) + U.tap(game, "a") + local entered = drive(function() return Space.mapId == UNION_ROOM and Union.state == "main" end, 25) + if not result(entered, "the attendant walks the player into the Union Room") then + print("[driver] map=" .. tostring(Space.mapId) .. " union=" .. tostring(Union.state)) + U.shot(game, DIR .. "/" .. VERSION .. "_presence_enter_failed.png") + return finish() + end + + local statuses = { "chatting", "trading", "battling", "idle", "idle", "idle", "idle", "idle", "idle", "idle", "idle", "idle" } + local names = { "BLUE", "GREEN", "GOLD", "SILVER", "MAY", "LEAF", "WALLY", "BRENDAN", "KRIS", "ETHAN", "LYRA", "DAWN" } + local versions = { "firered", "leafgreen", "emerald", "ruby", "sapphire" } + local crowd = {} + for i, name in ipairs(names) do + crowd[i] = peer(string.format("c%07x", i), name, 0x100 + i * 13, i % 2, versions[(i - 1) % #versions + 1], statuses[i]) + end + result(waitFor(function() return Union.playerCount() == #names end, 10, 120), "the peers appear in the plaza") + result(waitFor(function() return VirtualObjects.count() == #names end, 5, 60), "as avatars") + wait(40) + + local LinkTags = package.loaded["src.ui.game3.link_tags"] + result(LinkTags ~= nil, "the badge layer is loaded in the Union Room") + local src = LinkTags and LinkTags.sources() + local kinds, named = {}, 0 + for slot = 1, Plaza.CAP do + local p = Union.players[slot] + local tag = p and src and src.byVobj[Union.vobjId(slot)] + if tag then + if tag.name == p.name then named = named + 1 end + if tag.kind then kinds[tag.kind] = true end + end + end + result(named == #names, "every avatar carries its relay name (" .. named .. ")") + result(kinds.chat and kinds.trade and kinds.battle, "chatting, trading and battling each get an icon") + result(src and src.player and src.player.name == "RED", "the player's own badge reads RED") + + place(12, 15, "up") + wait(10) + U.still(game, DIR .. "/" .. VERSION .. "_presence_01_badges_and_icons.png") + + local homes = {} + for slot = 1, Plaza.CAP do + if Union.players[slot] then homes[slot] = { Union.cellFor(slot) } end + end + place(1, 22, "up") + local moved, stepping, busyMoved = {}, false, false + for _ = 1, 1500 do + wait(1) + for slot, h in pairs(homes) do + local rec = VirtualObjects.get(Union.vobjId(slot)) + if rec and (rec.x ~= h[1] or rec.y ~= h[2]) then + moved[slot] = true + if Union.memberStatusKind(Union.players[slot]) then busyMoved = true end + if rec.moving and not stepping then + stepping = true + place(12, 15, "up") + wait(8) + U.still(game, DIR .. "/" .. VERSION .. "_presence_02_wander_mid_step.png") + place(1, 22, "up") + end + end + end + end + local n = 0 + for _ in pairs(moved) do n = n + 1 end + result(n >= 3, "idle avatars wander off their cells (" .. n .. ")") + result(not busyMoved, "chatting, trading and battling avatars stay put") + + place(12, 15, "up") + wait(30) + U.still(game, DIR .. "/" .. VERSION .. "_presence_03_after_wander.png") + + place(12, 22, "down") + wait(10) + for _ = 1, 20 do + if Map.current == CENTER_2F then break end + U.hold(game, "down", 8) + relay:pump() + end + result(waitFor(function() return Map.current == CENTER_2F end, 8, 300), "the exit warps back to the 2F") + wait(20) + result(LinkTags.sources() == nil, "no badges outside the link rooms") + + local LinkPlayers = require("src.core.game3.link.link_players") + local inbox = {} + local fake = { + role = "host", + update = function() end, + isOpen = function() return true end, + isReady = function() return true end, + getSeat = function() return 0 end, + send = function() return true end, + take = function(_, kind) + for i, m in ipairs(inbox) do + if m.type == kind then return table.remove(inbox, i) end + end + return nil + end, + players = function() + return { { seat = 0, name = "RED", gender = 0, version = "firered" }, + { seat = 1, name = "MAY", gender = 1, version = "emerald" } } + end, + } + Client.disconnect() + Map.load(nil, game, PER[VERSION].trade, { x = 4, y = 5, facing = "right" }) + place(4, 5, "right") + wait(30) + Link.link = fake + Link.startPump() + inbox[#inbox + 1] = { type = LinkPlayers.MSG, seat = 1, map = "tradeCenter", x = 7, y = 5, facing = "left", busy = true } + wait(30) + local tc = LinkTags.sources() + local mayTag = tc and tc.byVobj[LinkPlayers.VOBJ_BASE + 1] + result(mayTag and mayTag.name == "MAY", "the Trade Center partner wears her name") + result(mayTag and mayTag.kind == "trade", "and a trade icon while she is busy at the table") + result(tc and tc.player and tc.player.name == "RED", "and the player keeps his badge in the link room") + U.still(game, DIR .. "/" .. VERSION .. "_presence_04_trade_center.png") + Link.link = nil + wait(4) + finish() +end diff --git a/tests/emerald_f4_test.lua b/tests/emerald_f4_test.lua index 65675d31..51fcf564 100644 --- a/tests/emerald_f4_test.lua +++ b/tests/emerald_f4_test.lua @@ -373,8 +373,17 @@ eq(Rse.var("VAR_DEOXYS_ROCK_LEVEL", sess), 0, "level back to 0") sess.options = {} check(Islands.pendingGift(sess) == nil, "no gifts while the option is off") Islands.setEnabled(sess, true) +check(Islands.pendingGift(sess) == nil, "the option alone hands out nothing; the relay controls tickets") +eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 0, "no Eon Ticket without the relay") +local feedCards = {} +for _, b in ipairs(require("src.core.game3.mystery_gift").builtins("rse")) do feedCards[#feedCards + 1] = { key = b.key, card = b.card } end +Islands.applyFeed(sess, { cards = { feedCards[2] } }) +eq(Islands.pendingGift(sess).id, "aurora", "a relay AURORA TICKET card is offered") +check(not Islands.eonPending(sess), "the relay feed without the Eon card leaves the Eon Ticket off") +Islands.applyFeed(sess, { cards = feedCards }) +Islands.sync(sess, { offline = true }) eq(Islands.pendingGift(sess).id, "aurora", "the Aurora Ticket is the first wonder card") -eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 1, "the Eon Ticket distribution var is raised") +eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 1, "the relay Eon Ticket raises the distribution var") Rse.setFlag("FLAG_RECEIVED_AURORA_TICKET", true, sess) eq(Islands.pendingGift(sess).id, "mystic", "then the Mystic Ticket") Rse.setFlag("FLAG_RECEIVED_MYSTIC_TICKET", true, sess) diff --git a/tests/engine/box_events_test.lua b/tests/engine/box_events_test.lua index 2cb5f543..05e6c337 100644 --- a/tests/engine/box_events_test.lua +++ b/tests/engine/box_events_test.lua @@ -21,6 +21,23 @@ for _, version in ipairs({ "ruby", "sapphire", "emerald", "firered", "leafgreen" tables[prefix.."data/generated/gba/items/pack.lua"] = Serializer.encode({items=items}) end CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() +Events.autoFetch=false +local Gift=require("src.core.game3.mystery_gift") +local EON_EVENT=require("src.core.Base64").decode("AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/") +T.eq(Gift.eventRecordMixingItem(EON_EVENT),275,"the relay eon event hands out ITEM_EON_TICKET by record mixing") +for _,version in ipairs({"firered","leafgreen","emerald","ruby","sapphire"}) do + T.check(not Events.receive({version=version,flags={},vars={},modData={},bag=require("src.core.game3.bag").new()}, + version=="emerald" and "aurora" or (Events.FAMILY[version]=="rs" and "eon" or "aurora"),2), + version.." hands out no ticket before the relay feed arrives") +end +local function feedFor(version) + local fam=Events.FAMILY[version] + if fam=="rs" then return {cards={},news={},events={{key="rs_eon_ticket",label="EON TICKET",bytes=EON_EVENT}}} end + local cards={} + for _,b in ipairs(Gift.builtins(fam)) do cards[#cards+1]={key=b.key,card=b.card} end + return {cards=cards,news={}} +end +for _,version in ipairs({"firered","leafgreen","emerald","ruby","sapphire"}) do Events.setFeed(version,feedFor(version)) end T.check(not Events.allowed("oldSeaMap","emerald",2),"English Emerald cannot receive Old Sea Map") T.check(Events.allowed("oldSeaMap","emerald",1)~=nil,"Japanese Emerald has historical Old Sea Map distribution") T.check(not Events.allowed("aurora","emerald",1),"Japanese Emerald has no historical AuroraTicket distribution") @@ -104,5 +121,13 @@ for _,version in ipairs({"emerald","firered","leafgreen"}) do T.eq(Events.status(save,row),"Event already completed",version.." completed encounter is visible before receiving card") T.check(not Events.receive(save,"mystic",2),"completed encounter cannot create undeliverable pending card") end +Events.setFeed("firered",{cards={},news={}}) +do + local save,why=Events.receive({version="firered",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()},"aurora",2) + T.check(save==nil and tostring(why):find("not offering"),"a ticket the relay does not publish cannot be received: "..tostring(why)) +end +Events.setFeed("ruby",{cards={},news={},events={}}) +T.check(not Events.receive({version="ruby",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()},"eon",2), + "Ruby gets no Eon Ticket without the relay e-Reader event") CacheFs.readAt=oldRead;Catalog.reset() T.finish("Box events") diff --git a/tests/g3link_rs_mix_side.lua b/tests/g3link_rs_mix_side.lua new file mode 100644 index 00000000..4db38be5 --- /dev/null +++ b/tests/g3link_rs_mix_side.lua @@ -0,0 +1,45 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local version, seat, outFile, inFile = arg[1], tonumber(arg[2]), arg[3], arg[4] +local GameVersion = require("src.core.GameVersion") +require("src.core.game3.profile").reset() +GameVersion.set(version) +require("src.import.gba.versions").select(version) +require("src.core.game3.pokemon").install(nil) + +local Json = require("src.link.Json") +local Wire = require("src.link.Wire") +local Schema = require("src.core.game3.save_schema_firered") +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") +local RecordMix = require("src.core.game3.link.record_mix") + +local other = version == "emerald" and "ruby" or "emerald" +local s = Schema.newGame({ version = version, name = version == "emerald" and "MAY" or "BRENDAN", rngSeed = 7 + seat }) +s.trainerId, s.secretId, s.gender = 0x1000 + seat, 0, seat % 2 +s.party = { { species = 1, level = 5, nickname = "", heldItem = 0 } } +Space.store = Flags.newStore() +local partners = {} +partners[seat + 1] = { seat = seat, version = version } +partners[2 - seat] = { seat = 1 - seat, version = other } + +local mine = RecordMix.packet(s, seat, partners) +local text = Json.encode({ type = RecordMix.MSG.PACKET, spot = seat, packet = RecordMix.toWire(mine) }) +local f = assert(io.open(outFile, "wb")) +f:write(text) +f:close() +print("bytes " .. #text) +print("layout " .. tostring(mine.recordLayout)) +if not inFile then return end +local g = assert(io.open(inFile, "rb")) +local theirs = Wire.sanitize(Json.decode(g:read("*a"))) +g:close() +local list = {} +list[seat + 1] = mine +list[2 - seat] = RecordMix.fromWire(theirs.packet) +local applied = RecordMix.receive(s, list, seat + 1) +print("unsupported " .. tostring(applied and applied.unsupportedReason)) +for _, key in ipairs({ "secretBases", "tvShows", "pokeNews", "oldMan", "dewfordTrends", "daycareMail", "battleTower" }) do + print(key .. " " .. tostring(applied and applied[key])) +end diff --git a/tests/game3_gift_catalog_test.lua b/tests/game3_gift_catalog_test.lua new file mode 100644 index 00000000..bbb24764 --- /dev/null +++ b/tests/game3_gift_catalog_test.lua @@ -0,0 +1,153 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.fixture_data.game3_items").install() +love = love or require("tests.love_stub") +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local romBundle = require("tests.game3_cache").bundle() + +local FIXTURE_PUB = "72184f81ec076cc56ddc93d4cc2230433d1e81374126419ddd06593381c60edd" +local FEED_FRLG = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":2,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1001,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[680,754,755],\"item\":370,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2122,680]},\"iconSpecies\":0,\"idNumber\":1,\"key\":\"mystic_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MYSTIC TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[679,740],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2123,679]},\"iconSpecies\":0,\"idNumber\":2,\"key\":\"aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":1,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1003,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":984,\"kind\":\"egg\",\"moves\":{\"3\":57},\"slotVar\":16565,\"species\":172},\"iconSpecies\":172,\"idNumber\":3,\"key\":\"surf_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":4,\"bodyText\":[\"Thank you for using the STAMP CARD\",\"System.\",\"Trade with other TRAINERS to fill\",\"your STAMP CARD.\"],\"family\":\"frlg\",\"flagId\":1004,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"none\"},\"iconSpecies\":0,\"idNumber\":4,\"key\":\"stamp_card\",\"maxStamps\":7,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"STAMP CARD\",\"type\":1,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"Your BATTLE COUNT CARD keeps\",\"track of your battle record against\",\"TRAINERS with the same CARD.\",\"Look for them and battle!\"],\"family\":\"frlg\",\"flagId\":1005,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":984,\"item\":13,\"kind\":\"item\",\"quantity\":1,\"requireStat\":0,\"requireValue\":3},\"iconSpecies\":0,\"idNumber\":5,\"key\":\"battle_card\",\"maxStamps\":0,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"BATTLE COUNT CARD\",\"type\":2,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":6,\"bodyText\":[\"The POKéMON living in ALTERING\",\"CAVE have changed.\",\"Go and see what turns up\",\"on SIX ISLAND.\"],\"family\":\"frlg\",\"flagId\":1006,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"var\",\"slotVar\":16420,\"varAdd\":1,\"varWrap\":10},\"iconSpecies\":0,\"idNumber\":6,\"key\":\"altering_cave\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ALTERING CAVE\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":0,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":6930,\"otName\":\"MYSTRY\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1000,\"key\":\"ev000_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":1,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metGame\":2,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":0,\"otId\":20043,\"otName\":\"WISHMKR\",\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1001,\"key\":\"ev001_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metLevel\":0,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":2,\"otId\":40122,\"otName\":\"CHANNEL\",\"secretIdRandom\":true,\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1002,\"key\":\"ev002_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":5,\"metGame\":1,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":33,\"2\":45,\"3\":39},\"movesOnly\":true,\"nickname\":\"ジグザグマ\",\"otGender\":1,\"otId\":21121,\"otName\":\"ルビー\",\"shiny\":\"always\",\"species\":288},\"iconSpecies\":288,\"idNumber\":1003,\"key\":\"ev003_zigzagoon\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ZIGZAGOON\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":6,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":20078,\"otName\":\"Aura\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1078,\"key\":\"ev078_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":30,\"metGame\":2,\"metLevel\":30,\"metLocation\":255,\"moves\":{\"1\":36,\"2\":93,\"3\":232,\"4\":287},\"movesOnly\":true,\"nationalRibbon\":true,\"otGender\":0,\"otId\":2005,\"otName\":\"ROCKS\",\"shiny\":\"never\",\"species\":399},\"iconSpecies\":399,\"idNumber\":1079,\"key\":\"ev079_metang\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"METANG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"egg\",\"language\":1,\"metGame\":2,\"moves\":{\"1\":84,\"2\":204,\"3\":298},\"movesOnly\":true,\"otGender\":0,\"otName\":\"オヤNAME\",\"shiny\":\"always\",\"species\":172},\"iconSpecies\":172,\"idNumber\":1127,\"key\":\"ev127_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"egg\",\"moves\":{\"1\":281,\"2\":273},\"movesOnly\":true,\"shiny\":\"random\",\"species\":83},\"iconSpecies\":83,\"idNumber\":1141,\"key\":\"ev141_farfetch_d\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"セレビィ\",\"otGender\":1,\"otId\":31121,\"otName\":\"アゲト\",\"shiny\":\"never\",\"species\":251},\"iconSpecies\":251,\"idNumber\":1178,\"key\":\"ev178_celebi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"CELEBI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":50,\"metLevel\":50,\"metLocation\":255,\"moves\":{\"1\":85,\"2\":97,\"3\":87,\"4\":113},\"movesOnly\":true,\"otIdMax\":2999,\"otNames\":[\"PCNYb\",\"PCNYc\"],\"shiny\":\"never\",\"species\":25},\"iconSpecies\":25,\"idNumber\":1179,\"key\":\"ev179_pikachu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"PIKACHU\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"キモリ\",\"otId\":51126,\"otNames\":[\"トウキョー\",\"ヨコハマ\",\"ナゴヤ\",\"オーサカ\",\"フクオカ\",\"サッポロ\"],\"shiny\":\"never\",\"species\":277},\"iconSpecies\":277,\"idNumber\":1229,\"key\":\"ev229_treecko\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"TREECKO\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]}],\"family\":\"frlg\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"761fa0e8df514a1dd630cef130d7129a627f3742a509d5446dc2228f743238ec28e7247fe15d8f76596f936b3c77d2b38e5524d07fcf434da066027f573c2304","key":"72184f81ec076cc56ddc93d4cc2230433d1e81374126419ddd06593381c60edd"}]==] +local FEED_RSE = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":1,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1003,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[2227],\"item\":275,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2227]},\"iconSpecies\":0,\"idNumber\":101,\"key\":\"rse_eon_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"EON TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[314,429],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2261,314]},\"iconSpecies\":0,\"idNumber\":102,\"key\":\"rse_aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1001,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[315,145,146],\"item\":370,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2272,315]},\"iconSpecies\":0,\"idNumber\":103,\"key\":\"rse_mystic_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MYSTIC TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"We received this OLD SEA MAP\",\"addressed to you.\"],\"family\":\"rse\",\"flagId\":1002,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[316,458],\"item\":376,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2262,316]},\"iconSpecies\":0,\"idNumber\":104,\"key\":\"rse_old_sea_map\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"OLD SEA MAP\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":1,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"rse\",\"flagId\":1004,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":484,\"kind\":\"egg\",\"moves\":{\"3\":57},\"script\":\"surfPichu\",\"slotVar\":16605,\"species\":172},\"iconSpecies\":172,\"idNumber\":105,\"key\":\"rse_surf_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":4,\"bodyText\":[\"Thank you for using the STAMP CARD\",\"System.\",\"Trade with other TRAINERS to fill\",\"your STAMP CARD.\"],\"family\":\"rse\",\"flagId\":1005,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"none\",\"script\":\"stampCard\"},\"iconSpecies\":0,\"idNumber\":106,\"key\":\"rse_stamp_card\",\"maxStamps\":7,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"STAMP CARD\",\"type\":1,\"versions\":[\"emerald\"]},{\"bgType\":5,\"bodyText\":[\"Your BATTLE COUNT CARD keeps\",\"track of your battle record against\",\"TRAINERS with the same CARD.\",\"Look for them and battle!\"],\"family\":\"rse\",\"flagId\":1006,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":484,\"item\":13,\"kind\":\"item\",\"quantity\":1,\"requireStat\":0,\"requireValue\":3,\"script\":\"battleCard\"},\"iconSpecies\":0,\"idNumber\":107,\"key\":\"rse_battle_card\",\"maxStamps\":0,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"BATTLE COUNT CARD\",\"type\":2,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"The POKéMON living in ALTERING\",\"CAVE have changed.\",\"Go and see what turns up\",\"on ROUTE 103.\"],\"family\":\"rse\",\"flagId\":1007,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"var\",\"script\":\"alteringCave\",\"slotVar\":16446,\"varAdd\":1,\"varWrap\":10},\"iconSpecies\":0,\"idNumber\":108,\"key\":\"rse_altering_cave\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ALTERING CAVE\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":0,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":6930,\"otName\":\"MYSTRY\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1000,\"key\":\"rse_ev000_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":1,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metGame\":2,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":0,\"otId\":20043,\"otName\":\"WISHMKR\",\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1001,\"key\":\"rse_ev001_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metLevel\":0,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":2,\"otId\":40122,\"otName\":\"CHANNEL\",\"secretIdRandom\":true,\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1002,\"key\":\"rse_ev002_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":5,\"metGame\":1,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":33,\"2\":45,\"3\":39},\"movesOnly\":true,\"nickname\":\"ジグザグマ\",\"otGender\":1,\"otId\":21121,\"otName\":\"ルビー\",\"shiny\":\"always\",\"species\":288},\"iconSpecies\":288,\"idNumber\":1003,\"key\":\"rse_ev003_zigzagoon\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ZIGZAGOON\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":20078,\"otName\":\"Aura\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1078,\"key\":\"rse_ev078_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":7,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":30,\"metGame\":2,\"metLevel\":30,\"metLocation\":255,\"moves\":{\"1\":36,\"2\":93,\"3\":232,\"4\":287},\"movesOnly\":true,\"nationalRibbon\":true,\"otGender\":0,\"otId\":2005,\"otName\":\"ROCKS\",\"shiny\":\"never\",\"species\":399},\"iconSpecies\":399,\"idNumber\":1079,\"key\":\"rse_ev079_metang\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"METANG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":7,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"egg\",\"language\":1,\"metGame\":2,\"moves\":{\"1\":84,\"2\":204,\"3\":298},\"movesOnly\":true,\"otGender\":0,\"otName\":\"オヤNAME\",\"shiny\":\"always\",\"species\":172},\"iconSpecies\":172,\"idNumber\":1127,\"key\":\"rse_ev127_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"セレビィ\",\"otGender\":1,\"otId\":31121,\"otName\":\"アゲト\",\"shiny\":\"never\",\"species\":251},\"iconSpecies\":251,\"idNumber\":1178,\"key\":\"rse_ev178_celebi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"CELEBI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":50,\"metLevel\":50,\"metLocation\":255,\"moves\":{\"1\":85,\"2\":97,\"3\":87,\"4\":113},\"movesOnly\":true,\"otIdMax\":2999,\"otNames\":[\"PCNYb\",\"PCNYc\"],\"shiny\":\"never\",\"species\":25},\"iconSpecies\":25,\"idNumber\":1179,\"key\":\"rse_ev179_pikachu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"PIKACHU\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":5,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"キモリ\",\"otId\":51126,\"otNames\":[\"トウキョー\",\"ヨコハマ\",\"ナゴヤ\",\"オーサカ\",\"フクオカ\",\"サッポロ\"],\"shiny\":\"never\",\"species\":277},\"iconSpecies\":277,\"idNumber\":1229,\"key\":\"rse_ev229_treecko\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"TREECKO\",\"type\":0,\"versions\":[\"emerald\"]}],\"family\":\"rse\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"802db621006837278b5fb0f24a8c6c9a77a339e4794644fdf7ebfb22815d41d69c5e1a25ebd78938a86fb02393bfb09524a294c968a0cee693d1134204f5350c","key":"72184f81ec076cc56ddc93d4cc2230433d1e81374126419ddd06593381c60edd"}]==] +local FEED_RS = [==[{"v":1,"payload":"{\"cards\":[],\"events\":[{\"family\":\"rs\",\"key\":\"rs_eon_ticket\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/\",\"title\":\"EON TICKET\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_gift_ribbon\",\"payload\":\"AZsIAAICAAIAAAAEAIABAAAQTGwAALkIAAK9CAACCAABAg==\",\"title\":\"GIFT RIBBON\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev000_mew\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQDIYAAB4AAAKsAAACDCQAAAIC6rS/fxIbAADHv9H/////////AgLH083OzNP/AEt4AAD4ULVeM5lEUPivv/9vr79/yK2/f/jLv3/5ry9/+K+/f9ulv3/4r79/+K+/f/ivv38AAAAACv8pACkAGwAaABgAHAAdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"MEW\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev001_jirachi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ26MAAB4AAAKsAAACDCQAAAICsPZhQktOAADEw8y7vcLD////AgLRw83Cx8XMAJ//AABiuWFCZ7hhQvvcYULquTxCZ7hhQvGha0L7uGFC+7hhQvu4YUL7R2Rjsg+fZPu4YcIAAAAABf8ZABkAEAAPABAADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"JIRACHI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev002_jirachi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ47EAAB4AAAKsAAACDCQAAAICdjgEBbqcnsnEw8y7vcLD////AgK9wrvIyL/GABt3AADdpcfMUKSazMa9kMxVpZrMUKSazMzAmszMpJrMzKSazMykmszMW5ptCxOb0sykmkwAAAAABf8ZABkAEQAPAA8ADQAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"JIRACHI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev003_zigzagoon\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQv1oAAB4AAAKsAAACDCQAAAICJJWnx4FSAACNiYyJb///////AQJ5l67/////ACHdAAClOCJn4f+i0qXHp0eEx4rHgsenx4bvuceFxqfH2Menx6WBp8elx6fHpcenx6XHp8cAAAAABf8TABMACAAIAAsACAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"ZIGZAGOON\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev078_mew\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ3ekAAB4AAAKsAAACDCQAAAICVb9KzW5OAADHv9H/////////AgK76ebV////AJghAAA7DkDsICyy/zvxSk068drNO/FKzRj7Ss078UrNO/FKzTvxSs2s8UrNC/NKzTuVSs0AAAAACv8qACoAGQAbABoAGgAbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"MEW\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev079_metang\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQooIAAB4AAAKsAAACDCQAAAICHAHtj9UHAADHv867yMH/////AgLMyb3Fzf//ANYAAABGB+2PH4Xtj8kl7Y/J+fOuUVsRpskG7Q7tBrCPIQbyjt0fzpvJBu2PyQbtj8kG7Y8AAAAAHv9TAFMAOgBHACoALwA1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"METANG\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev127_pichu\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQyzQAAB4AAAKsAAACDCQAAAICag5pDgAAAABgb4v/////////AQZVdMi7x7//AG1tAADGDmkOFw5pDmpIaQ5qDmkOag5pDmoOaQ4+DqUOQA9pDnQafQ5q8WwvRvv+dGoOaY4AAAAABf8RABEACQAHAAsACAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"POKéMON EGG\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev178_celebi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQLaQAAB4AAAKsAAACDCQAAAICzHDO1JF5AABeepeA////////AQJRimT/////AEB1AABd9sR1ZYfz5F0JzlQACafUigkV1EQdy82mCc7UbQvO1F1tztRdCc7UXQnO1F0JztQAAAAACv8qACoAGgAbABsAGQAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"CELEBI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev179_pikachu\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQvXkAAB4AAAKsAAACDCQAAAICkrJxl94CAADKw8W7vcLP////AgLKvcjT1///ACHCAABMT0O2+j4xsEywcRdVsHGXBFhwl0z2cZcZsBCXG7AAl0Oue4lMsHGXTLBxl0ywcZcAAAAAMv9qAGoARgAkAF8AOgA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"PIKACHU\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev229_treecko\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQVTcAAB4AAAKsAAACDCQAAAICToszm7bHAABXc3j/////////AQJ2Wmpv////ACFVAAD5TBibv0wzm9tSJ5vtTTObyE4zm/gKM5v4TDOb+Ewzm/hMM5v4szm6vWvQvvhMMxsAAAAACv8cABwAEQAMABMAEgARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"TREECKO\",\"versions\":[\"ruby\",\"sapphire\"]}],\"family\":\"rs\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"0b977696d237074135c5d95c5b957f45f523552aa596c56ed28ce590808e4b99128b792c91545f9e0bdfe20c9ff75ae8bdd44fbbffb8b76eb42e1f67140ce308","key":"72184f81ec076cc56ddc93d4cc2230433d1e81374126419ddd06593381c60edd"}]==] + + +local Json = require("src.link.Json") +local MysteryGift = require("src.core.game3.mystery_gift") +local Bag = require("src.core.game3.bag") +local Schema = require("src.core.game3.save_schema_firered") +MysteryGift.GIFT_PUBKEY = FIXTURE_PUB + +local FEEDS = { frlg = FEED_FRLG, rse = FEED_RSE, rs = FEED_RS } +local FAMILY_GAMES = { frlg = { "firered", "leafgreen" }, rse = { "emerald" }, rs = { "ruby", "sapphire" } } + +local function raw(family) + return Json.decode(Json.decode(FEEDS[family]).payload) +end + +local function session(version) + local s = Schema.newGame({ rngSeed = 0x99 }) + s.version = version + s.store = { flags = {}, vars = {} } + s.bag = Bag.new() + s.party = {} + return s +end + +print("[test] 1. Every card preset reaches exactly the games it lists") +for _, family in ipairs({ "frlg", "rse" }) do + for _, row in ipairs(raw(family).cards) do + for _, game in ipairs(FAMILY_GAMES[family]) do + local list = assert(MysteryGift.verifyFeed(Json.decode(FEEDS[family]), family, game)) + local found = false + for _, e in ipairs(list.cards) do if e.key == row.key then found = true end end + eq(found, MysteryGift.offeredIn(row.versions, game), row.key .. " on " .. game) + end + end +end + +print("[test] 2. Every non-mon card preset delivers on its own game") +for _, family in ipairs({ "frlg", "rse" }) do + for _, row in ipairs(raw(family).cards) do + local game = row.versions[1] + local s = session(game) + local list = MysteryGift.verifyFeed(Json.decode(FEEDS[family]), family, game) + local entry + for _, e in ipairs(list.cards) do if e.key == row.key then entry = e end end + check(entry ~= nil, row.key .. " parses on " .. game) + if entry then + check(MysteryGift.receiveCard(s, entry.card), row.key .. " is saved") + local kind = entry.card.gift.kind + local script = MysteryGift.ramScriptId(s, entry.card) + if script then + -- pokeemerald/src/script.c:441 GetSavedRamScriptIfValid + local known = { eon = true, aurora = true, mystic = true, oldSeaMap = true, surfPichu = true, + stampCard = true, battleCard = true, alteringCave = true } + check(known[script] == true, row.key .. " runs the Emerald ROM gift script " .. tostring(script)) + elseif kind == "item" and not entry.card.gift.requireStat then + eq(MysteryGift.deliverGift(s, entry.card), MysteryGift.DELIVER_GIVEN, row.key .. " is handed over") + check(Bag.has(s.bag, entry.card.gift.item, 1), row.key .. " lands in the bag") + elseif kind == "var" then + eq(MysteryGift.deliverGift(s, entry.card), MysteryGift.DELIVER_GIVEN, row.key .. " bumps its variable") + elseif kind == "none" or entry.card.gift.requireStat then + eq(MysteryGift.deliverGift(s, entry.card), MysteryGift.DELIVER_NOTHING, row.key .. " is a card only until earned") + end + end + end +end + +print("[test] 3. Event Pokémon cards follow their PKHeX templates") +local bit = require("bit") +local function shinyValue(mon) + local p = mon.personality % 4294967296 + return bit.bxor(bit.bxor(mon.otId % 65536, (mon.otSecretId or 0) % 65536), + bit.bxor(math.floor(p / 65536), p % 65536)) +end +if not romBundle then + print("[skip] no FireRed cache for the species data") +else + local Ribbons = require("src.core.game3.rse.ribbons") + for _, family in ipairs({ "frlg", "rse" }) do + for _, row in ipairs(raw(family).cards) do + local g = row.gift + if (g.kind == "mon" or g.kind == "egg") and g.shiny then + local game = row.versions[1] + for round = 1, 8 do + local s = session(game) + require("src.core.game3.rng").SeedRng(round * 7919) + local list = MysteryGift.verifyFeed(Json.decode(FEEDS[family]), family, game) + local entry + for _, e in ipairs(list.cards) do if e.key == row.key then entry = e end end + check(MysteryGift.receiveCard(s, entry.card), row.key .. " is saved") + eq(MysteryGift.deliverGift(s, entry.card), MysteryGift.DELIVER_GIVEN, row.key .. " joins the party") + eq(MysteryGift.deliverGift(s, entry.card), MysteryGift.DELIVER_ALREADY, row.key .. " only once") + local mon = s.party[#s.party] + eq(mon and mon.species, g.species, row.key .. " species") + if round == 1 then + if g.kind == "mon" then eq(mon.level, g.level, row.key .. " level") end + if g.otName then eq(mon.otName, g.otName, row.key .. " OT " .. g.otName) end + if g.otNames then + local hit = false + for _, n in ipairs(g.otNames) do if n == mon.otName then hit = true end end + check(hit, row.key .. " OT is one of its choices: " .. tostring(mon.otName)) + end + if g.otIdMax then check(mon.otId >= 1 and mon.otId <= g.otIdMax, row.key .. " TID rolled in range") + elseif g.otId then eq(mon.otId, g.otId % 65536, row.key .. " TID") end + if g.otId and not g.secretIdRandom then eq(mon.otSecretId, 0, row.key .. " SID 0") end + if g.language then eq(mon.language, g.language, row.key .. " language") end + if g.otGender == 0 or g.otGender == 1 then eq(mon.otGender, g.otGender, row.key .. " OT gender") end + eq(mon.modernFatefulEncounter, g.fateful ~= false, row.key .. " fateful flag") + if g.nationalRibbon then eq(Ribbons.get(mon, "national"), 1, row.key .. " National Ribbon") end + if g.movesOnly then + for slot = 1, 4 do eq(mon.moves[slot], g.moves[tostring(slot)], row.key .. " move " .. slot) end + end + if g.nickname then eq(mon.nickname, g.nickname, row.key .. " nickname") end + end + if g.shiny == "never" then check(shinyValue(mon) >= 8, row.key .. " is never shiny") end + if g.shiny == "always" then check(shinyValue(mon) < 8, row.key .. " is always shiny") end + end + end + end + end +end + +print("[test] 4. Every Ruby/Sapphire event passes the cart header on both games") +for _, game in ipairs({ "ruby", "sapphire" }) do + local list = assert(MysteryGift.verifyFeed(Json.decode(FEED_RS), "rs", game)) + eq(#list.events, #raw("rs").events, "every rs event is listed on " .. game) + for _, ev in ipairs(list.events) do + check(MysteryGift.eventHeaderOk(ev.bytes, game), ev.key .. " passes me_checkcompat on " .. game) + end +end + +print("[test] 5. News reaches every game") +for family, games in pairs(FAMILY_GAMES) do + for _, game in ipairs(games) do + local list = MysteryGift.verifyFeed(Json.decode(FEEDS[family]), family, game) + eq(list and #list.news, 1, "the news reaches " .. game) + end +end + +T.finish("game3_gift_catalog") diff --git a/tests/game3_gift_feed_test.lua b/tests/game3_gift_feed_test.lua index 78a82912..2ef5ef55 100644 --- a/tests/game3_gift_feed_test.lua +++ b/tests/game3_gift_feed_test.lua @@ -121,7 +121,7 @@ local job = MysteryGift.fetchOnline({ transport = okT }) eq(job.status, "pending", "the fetch starts") local req = okT.begun[1] check(req ~= nil and req.method == "GET", "it is a GET") -check(req ~= nil and req.url:sub(-#"/gifts/gen3") == "/gifts/gen3", "to /gifts/gen3: " .. tostring(req and req.url)) +check(req ~= nil and req.url:find("/gifts/gen3", 1, true) ~= nil, "to /gifts/gen3: " .. tostring(req and req.url)) check(req ~= nil and req.headers["x-sync-token"] == nil, "without sync credentials") eq(req and req.maxSeconds, 10, "with a ten second budget") eq((MysteryGift.pollOnline(job)), "pending", "pending while the transport is") diff --git a/tests/game3_gift_menu_test.lua b/tests/game3_gift_menu_test.lua index a11c5434..3fb6d370 100644 --- a/tests/game3_gift_menu_test.lua +++ b/tests/game3_gift_menu_test.lua @@ -21,7 +21,7 @@ love = love or require("tests.love_stub") local romBundle = require("tests.game3_cache").bundle() if not romBundle then local LIST_ROWS = { sListMenuItems_CardsOrNews = 3, sListMenuItems_ReceiveSendToss = 4, sListMenuItems_ReceiveToss = 3, - sListMenuItems_WirelessOrFriend = 3 } + sListMenuItems_WirelessOrFriend = 3, sListMenuItems_ReceiveSend = 3, sListMenuItems_Receive = 2 } 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, @@ -145,12 +145,12 @@ local function giftSession(save) end local function openWireless(st, d, isNews) - if isNews then - d.step("down") - d.step("a") - return - end + if isNews then d.step("down") end d.step("a") + if st.state == Ui.STATE.SAVED_VIEW then + d.step("a") + d.step("a") + end d.waitFor(function() return st.state == Ui.STATE.SEARCHING end) end @@ -259,7 +259,11 @@ do teq(rows[3], "EXIT", "EXIT is the third row") step("a") - eq(st.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches the card list at once") + -- pokefirered/src/mystery_gift_menu.c:1147 MG_STATE_DONT_HAVE_ANY + eq(st.state, Ui.STATE.RESULT_MSG, "with no card held the don't-have text comes first") + eq(feedTransport.calls, 0, "nothing is fetched yet") + d.waitFor(function() return st.state == Ui.STATE.SEARCHING end) + eq(st.state, Ui.STATE.SEARCHING, "then WONDER CARDS fetches the card list") eq(feedTransport.calls, 1, "one request goes out") eq(WirelessIcon.anim(), "searching", "the wireless icon plays its searching anim") tcheck(type(st.prompt) == "string" and st.prompt:find("Searching for a WIRELESS"), @@ -306,7 +310,22 @@ print("[test] 5. The list marks the held card and opens it") do local d = driver(st) d.step("a") - eq(st.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches again") + -- pokefirered/src/mystery_gift_menu.c:1390 MG_STATE_LOAD_GIFT + eq(st.state, Ui.STATE.SAVED_VIEW, "WONDER CARDS shows the held card first") + eq(Ui.card(st).titleText, "MYSTIC TICKET", "the saved card is on screen") + local okSaved, errSaved = pcall(Ui.draw, st) + tcheck(okSaved, "the saved card draws: " .. tostring(errSaved)) + d.step("a") + eq(st.state, Ui.STATE.GIFT_MENU, "A opens the card's menu") + eq(#st.rows, 3, "a keep-only card offers RECEIVE, TOSS and CANCEL") + eq(st.giftActions[2], "toss", "the second row tosses") + local okMenu, errMenu = pcall(Ui.draw, st) + tcheck(okMenu, "the card menu draws: " .. tostring(errMenu)) + d.step("b") + eq(st.state, Ui.STATE.SAVED_VIEW, "B goes back to the card") + d.step("a") + d.step("a") + eq(st.state, Ui.STATE.SEARCHING, "RECEIVE fetches again") d.waitFor(function() return st.state ~= Ui.STATE.SEARCHING end) eq(st.state, Ui.STATE.OFFER_LIST, "the list opens") local lm = ListStub.last @@ -378,7 +397,7 @@ print("[test] 7. Wonder News over WIRELESS COMMUNICATION") do local st2, d, sess2 = freshScreen(okFeed()) openWireless(st2, d, true) - eq(st2.state, Ui.STATE.SEARCHING, "WONDER NEWS fetches the list at once") + eq(st2.state, Ui.STATE.SEARCHING, "WONDER NEWS fetches the list") searchResult(st2, d) eq(st2.state, Ui.STATE.OFFER_LIST, "the news list opens") eq(#ListStub.last.items, 1, "only news rows are listed") @@ -404,9 +423,10 @@ do d.step("a") d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end) eq(MysteryGift.getSavedCard(sess2).idNumber, 7, "the MEW card is installed") - local giftFlag = MysteryGift.receivedGiftFlag(MysteryGift.getSavedCard(sess2).flagId) - check(giftFlag ~= nil, "the MEW card has a received-gift flag") - MysteryGift.setFlag(sess2, giftFlag, true) + check(MysteryGift.isGiftNotReceived(sess2), "the MEW is not collected yet") + local rec = MysteryGift.ensure(sess2) + rec.deliveredIds = { 7 } + check(MysteryGift.wasDelivered(sess2, MysteryGift.getSavedCard(sess2)), "a Pokemon card is tracked by its ID number") MysteryGift.clearCardAndRelated(sess2) check(not MysteryGift.validateSavedCard(sess2), "then tossed") @@ -439,7 +459,7 @@ print("[test] 9. Replacing a held card asks first") do local st2, d, sess2 = freshScreen(okFeed()) MysteryGift.receiveCard(sess2, MysteryGift.builtins()[2].card) - d.step("a") + openWireless(st2, d, false) eq(st2.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches the list") searchResult(st2, d) d.step("a") @@ -582,13 +602,111 @@ do teq(locked.rows[2], "EXIT", "row 2 is EXIT") Ui.update(locked, function(k) return k == "a" end, 1 / 60) check(locked.isNews, "the first row opens the news branch") - eq(locked.state, Ui.STATE.SEARCHING, "which fetches the news list") + eq(locked.state, Ui.STATE.RESULT_MSG, "which says no news is held before fetching") local open = Ui.new({ session = giftSession({}), onSave = function() return true end }) eq(#open.rows, 3, "the passphrase adds WONDER CARDS back") teq(open.rows[1], "WONDER CARDS", "on the first row") end +print("[test] 14. Receiving Wonder News from the relay saves it and sets the reward") +do + local save = {} + local st2, d, sess2 = freshScreen(okFeed(), save) + local before = #fanfares + openWireless(st2, d, true) + searchResult(st2, d) + d.step("a") + eq(st2.state, Ui.STATE.NEWS_VIEW, "the news opens") + d.step("a") + eq(st2.state, Ui.STATE.RESULT_MSG, "A receives it") + check(MysteryGift.validateSavedNews(sess2), "the news is saved") + check(MysteryGift.hasClaimedNews(sess2, 1), "and claimed") + eq(#fanfares, before + 1, "the received fanfare plays") + tcheck(st2.msg ~= nil and tostring(st2.msg.text):find("NEWS"), "with the news-received text") + eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_NONE, + "the reward is not set before the message ends") + d.waitFor(function() return st2.state == Ui.STATE.SAVE end) + -- pokefirered/src/mystery_gift_menu.c:1369 WonderNews_SetReward(WONDER_NEWS_RECV_WIRELESS) + eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_RECV_WIRELESS, + "the wireless reward is set once the message is done") + d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end) + check(type(save.modData) == "table" and save.modData.mysteryGift.news ~= nil, "the news reached the save") + + openWireless(st2, d, true) + eq(st2.state, Ui.STATE.SEARCHING, "the saved news offers RECEIVE") + searchResult(st2, d) + eq(ListStub.last.items[1].colors, ListStub.COLOR_WHITE, "the held news prints greyed") + MysteryGift.getSavedNewsMetadata(sess2).newsType = MysteryGift.WONDER_NEWS_NONE + d.step("a") + d.step("a") + eq(st2.state, Ui.STATE.RESULT_MSG, "the same news again ends the link") + tcheck(st2.msg ~= nil and tostring(st2.msg.text):find("already"), "with the already-had text") + d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU end) + eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_NONE, + "already-had news earns no reward") +end + +print("[test] 15. The saved card menu tosses a card") +do + local save = {} + local saves = 0 + local sess2 = giftSession(save) + local st2 = Ui.new({ session = sess2, fetch = { transport = okFeed() }, + onSave = function(s) saves = saves + 1 return MysteryGift.applyToSave(s, save) end }) + local d = driver(st2) + check(MysteryGift.receiveCard(sess2, MysteryGift.builtins()[2].card), "an uncollected AURORA card is held") + d.step("a") + eq(st2.state, Ui.STATE.SAVED_VIEW, "the held card shows") + d.step("a") + d.step("down") + d.step("a") + eq(st2.state, Ui.STATE.ASK_TOSS, "TOSS asks first") + check(st2.yesno ~= nil, "with a yes/no") + d.step("a") + check(st2.yesno ~= nil and st2.state == Ui.STATE.ASK_TOSS, "an uncollected gift asks a second time") + d.step("b") + eq(st2.state, Ui.STATE.GIFT_MENU, "NO goes back to the menu") + check(MysteryGift.validateSavedCard(sess2), "and keeps the card") + d.step("down") + d.step("a") + d.step("a") + d.step("a") + check(not MysteryGift.validateSavedCard(sess2), "YES twice throws the card away") + d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end) + eq(saves, 1, "the toss is saved once") + check(save.modData.mysteryGift.card == nil, "the save has no card") +end + +print("[test] 16. A shareable card has no SEND row") +do + local sess2 = giftSession({}) + local st2 = Ui.new({ session = sess2, onSave = function() return true end }) + local d = driver(st2) + check(MysteryGift.receiveCard(sess2, MysteryGift.builtins()[4].card), "a shareable STAMP CARD is held") + d.step("a") + d.step("a") + eq(#st2.rows, 3, "the menu is RECEIVE, TOSS and CANCEL") + eq(table.concat(st2.giftActions, ","), "receive,toss,cancel", "with no send action") + check(MysteryGift.sendToFriend == nil, "there is no friend-send hook") + d.step("b") + d.step("b") + eq(st2.state, Ui.STATE.MAIN_MENU, "B twice goes back to the menu") +end + +print("[test] 17. Held news offers RECEIVE and CANCEL only") +do + local sess2 = giftSession({}) + local st2 = Ui.new({ session = sess2, onSave = function() return true end }) + local d = driver(st2) + check(MysteryGift.receiveNews(sess2, { id = 3, sendType = 1, titleText = "NEWS", bodyText = {} }), "shareable news is held") + d.step("down") + d.step("a") + eq(st2.state, Ui.STATE.SAVED_VIEW, "the held news shows") + d.step("a") + eq(table.concat(st2.giftActions, ","), "receive,cancel", "news has no send or toss") +end + if failed == 0 then print("PASS game3_gift_menu") else diff --git a/tests/game3_gift_rse_test.lua b/tests/game3_gift_rse_test.lua index 2eda7360..fb66dea4 100644 --- a/tests/game3_gift_rse_test.lua +++ b/tests/game3_gift_rse_test.lua @@ -218,10 +218,10 @@ do eq(rse and #rse.cards, 1, "rse sees one card") eq(rse and rse.cards[1].key, "em", "the rse row") eq(rse and rse.cards[1].card.gift.setFlags[1], F("FLAG_ENABLE_SHIP_BIRTH_ISLAND"), "names resolve with Emerald ids") - eq(rse and #rse.news, 1, "rse sees one news") + eq(rse and #rse.news, 2, "rse sees every news, news has no family limit") local frlg = MysteryGift.parseFeed(payload, "frlg") eq(frlg and #frlg.cards, 2, "frlg sees its row and the unlabeled one") - eq(frlg and #frlg.news, 1, "frlg sees the unlabeled news") + eq(frlg and #frlg.news, 2, "frlg sees every news too") local begun = {} local transport = { @@ -231,10 +231,15 @@ do } local job = MysteryGift.fetchOnline({ transport = transport, session = newSession("emerald") }) eq(job.family, "rse", "the Emerald job carries its family") - check(begun[1] and begun[1].url:sub(-#"/gifts/gen3?family=rse") == "/gifts/gen3?family=rse", - "Emerald fetches ?family=rse: " .. tostring(begun[1] and begun[1].url)) + local emPath = "/gifts/gen3?family=rse&version=emerald" + check(begun[1] and begun[1].url:sub(-#emPath) == emPath, + "Emerald fetches ?family=rse and names its game: " .. tostring(begun[1] and begun[1].url)) MysteryGift.fetchOnline({ transport = transport, session = newSession("firered") }) - check(begun[2] and begun[2].url:sub(-#"/gifts/gen3") == "/gifts/gen3", "FireRed keeps the bare path") + check(begun[2] and begun[2].url:sub(-#"/gifts/gen3?version=firered") == "/gifts/gen3?version=firered", + "FireRed keeps the frlg path and names its game") + MysteryGift.fetchOnline({ transport = transport, session = newSession("leafgreen") }) + check(begun[3] and begun[3].url:sub(-#"/gifts/gen3?version=leafgreen") == "/gifts/gen3?version=leafgreen", + "LeafGreen names its own game") end print("[test] 11. LINK TOGETHER WITH ALL") diff --git a/tests/game3_gift_versions_test.lua b/tests/game3_gift_versions_test.lua new file mode 100644 index 00000000..d282fe2f --- /dev/null +++ b/tests/game3_gift_versions_test.lua @@ -0,0 +1,91 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.fixture_data.game3_items").install() +love = love or require("tests.love_stub") +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local FIXTURE_PUB = "b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d" +local FEED_FRLG = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[679,740],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2123,679]},\"iconSpecies\":0,\"idNumber\":2,\"key\":\"aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"A mythical POKéMON has been\",\"sent to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1007,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"mon\",\"level\":10,\"moves\":{\"1\":1},\"otId\":20078,\"otName\":\"AURA\",\"species\":151},\"iconSpecies\":151,\"idNumber\":7,\"key\":\"lg_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"leafgreen\"]}],\"family\":\"frlg\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"f21a2cff1f284e7f31f507d27a2190b765f4839ff72077fe31321dce8bb655d460522748b79ef455500699012940a3509286b56820b8a2a657676226d5e9840c","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==] +local FEED_RSE = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":6,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"We received this OLD SEA MAP\",\"addressed to you.\"],\"family\":\"rse\",\"flagId\":1002,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[316,458],\"item\":376,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2262,316]},\"iconSpecies\":0,\"idNumber\":104,\"key\":\"rse_old_sea_map\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"OLD SEA MAP\",\"type\":0,\"versions\":[\"emerald\"]}],\"family\":\"rse\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"e10196799b4ff259bbf3b20f9ed133d5041dabf4f78bba4a3612d9464ecd2a693f95035e324697f0c31ae00efbcf33c43496882b7af03aeee2a5c99fe0a28f03","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==] +local FEED_RS = [==[{"v":1,"payload":"{\"cards\":[],\"events\":[{\"family\":\"rs\",\"key\":\"rs_eon_ticket\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/\",\"title\":\"EON TICKET\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_gift_ribbon\",\"payload\":\"AZsIAAICAAIAAAAEAIABAAAQTGwAALkIAAK9CAACCAABAg==\",\"title\":\"GIFT RIBBON\",\"versions\":[\"ruby\",\"sapphire\"]}],\"family\":\"rs\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"ff759a880d1e32cd6e8962c86e3cb2f56b729b973545f4b748eaa7d571961c6690fae7034d36ef819f7127791d9bf972621097eab735f782558fa8cd1e9a680d","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==] + + +local Json = require("src.link.Json") +local MysteryGift = require("src.core.game3.mystery_gift") +local Bag = require("src.core.game3.bag") +local Schema = require("src.core.game3.save_schema_firered") +MysteryGift.GIFT_PUBKEY = FIXTURE_PUB + +local function keys(list, field) + local out = {} + for _, e in ipairs(list or {}) do out[#out + 1] = e[field or "key"] end + return table.concat(out, ",") +end + +local function verify(body, family, version) + return MysteryGift.verifyFeed(Json.decode(body), family, version) +end + +print("[test] 1. Cards only reach the games that had them") +local fr = verify(FEED_FRLG, "frlg", "firered") +local lg = verify(FEED_FRLG, "frlg", "leafgreen") +eq(fr and keys(fr.cards), "aurora_ticket", "FireRed does not see the LeafGreen-only card") +eq(lg and keys(lg.cards), "aurora_ticket,lg_mew", "LeafGreen sees its own card") +eq(lg and lg.cards[1].card.versions and table.concat(lg.cards[1].card.versions, ","), "firered,leafgreen", + "the card keeps its game list") +local em = verify(FEED_RSE, "rse", "emerald") +eq(em and keys(em.cards), "rse_old_sea_map", "Emerald sees the OLD SEA MAP") +local any = verify(FEED_FRLG, "frlg", nil) +eq(any and #any.cards, 2, "with no game named nothing is dropped") + +print("[test] 2. Wonder News reaches every game") +for _, row in ipairs({ { "firered", FEED_FRLG, "frlg" }, { "leafgreen", FEED_FRLG, "frlg" }, + { "emerald", FEED_RSE, "rse" }, { "ruby", FEED_RS, "rs" }, { "sapphire", FEED_RS, "rs" } }) do + local list = verify(row[2], row[3], row[1]) + eq(list and keys(list.news), "kanto_news", "news reaches " .. row[1]) +end + +print("[test] 3. LeafGreen receives and collects its Wonder Card") +do + local session = Schema.newGame({ rngSeed = 0x77 }) + session.version = "leafgreen" + session.store = { flags = {}, vars = {} } + session.bag = Bag.new() + eq(MysteryGift.versionOf(session), "leafgreen", "the session is LeafGreen") + local entry = lg.cards[1] + check(MysteryGift.receiveCard(session, entry.card), "the relay AURORA TICKET card is saved on LeafGreen") + eq(MysteryGift.deliverGift(session, MysteryGift.getSavedCard(session)), MysteryGift.DELIVER_GIVEN, + "the deliveryman hands it over") + check(Bag.has(session.bag, MysteryGift.ITEM_AURORA_TICKET, 1), "the AURORA TICKET is in the bag") + -- pokefirered/data/mystery_event_msg.s:222 + check(MysteryGift.getFlag(session, 0x84B) and MysteryGift.getFlag(session, 0x2A7), + "FLAG_ENABLE_SHIP_BIRTH_ISLAND and FLAG_RECEIVED_AURORA_TICKET are set") +end + +print("[test] 4. The rs feed carries signed Mystery Events") +local ruby = verify(FEED_RS, "rs", "ruby") +local sapphire = verify(FEED_RS, "rs", "sapphire") +eq(ruby and keys(ruby.events), "rs_eon_ticket,rs_gift_ribbon", "Ruby lists both e-Reader events") +eq(sapphire and keys(sapphire.events), "rs_eon_ticket,rs_gift_ribbon", "and so does Sapphire") +eq(ruby and #ruby.cards, 0, "Ruby gets no Wonder Cards") +eq(ruby and ruby.events[1].label, "EON TICKET", "the event keeps its title") +check(ruby and #ruby.events[1].bytes > 17 and #ruby.events[1].bytes <= 0x7D4, "the bytes fit an e-Reader block") +-- pokeruby/data/debug_mystery_event_scripts.s:37 me_setrecordmixinggift 0x1, 0x5, ITEM_EON_TICKET +eq(MysteryGift.eventRecordMixingItem(ruby.events[1].bytes), 275, "the Eon event spreads ITEM_EON_TICKET") +eq(MysteryGift.eventRecordMixingItem(ruby.events[2].bytes), nil, "the ribbon event has no record mixing gift") +local forged = FEED_RS:gsub("EON TICKET", "EON TICKEU") +local bad, why = verify(forged, "rs", "ruby") +check(bad == nil and why == "bad_signature", "a tampered rs feed is refused") +local data = Json.decode(Json.decode(FEED_RS).payload) +local raw = require("src.core.Base64").decode(data.events[1].payload) +local rubyOnly = raw:sub(1, 13) .. string.char(0x80, 0, 0, 0) .. raw:sub(18) +data.events[1].payload = require("src.core.Base64").encode(rubyOnly) +local payload = Json.encode(data) +eq(#MysteryGift.parseFeed(payload, "rs", "ruby").events, 2, "a Ruby-only block still runs on Ruby") +eq(#MysteryGift.parseFeed(payload, "rs", "sapphire").events, 1, "but is dropped on Sapphire by its header") +data.events[2].versions = { "ruby" } +eq(#MysteryGift.parseFeed(Json.encode(data), "rs", "sapphire").events, 0, "and the feed game list drops the other") + +T.finish("game3_gift_versions") diff --git a/tests/game3_link_battle_test.lua b/tests/game3_link_battle_test.lua index 8d5bdf9b..98b8cc97 100644 --- a/tests/game3_link_battle_test.lua +++ b/tests/game3_link_battle_test.lua @@ -221,19 +221,19 @@ session.party = { charizard() } Link.reset() freshCtx() setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) +local connects = 0 +local Connect = { start = function() connects = connects + 1; return false, "offline" end, + state = function() return "offline" end } +package.loaded["src.online.Connect"] = Connect +package.loaded["src.online.ArenaData"] = { + liveProfile3 = function(_, rulesetId) return { engine = 3, version = "firered", rulesetId = rulesetId } end, +} +Link._live = nil local waited = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) --- pokefirered/src/cable_club.c:208-222 -check(waited, "with no cable yet the counter parks the script instead of answering") -eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "and reports LINKUP_ONGOING while it waits") -local spun = 0 -for _ = 1, LB.LINKUP_TICKS + 1 do - if ctx.nativePoll() then break end - spun = spun + 1 -end --- pokefirered/src/cable_club.c:482 TryLinkTimeout -eq(spun, LB.LINKUP_TICKS, "it waits the full TryLinkTimeout window before giving up") +check(not waited, "with no cable and no relay the counter answers at once") +eq(connects, 1, "after trying the relay once") eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR, - "and a partner that never arrives is LINKUP_CONNECTION_ERROR") + "and a relay that cannot be reached is LINKUP_CONNECTION_ERROR") local host, guest = FakeRelay.pair({ game = game }) pumpLink(host, guest) @@ -243,51 +243,67 @@ setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) local yielded = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) check(yielded, "the script yields while the linkup runs") eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "VAR_RESULT is LINKUP_ONGOING while it waits") -check(ctx.nativePoll() == false, "nothing is decided before the peer says what it picked") guest:update(0) -local peerPick = guest:take(LB.MSG.LINKUP) -eq(peerPick and peerPick.linkType, LB.LINKTYPE.SINGLE_BATTLE, - "the peer was told this machine picked LINKTYPE_SINGLE_BATTLE") -guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.SINGLE_BATTLE, players = 2 }) -host:update(0) -check(ctx.nativePoll() == true, "the poll settles once both picks are in") -eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "matching picks report LINKUP_SUCCESS") +eq(guest:take(LB.MSG.LINKUP), nil, "a relay link sends no linkup message: the hellos already carry the picks") +check(ctx.nativePoll() == true, "the poll settles from the hellos") +eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "a peer on the generic wireless link type is LINKUP_SUCCESS") eq(LB.state, "seat", "and the session is waiting for a colosseum seat") +Link.closeLink("test") +local Game3Link = require("src.link.Game3Link") +local function relayPair(hostType, guestType, seats) + local room = FakeRelay.room({ seats = seats or 2 }) + local a = Game3Link.attach(FakeRelay.transport(room, 0), { game = game, linkType = hostType, seat = 0, seats = room.seats }) + local b = Game3Link.attach(FakeRelay.transport(room, 1), { game = game, linkType = guestType, seat = 1, seats = room.seats }) + pumpLink(a, b) + return a, b +end +host, guest = relayPair(LB.LINKTYPE.SINGLE_BATTLE, LB.LINKTYPE.DOUBLE_BATTLE) +Link.attach(host) freshCtx() setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) -guest:update(0) -guest:take(LB.MSG.LINKUP) -guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 }) -host:update(0) ctx.nativePoll() eq(getVar(Link.VAR_RESULT), Link.LINKUP.DIFF_SELECTIONS, - "a peer that picked another mode reports LINKUP_DIFF_SELECTIONS") + "a cable peer that picked another mode reports LINKUP_DIFF_SELECTIONS") +Link.closeLink("test") +host, guest = relayPair(LB.LINKTYPE.MULTI_BATTLE, LB.LINKTYPE.MULTI_BATTLE) +Link.attach(host) freshCtx() setVar(Link.VAR_0x8004, Link.USING.MULTI_BATTLE) Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) -guest:update(0) -local multiPick = guest:take(LB.MSG.LINKUP) -eq(multiPick and multiPick.linkType, LB.LINKTYPE.MULTI_BATTLE, "MULTI BATTLE picks LINKTYPE_MULTI_BATTLE") -guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 }) -host:update(0) ctx.nativePoll() eq(getVar(Link.VAR_RESULT), Link.LINKUP.WRONG_NUM_PLAYERS, - "a four player mode on a two machine cable reports LINKUP_WRONG_NUM_PLAYERS") + "a four player mode on a two machine link reports LINKUP_WRONG_NUM_PLAYERS") +Link.closeLink("test") + +local lhost, lguest = Game3Link.loopback({ game = game }) +pumpLink(lhost, lguest) +Link.attach(lhost) +freshCtx() +setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) +Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) +check(ctx.nativePoll() == false, "a direct cable waits for the peer's pick") +lguest:update(0) +local peerPick = lguest:take(LB.MSG.LINKUP) +eq(peerPick and peerPick.linkType, LB.LINKTYPE.SINGLE_BATTLE, + "and tells the peer this machine picked LINKTYPE_SINGLE_BATTLE") +lguest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 }) +lhost:update(0) +ctx.nativePoll() +eq(getVar(Link.VAR_RESULT), Link.LINKUP.DIFF_SELECTIONS, "a different pick over the cable is LINKUP_DIFF_SELECTIONS") Link.closeLink("test") freshCtx() setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE) +connects = 0 Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters) -eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, - "a torn down link puts the counter back to waiting for a machine") -for _ = 1, LB.LINKUP_TICKS + 1 do - if ctx.nativePoll() then break end -end -eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR, - "and times out to LINKUP_CONNECTION_ERROR") +eq(connects, 1, "a torn down link sends the counter back to the relay") +eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR, "which answers LINKUP_CONNECTION_ERROR when unreachable") +package.loaded["src.online.Connect"] = nil +package.loaded["src.online.ArenaData"] = nil +Link._live = nil Link.reset() print("[test] 4. the shared seed drives one RNG stream on both machines") diff --git a/tests/game3_link_room_presence_test.lua b/tests/game3_link_room_presence_test.lua new file mode 100644 index 00000000..2b598fb7 --- /dev/null +++ b/tests/game3_link_room_presence_test.lua @@ -0,0 +1,184 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +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 session = { name = "RED", gender = 0, trainerId = 0x1234, version = "firered" } +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + _game = { session = session, data = { maps = {} } }, +} +local Player = { cellX = 1, cellY = 22, facing = "up" } +package.loaded["src.core.game3.player"] = Player + +local Plaza = require("src.core.game3.link.union_plaza_map") +package.loaded["src.core.game3.scripting.space"] = { mapId = Plaza.MAP_ID } +package.loaded["src.core.game3.collision"] = { + _mapId = Plaza.MAP_ID, + inBounds = function(x, y) return x >= 0 and y >= 0 and x < Plaza.WIDTH and y < Plaza.HEIGHT end, + isWater = function() return false end, + canEnter = function(_, x, y) return Plaza.roleAt(x, y) == "." end, +} +local VirtualObjects = require("src.core.game3.virtual_objects") +package.loaded["src.core.game3.objects"] = { + blocks = function(x, y) + for _, vo in ipairs(VirtualObjects.list()) do + if vo.solid and ((vo.x == x and vo.y == y) or (vo.prevX == x and vo.prevY == y)) then return true end + end + return false + end, +} + +local Wander = require("src.core.game3.link.plaza_wander") + +do + local function trace(seed) + local w = Wander.new(seed, 5, 5) + local env = { canWander = true, free = function() return true end } + local out = {} + for i = 1, 3000 do + Wander.tick(w, env) + out[i] = w.x * 100 + w.y + end + return out, w + end + local a = trace(42) + local b = trace(42) + local same, left, far = true, false, 0 + for i = 1, #a do + if a[i] ~= b[i] then same = false end + local x, y = math.floor(a[i] / 100), a[i] % 100 + if x ~= 5 or y ~= 5 then left = true end + far = math.max(far, math.abs(x - 5), math.abs(y - 5)) + end + check(same, "the same seed walks the same path") + check(left, "an idle avatar leaves its cell now and then") + check(far <= Wander.RADIUS, "and never strays past its radius (" .. far .. ")") + + local w = Wander.new(7, 5, 5) + local env = { canWander = true, free = function() return true end } + local guard = 0 + while Wander.atHome(w) and guard < 5000 do Wander.tick(w, env); guard = guard + 1 end + check(not Wander.atHome(w), "the avatar starts a walk") + env.canWander = false + for _ = 1, 400 do Wander.tick(w, env) end + check(Wander.atHome(w), "and walks back home when it may not wander") + local still = Wander.new(9, 5, 5) + for _ = 1, 3000 do Wander.tick(still, { canWander = false, free = function() return true end }) end + check(Wander.atHome(still), "a busy avatar never moves") +end + +local Union = require("src.core.game3.link.union_room") +Union.graphicsIdFor = function() return 1 end +Union._avatars = { opposite_facing = { 0, 2, 1, 4, 3 }, member_facing = { 1, 3, 1, 4, 2 } } +Union.state = "main" +Union._vobjs = {} +Union.players = { + [1] = { id = "b", name = "BLUE", gender = 0, trainerId = 2, status = "idle", activity = Union.ACTIVITY.NONE + Union.IN_UNION_ROOM }, + [2] = { id = "g", name = "GREEN", gender = 1, trainerId = 3, status = "chatting", activity = Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM }, + [3] = { id = "y", name = "YELLOW", gender = 1, trainerId = 4, status = "trading", activity = Union.ACTIVITY.TRADE + Union.IN_UNION_ROOM }, + [4] = { id = "p", name = "PINK", gender = 1, trainerId = 5, status = "battling", activity = Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM }, +} +Union.refreshPlaza() + +eq(Union.memberStatusKind(Union.players[1]), nil, "an idle member shows no status") +eq(Union.memberStatusKind(Union.players[2]), "chat", "chatting shows the chat icon") +eq(Union.memberStatusKind(Union.players[3]), "trade", "trading shows the trade icon") +eq(Union.memberStatusKind(Union.players[4]), "battle", "battling shows the battle icon") +eq(Union.memberStatusKind({ activity = Union.ACTIVITY.CARD + Union.IN_UNION_ROOM }), "chat", + "with no relay status the activity decides") + +local hx, hy = Union.cellFor(1) +local bx, by = Union.cellFor(2) +local leftHome, moved2, maxOff = false, false, 0 +local awayAt +for _ = 1, 4000 do + Union.animateAll() + local rec = VirtualObjects.get(Union.vobjId(1)) + if rec and (rec.x ~= hx or rec.y ~= hy) then + leftHome = true + maxOff = math.max(maxOff, math.abs(rec.x - hx), math.abs(rec.y - hy)) + if not rec.moving and not awayAt then awayAt = { rec.x, rec.y } end + end + local rec2 = VirtualObjects.get(Union.vobjId(2)) + if rec2 and (rec2.x ~= bx or rec2.y ~= by) then moved2 = true end + if awayAt then break end +end +check(leftHome, "an idle plaza avatar wanders off its cell") +check(maxOff <= 1, "inside its own 3x3 box") +check(not moved2, "a chatting avatar stays put") + +if awayAt then + Player.cellX, Player.cellY, Player.facing, Player.moving = awayAt[1], awayAt[2] + 1, "up", false + if Plaza.roleAt(Player.cellX, Player.cellY) ~= "." then + Player.cellY, Player.facing = awayAt[2] - 1, "down" + end + eq(Union.tryInteractWithMember(), 1, "talking reaches the avatar where it stands now") + for _ = 1, 200 do Union.animateAll() end + local rec = VirtualObjects.get(Union.vobjId(1)) + check(rec and rec.x == awayAt[1] and rec.y == awayAt[2], "and it holds still while talked to") + Union.updateMemberFacing(1) + Player.cellX, Player.cellY = 1, 22 + local home = false + for _ = 1, 4000 do + Union.animateAll() + local r = VirtualObjects.get(Union.vobjId(1)) + if r and r.x == hx and r.y == hy and not r.moving then home = true break end + end + check(home, "after the talk it walks back to its own cell") +else + check(false, "the idle avatar never stopped away from home") +end + +local LinkTags = require("src.ui.game3.link_tags") +local src = LinkTags.sources() +check(src ~= nil, "the Union Room has tags") +local t1 = src and src.byVobj[Union.vobjId(1)] +eq(t1 and t1.name, "BLUE", "the badge carries the relay avatar name") +eq(t1 and t1.kind, nil, "idle has no icon") +eq(src and src.byVobj[Union.vobjId(2)] and src.byVobj[Union.vobjId(2)].kind, "chat", "chatting badge") +eq(src and src.player and src.player.name, "RED", "the local player gets a badge too") +eq(LinkTags.tagFor(src, { kind = "player" }), src and src.player, "the player actor maps to its badge") +eq(LinkTags.tagFor(src, { kind = "npc", eventObject = { virtualId = Union.vobjId(3) } }), src and src.byVobj[Union.vobjId(3)], + "a plaza avatar maps to its badge") + +local measure = function(name) return #name * 6 end +local left = LinkTags.layout({ name = "LONGNAME", kind = "trade" }, 0, 40, 240, measure) +check(left.x >= LinkTags.EDGE, "a badge at the left edge stays on screen") +local right = LinkTags.layout({ name = "LONGNAME", kind = "battle" }, 240, 40, 240, measure) +check(right.x + right.w <= 240 - LinkTags.EDGE, "a badge at the right edge stays on screen") +local plain = LinkTags.layout({ name = "WALLACEMORE" }, 120, 40, 240, function() return 999 end) +eq(plain.textW, LinkTags.MAX_TEXT_W, "long names are clipped to the badge width") +for kind in pairs(LinkTags.ICONS) do + local w, h = LinkTags.iconSize(kind) + check(h <= LinkTags.PLATE_H and w > 0, kind .. " icon fits the badge") +end + +local LP = require("src.core.game3.link.link_players") +LP._mapId = "tradeCenter" +LP._remotes[1] = { busy = true, name = "GREEN", x = 4, y = 5 } +LP._remotes[2] = { busy = false, name = "GOLD", x = 7, y = 5 } +eq(LP.statusKind(1), "trade", "a busy player in the Trade Center is trading") +eq(LP.statusKind(2), nil, "an idle one has no icon") +LP._mapId = "colosseum2P" +eq(LP.statusKind(1), "battle", "a busy player in the Colosseum is battling") +src = LinkTags.sources() +eq(src and src.byVobj[LP.VOBJ_BASE + 1] and src.byVobj[LP.VOBJ_BASE + 1].name, "GREEN", "link room avatars get badges") +LP._remotes, LP._mapId = {}, nil + +if failed > 0 then + print(("FAIL game3_link_room_presence_test (%d)"):format(failed)) + os.exit(1) +end +print("PASS game3_link_room_presence_test") diff --git a/tests/game3_link_rs_cross_test.lua b/tests/game3_link_rs_cross_test.lua new file mode 100644 index 00000000..b1adbf02 --- /dev/null +++ b/tests/game3_link_rs_cross_test.lua @@ -0,0 +1,262 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +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 store = { flags = {}, vars = {} } +local session = { + version = "ruby", store = store, map = "RS_TEST_POKEMON_CENTER_2F", x = 5, y = 3, + name = "BRENDAN", gender = 0, trainerId = 0x2222, + party = { { species = 280, level = 12 }, { species = 283, level = 9 } }, + bag = { pockets = { items = {} } }, +} +local game = { data = { maps = {} }, session = session, input = { wasPressed = function() return false end }, + save = { player = { name = "BRENDAN" } } } +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + _game = game, +} +package.loaded["src.core.game3.player"] = { cellX = 5, cellY = 3, facing = "up" } +package.loaded["src.core.game3.map"] = { load = function() end } +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, + translate = function(ir) return ir 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, +} +local ctx = { specialVars = {}, stringVars = {} } +local adapters = { log = function() end, playSe = function() end } +package.loaded["src.core.game3.scripting.space"] = { + store = store, mapId = session.map, vm = { ctx = ctx, adapters = adapters }, + ensureBundle = function() return nil end, +} +package.loaded["src.online.ArenaData"] = { + liveProfile3 = function(_, rulesetId) + return { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla", + version = session.version, rulesetId = rulesetId } + end, +} +local Client = FakeRelay.client({ state = "online", id = "0000beef" }) +package.loaded["src.online.Client"] = Client + +local Link = require("src.core.game3.link") +local LB = require("src.core.game3.link.battle") +local Flags = require("src.core.game3.scripting.flags") +local Lobby = require("src.ui.game3.rs.cable_lobby") +local Game3Link = require("src.link.Game3Link") +local RsNatives = require("src.core.game3.scripting.natives_link_rs").BY_NAME +local RseNatives = require("src.core.game3.scripting.natives_link_rse").BY_NAME +local ContestNatives = require("src.core.game3.scripting.natives_contest").BY_NAME +local CL = require("src.core.game3.link.contest_link") + +local function setVar(id, v) Flags.setVar(store, ctx, id, v) end +local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end +local function pick(id) + for _, row in ipairs(Lobby.rows()) do + if row.id == id and not row.disabled then return Lobby.opts.select(row) end + end + error("no enabled row " .. tostring(id)) +end +local function pump(peers) + for _ = 1, 4 do + for _, p in ipairs(peers) do p:update(0) end + Link.update(0) + end +end + +local function linkup(version, start, peerVersions, peerLinkType, max) + session.version = version + ctx.specialVars, ctx.nativePoll = {}, nil + Client.clear() + Client._group, Client._room, Client._seat = nil, nil, nil + Lobby.reset() + Link.reset() + Link._live = nil + local yielded = start() + if not yielded then return nil, "did not yield" end + pick("leader") + local members = { { id = "0000beef", name = session.name } } + for i = 1, #peerVersions do members[#members + 1] = { id = string.format("00e0000%d", i), name = "P" .. i } end + Client._group = { leader = "0000beef", min = 2, max = max, members = members, pending = {} } + ctx.nativePoll() + if Client.count("startGroup") == 0 then pick("start") end + local room = FakeRelay.room({ seats = #peerVersions + 1 }) + Client.bindRoom(room, 0) + local peers = {} + for i, v in ipairs(peerVersions) do + local hello = Game3Link.hello(game, peerLinkType, { version = v, name = v:upper() .. i, trainerId = 0x3000 + i }) + peers[i] = Game3Link.attach(FakeRelay.transport(room, i), { + game = game, linkType = peerLinkType, seat = i, seats = room.seats, hello = hello, + }) + end + ctx.nativePoll() + pump(peers) + for _ = 1, LB.LINKUP_TICKS + 5 do + if ctx.nativePoll() then break end + end + return getVar(Link.VAR_RESULT), peers +end + +local LT = Game3Link.LINKTYPE + +print("[test] 1. trade: Ruby cable vs Emerald, both directions") +local code = linkup("ruby", function() return RsNatives.sub_80834E4(ctx, adapters) end, + { "emerald" }, LT.TRADE_SETUP, 2) +eq(code, Link.LINKUP.SUCCESS, "Ruby's Trade Center links with an Emerald trader") +code = linkup("emerald", function() return RseNatives.TryTradeLinkup(ctx, adapters) end, + { "sapphire" }, LT.TRADE_SETUP, 2) +eq(code, Link.LINKUP.SUCCESS, "Emerald's cable Trade Center links with a Sapphire trader") + +print("[test] 2. battles: Ruby cable vs Emerald and FireRed wireless players") +code = linkup("ruby", function() setVar(0x8004, 1); return RsNatives.sub_808347C(ctx, adapters) end, { "emerald" }, LT.BATTLE, 2) +eq(code, Link.LINKUP.SUCCESS, "single battle with an Emerald Direct Corner player") +code = linkup("sapphire", function() setVar(0x8004, 2); return RsNatives.sub_808347C(ctx, adapters) end, { "firered" }, LT.BATTLE, 2) +eq(code, Link.LINKUP.SUCCESS, "double battle with a FireRed Direct Corner player") +code = linkup("ruby", function() setVar(0x8004, 5); return RsNatives.sub_808347C(ctx, adapters) end, + { "emerald", "leafgreen", "sapphire" }, LT.BATTLE, 4) +eq(code, Link.LINKUP.SUCCESS, "multi battle with Emerald, LeafGreen and Sapphire") +code = linkup("emerald", function() setVar(0x8004, 1); return RseNatives.TryBattleLinkup(ctx, adapters) end, { "ruby" }, LT.SINGLE_BATTLE, 2) +eq(code, Link.LINKUP.SUCCESS, "Emerald's cable colosseum with a Ruby cable player") +code = linkup("emerald", function() setVar(0x8004, 1); return RseNatives.TryBattleLinkup(ctx, adapters) end, { "ruby" }, LT.DOUBLE_BATTLE, 2) +eq(code, Link.LINKUP.DIFF_SELECTIONS, "a single vs double cable pick is still LINKUP_DIFF_SELECTIONS") + +print("[test] 3. record corner: Ruby with Emerald, three players") +code = linkup("ruby", function() return RsNatives.sub_808350C(ctx, adapters) end, + { "emerald", "sapphire" }, LT.RECORD_MIX_BEFORE, 4) +eq(code, Link.LINKUP.SUCCESS, "Ruby's Record Corner links three players") +local players = Link.link and Link.link:players() or {} +eq(#players, 3, "all three seats are linked") +eq(players[2] and players[2].version, "emerald", "seat 1 is the Emerald player") +check(require("src.core.game3.link.rs_record_cross").anyRS(players), "Emerald sees an RS partner and uses the RS layout") + +print("[test] 4. berry blender: Ruby and Emerald blend in step") +local blendPeers +code, blendPeers = linkup("ruby", function() return RsNatives.sub_8083614(ctx, adapters) end, + { "emerald" }, LT.BERRY_BLENDER_SETUP, 4) +eq(code, Link.LINKUP.SUCCESS, "Ruby's blender links with an Emerald blender") +eq(Link.link and Link.link.linkType, LT.BERRY_BLENDER_SETUP, "the link runs as LINKTYPE_BERRY_BLENDER_SETUP") +local Blend = require("src.core.game3.rse.berry_blender_link") +local mine = Blend.new(Link.link, Link.link:players()) +local theirs = Blend.new(blendPeers[1], blendPeers[1]:players()) +eq(mine.rngState, theirs.rngState, "both machines seed the same blender RNG") +local a, b +for _ = 1, 8 do + a = a or mine:submitBerry(133) + b = b or theirs:submitBerry(140) + pump(blendPeers) +end +eq(a and a[1], 140, "Ruby sees the Emerald berry") +eq(b and b[0], 133, "Emerald sees the Ruby berry") +local fa, fb +for _ = 1, 8 do + fa = fa or mine:exchangeFrame(1, 2) + fb = fb or theirs:exchangeFrame(1, 3) + pump(blendPeers) +end +eq(fa and fa[1], 3, "the per-frame scores cross over") +eq(fb and fb[0], 2, "both ways") + +print("[test] 5. contests: Ruby with three Emerald players") +code = linkup("ruby", function() setVar(0x8011, 0); return RsNatives.sub_808363C(ctx, adapters) end, + { "emerald", "emerald", "emerald" }, 0x6602, 4) +eq(code, Link.LINKUP.SUCCESS, "Ruby's COOL contest links with three Emerald wireless contestants") +local flags = CL.flagsFor(Link.link, false) +eq(flags, CL.FLAG.IS_LINK + CL.FLAG.HAS_RS_PLAYER, "the contest is flagged HAS_RS_PLAYER") +code = linkup("emerald", function() setVar(0x8011, 0); return ContestNatives.TryContestEModeLinkup(ctx, adapters) end, + { "ruby" }, 0x6601, 4) +eq(code, Link.LINKUP.SUCCESS, "Emerald's cable contest links with a Ruby contestant") +eq(CL.flagsFor(Link.link, true), CL.FLAG.IS_LINK + CL.FLAG.IS_WIRELESS + CL.FLAG.HAS_RS_PLAYER, + "and a wireless Emerald contest with a Ruby player skips the standby too") +session.version = "ruby" +ctx.specialVars, ctx.nativePoll = {}, nil +Client.clear() +Client._group, Client._room, Client._seat = nil, nil, nil +Lobby.reset() +Link.reset() +setVar(0x8011, 0) +RsNatives.sub_808363C(ctx, adapters) +pick("join") +Client._groups = { { leader = "00e00009", name = "MAY", avatar = { name = "MAY" }, joined = 1, max = 4 } } +pick("00e00009") +eq(Client.last("joinGroup") and Client.last("joinGroup")[1], "00e00009", "Ruby joins an Emerald wireless contest group") +local room2 = FakeRelay.room({ seats = 2 }) +Client.bindRoom(room2, 1) +local lead = Game3Link.attach(FakeRelay.transport(room2, 0), { game = game, linkType = 0x6602, seat = 0, seats = 2, + hello = Game3Link.hello(game, 0x6602, { version = "emerald", name = "MAY", trainerId = 0x4444 }) }) +ctx.nativePoll() +pump({ lead }) +for _ = 1, LB.LINKUP_TICKS + 5 do + if ctx.nativePoll() then break end +end +eq(getVar(Link.VAR_RESULT), Link.LINKUP.WRONG_NUM_PLAYERS, + "a two player contest started by Emerald is LINKUP_WRONG_NUM_PLAYERS on Ruby") + +Link.reset() +Lobby.reset() + +print("[test] 6. record mixing data crosses Ruby and Emerald") +local function cacheRoot(version) + local env = os.getenv("POKEPORT_" .. version:upper() .. "_CACHE") + if env and env ~= "" then return env end + local identity, home = os.getenv("POKEPORT_" .. version:upper() .. "_IDENTITY") or os.getenv("POKEPORT_IDENTITY"), os.getenv("HOME") + if not (identity and identity ~= "" and home) then return nil end + for _, base in ipairs({ home .. "/Library/Application Support/LOVE/", home .. "/.local/share/love/" }) do + local root = base .. identity .. "/" .. version .. "/data/generated/gba" + local f = io.open(root .. "/meta.json", "rb") + if f then f:close(); return root end + end + return nil +end +local rubyRoot, emeraldRoot = cacheRoot("ruby"), cacheRoot("emerald") +if not (rubyRoot and emeraldRoot) then + print("[skip] 6. needs a Ruby and an Emerald cache (POKEPORT_RUBY_CACHE / POKEPORT_EMERALD_CACHE)") +else + local tmp = os.tmpname() + local function side(version, root, seat, out, inp) + local cmd = string.format("POKEPORT_GBA_CACHE=%q luajit tests/g3link_rs_mix_side.lua %s %d %q %s 2>&1", + root, version, seat, out, inp and string.format("%q", inp) or "") + local p = io.popen(cmd) + local text = p:read("*a") + p:close() + local out2 = {} + for k, v in text:gmatch("\n?(%w+) ([^\n]*)") do out2[k] = v end + return out2, text + end + local rubyOut, emOut = tmp .. ".ruby", tmp .. ".em" + local r1 = side("ruby", rubyRoot, 1, rubyOut) + local e = side("emerald", emeraldRoot, 0, emOut, rubyOut) + local r = side("ruby", rubyRoot, 1, rubyOut, emOut) + eq(e.layout, "rs", "Emerald sends the RS record layout to a Ruby partner") + check((tonumber(e.bytes) or 1e9) < 65536, "and the packet fits the relay's rse_record_mix cap (" .. tostring(e.bytes) .. ")") + check((tonumber(r1.bytes) or 1e9) < 65536, "Ruby's packet fits too (" .. tostring(r1.bytes) .. ")") + eq(e.unsupported, "nil", "Emerald takes Ruby's records") + eq(e.secretBases, "true", "Emerald mixes the secret bases") + eq(e.tvShows, "true", "and the TV shows") + eq(r.unsupported, "nil", "Ruby takes Emerald's records") + eq(r.oldMan, "true", "Ruby swaps the old man") + eq(r.daycareMail, "true", "and the daycare mail") + os.remove(tmp) + os.remove(rubyOut) + os.remove(emOut) +end + +print(string.format("%s game3_link_rs_cross_test", failed == 0 and "PASS" or ("FAIL " .. failed))) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_link_rs_entry_test.lua b/tests/game3_link_rs_entry_test.lua new file mode 100644 index 00000000..1156801a --- /dev/null +++ b/tests/game3_link_rs_entry_test.lua @@ -0,0 +1,304 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +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 store = { flags = {}, vars = {} } +local session = { + version = "ruby", store = store, map = "RS_OLDALE_TOWN_POKEMON_CENTER_2F", x = 5, y = 3, + name = "BRENDAN", gender = 0, trainerId = 0x2222, + party = { { species = 280, level = 12 }, { species = 283, level = 9 } }, + bag = { pockets = { items = {} } }, +} +local input = { pressed = {} } +function input:wasPressed(k) return self.pressed[k] == true end +function input:isDown(k) return self.pressed[k] == true end +local game = { data = { maps = {} }, session = session, input = input, save = { player = { name = "BRENDAN" } } } + +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + _game = game, +} +package.loaded["src.core.game3.player"] = { cellX = 5, cellY = 3, facing = "up" } +package.loaded["src.core.game3.map"] = { load = function() end } +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, + translate = function(ir) return ir 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, +} + +local ctx = { specialVars = {}, stringVars = {} } +local adapters = { log = function() end, playSe = function() end } +package.loaded["src.core.game3.scripting.space"] = { + store = store, mapId = session.map, vm = { ctx = ctx, adapters = adapters }, + ensureBundle = function() return nil end, +} + +local LIVE = { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla" } +package.loaded["src.online.ArenaData"] = { + liveProfile3 = function(_, rulesetId) + local p = { version = session.version } + 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" }) +package.loaded["src.online.Client"] = Client + +local Link = require("src.core.game3.link") +local LB = require("src.core.game3.link.battle") +local Union = require("src.core.game3.link.union_room") +local Flags = require("src.core.game3.scripting.flags") +local Lobby = require("src.ui.game3.rs.cable_lobby") +local Game3Link = require("src.link.Game3Link") +local NativesLinkRs = require("src.core.game3.scripting.natives_link_rs") + +local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end +local function setVar(id, v) Flags.setVar(store, ctx, id, v) end +local function fresh() + ctx.specialVars = {} + ctx.nativePoll, ctx.mode, ctx.status = nil, nil, nil + Client.clear() + Client._group, Client._groups, Client._directEntries = nil, {}, {} + Client._room, Client._seat = nil, nil + Lobby.reset() + Link.reset() + Link._live = nil +end +local function rowIds() + local out = {} + for i, row in ipairs(Lobby.rows()) do out[i] = row.id end + return out +end +local function pick(id) + for _, row in ipairs(Lobby.rows()) do + if row.id == id then return Lobby.opts.select(row) end + end + error("no row " .. tostring(id)) +end +local function poll(n) + local r + for _ = 1, n or 1 do + r = ctx.nativePoll() + if r then break end + end + return r +end +local function me(id) return { id = id or "0000beef", name = "BRENDAN", avatar = { name = "BRENDAN" } } end +local function peerOn(room, seat, version, linkType) + local hello = Game3Link.hello(game, linkType, { version = version, name = version:upper(), trainerId = 0x1000 + seat }) + return Game3Link.attach(FakeRelay.transport(room, seat), { + game = game, linkType = linkType, seat = seat, seats = room.seats, hello = hello, + }) +end +local function pump(peers) + for _ = 1, 4 do + for _, p in ipairs(peers) do p:update(0) end + Link.update(0) + end +end + +print("[test] 1. a Ruby cable leader auto-accepts and starts a two-seat group") +fresh() +setVar(0x8004, 1) +local yielded = NativesLinkRs.BY_NAME.sub_808347C(ctx, adapters) +check(yielded, "sub_808347C parks the script") +check(Lobby.isOpen(), "the cable lobby is up") +eq(table.concat(rowIds(), ","), "leader,join", "CREATE GROUP / JOIN GROUP") +pick("leader") +local og = Client.last("openGroup") +eq(og and og[1], "battle_single", "the leader opens a battle_single group") +eq(og and og[2] and og[2].rulesetId, "g3_single", "with the battle ruleset profile") +Client._group = { leader = "0000beef", min = 2, max = 2, members = { me() }, + pending = { { id = "00e00001", name = "MAY", avatar = { name = "MAY" } } } } +poll() +local acc = Client.last("acceptGroup") +eq(acc and acc[1], "00e00001", "the first group_request is accepted without a prompt") +eq(acc and acc[2], true, "with ok = true") +Client._group = { leader = "0000beef", min = 2, max = 2, members = { me(), { id = "00e00001", name = "MAY" } }, pending = {} } +poll() +eq(Client.count("startGroup"), 1, "a full two-seat group starts by itself") + +print("[test] 2. an Emerald wireless peer seated into the group links up with no linkup message") +local room = FakeRelay.room({ seats = 2 }) +Client.bindRoom(room, 0) +local peer = peerOn(room, 1, "emerald", Game3Link.LINKTYPE.BATTLE) +check(poll() == false, "the match opens the relay link") +check(Link.link ~= nil, "Link.openRelay ran") +pump({ peer }) +check(Link.link and Link.link:isReady(), "the hellos agree") +local done = poll(LB.LINKUP_TICKS + 5) +check(done == true, "the linkup finishes") +eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "Ruby vs an Emerald wireless player is LINKUP_SUCCESS") +eq(LB.state, "seat", "and the colosseum seat wait begins") +eq(Link.link and Link.link.linkType, 0x2233, "the link runs as LINKTYPE_SINGLE_BATTLE") +check(not Lobby.isOpen(), "the lobby closed") + +print("[test] 3. a Ruby joiner sees Direct Corner hosts next to cable groups") +fresh() +setVar(0x8004, 2) +NativesLinkRs.BY_NAME.sub_808347C(ctx, adapters) +pick("join") +eq(Client.last("groupList") and Client.last("groupList")[1], "battle_double", "it watches battle_double groups") +eq(Client.last("directList") and Client.last("directList")[1], "battle_double", "and the Direct Corner list") +Client._groups = { { leader = "00c00001", name = "RED", avatar = { name = "RED" }, joined = 1, max = 2 } } +Client._directEntries.battle_double = { + { kind = "room", room = "r00000000000000aa", host = "00d00001", name = "LEAF", avatar = { name = "LEAF" } }, + { kind = "room", room = "r00000000000000bb", host = "00d00002", name = "PIN", locked = true }, + { kind = "player", id = "00d00003", name = "AUTO" }, +} +local rows = Lobby.rows() +eq(#rows, 3, "one group and two hosted rooms (queued players are not joinable)") +eq(rows[2].kind, "room", "the Direct Corner host is listed") +eq(rows[3].disabled, true, "a PIN room is not joinable from the cable") +pick("r00000000000000aa") +local jr = Client.last("joinRoom") +eq(jr and jr[1], "r00000000000000aa", "room_join to the Direct Corner host") +eq(jr and jr[2], "player", "as a player") +Lobby.opts.cancel() +eq(Client.count("leaveRoom"), 1, "B while joining leaves the room") +check(Lobby.isOpen(), "and goes back to the list") + +print("[test] 4. FireRed's cable counter falls back to the relay instead of timing out") +fresh() +session.version = "firered" +local NativesLink = require("src.core.game3.scripting.natives_link") +setVar(0x8004, Link.USING.TRADE_CENTER) +yielded = NativesLink.BY_NAME.TryTradeLinkup(ctx, adapters) +check(yielded, "TryTradeLinkup parks the script") +check(Lobby.isOpen(), "with the cable lobby, not a dead wait") +for _ = 1, LB.LINKUP_TICKS + 5 do ctx.nativePoll() end +eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "and it does not time out to CONNECTION_ERROR") +pick("leader") +eq(Client.last("openGroup") and Client.last("openGroup")[1], "trade", "the trade group") +Lobby.opts.cancel() +eq(getVar(Link.VAR_RESULT), Link.LINKUP.FAILED, "B is LINKUP_FAILED") +eq(Client.count("leaveGroup"), 1, "and leaves the group") + +print("[test] 5. Emerald's cable counters pick the matching relay activity") +session.version = "emerald" +local cases = { + { "TryBattleLinkup", Link.USING.SINGLE_BATTLE, "battle_single" }, + { "TryBattleLinkup", Link.USING.MULTI_BATTLE, "battle_multi" }, + { "TryBattleLinkup", Link.USING.BATTLE_TOWER, "battle_tower" }, + { "TryRecordMixLinkup", 0, "record_corner" }, +} +local NativesLinkRse = require("src.core.game3.scripting.natives_link_rse") +for _, c in ipairs(cases) do + fresh() + setVar(0x8004, c[2]) + NativesLinkRse.BY_NAME[c[1]](ctx, adapters) + pick("leader") + eq(Client.last("openGroup") and Client.last("openGroup")[1], c[3], c[1] .. " " .. c[2] .. " opens " .. c[3]) +end +fresh() +NativesLinkRse.BY_NAME.TryBerryBlenderLinkup(ctx, adapters) +pick("leader") +eq(Client.last("openGroup") and Client.last("openGroup")[1], "berry_blender", "TryBerryBlenderLinkup opens berry_blender") +fresh() +setVar(0x8011, 2) +local NativesContest = require("src.core.game3.scripting.natives_contest") +NativesContest.BY_NAME.TryContestEModeLinkup(ctx, adapters) +pick("leader") +eq(Client.last("openGroup") and Client.last("openGroup")[1], "contest_cute", "a CUTE link contest opens contest_cute") +eq(LB.towerSpec().linkType, 0x2266, "a level 50 tower challenge links as LINKTYPE_BATTLE_TOWER_50") + +print("[test] 6. a 2..4 group still asks the leader") +fresh() +setVar(0x8004, 0) +NativesLinkRse.BY_NAME.TryRecordMixLinkup(ctx, adapters) +pick("leader") +Client._group = { leader = "0000beef", min = 2, max = 4, members = { me() }, + pending = { { id = "00e00002", name = "WALLY", avatar = { name = "WALLY" } } } } +poll() +eq(table.concat(rowIds(), ","), "yes,no", "ADD WALLY? YES / NO") +eq(Client.count("acceptGroup"), 0, "nothing accepted yet") +pick("yes") +eq(Client.last("acceptGroup") and Client.last("acceptGroup")[2], true, "YES accepts") + +print("[test] 7. the activity names follow each game's sLinkGroupActivityNameTexts") +session.version = "emerald" +eq(Union.ACTIVITY_NAMES[15], "sLinkGroupActivityNameTexts[15]", "Emerald RECORD CORNER") +eq(Union.ACTIVITY_NAMES[23], "sLinkGroupActivityNameTexts[23]", "Emerald COOL CONTEST") +eq(Union.ACTIVITY_NAMES[28], "sLinkGroupActivityNameTexts[28]", "Emerald BATTLE TOWER LV. 50") +eq(Union.ACTIVITY_NAMES[13], nil, "Emerald SPIN TRADE is an empty string") +eq(Union.ACTIVITY_NAMES[3], "sLinkGroupActivityNameTexts[3]", "MULTI BATTLE") +session.version = "firered" +eq(Union.ACTIVITY_NAMES[13], "sLinkGroupActivityNameTexts[13]", "FireRed SPIN TRADE") +eq(Union.ACTIVITY_NAMES[15], nil, "FireRed has no RECORD CORNER name") +eq(Union.ACTIVITY_NAMES[10], "sLinkGroupActivityNameTexts[10]", "BERRY CRUSH") + +print("[test] 8. the Direct Corner CHOOSE list shows open cable groups") +Client._directEntries.trade = { + { kind = "group", leader = "00f00001", name = "BRENDAN", avatar = { name = "BRENDAN", version = "ruby" }, joined = 1, max = 2 }, + { kind = "group", leader = "00f00002", name = "FULL", avatar = { name = "FULL" }, joined = 2, max = 2 }, +} +local drows = Union.directRows("trade") +eq(#drows, 1, "the open group is listed, the full one is not") +eq(drows[1] and drows[1].kind, "group", "as a group row") +eq(drows[1] and drows[1].leader, "00f00001", "with its leader id") +eq(drows[1] and drows[1].version, "ruby", "and the Ruby version for the trade check") + +print("[test] 9. record mixing packs byte runs so a full Emerald packet fits the relay cap") +local RecordMix = require("src.core.game3.link.record_mix") +local Json = require("src.link.Json") +local Wire = require("src.link.Wire") +local raw = {} +for i = 1, 160 do raw[i] = (i * 37) % 256 end +local packet = { secretBases = { { trainerName = "MAY", _recordMixNativeBytes = raw } }, tvShows = { [0] = { kind = 1 } } } +local msg = Wire.sanitize(Json.decode(Json.encode({ type = RecordMix.MSG.PACKET, spot = 1, packet = RecordMix.toWire(packet) }))) +local back = RecordMix.fromWire(msg.packet) +local same = true +for i = 1, 160 do if back.secretBases[1]._recordMixNativeBytes[i] ~= raw[i] then same = false end end +check(same, "160 native bytes survive the wire") +eq(back.tvShows[0].kind, 1, "zero-indexed rows still round trip") +check(#Json.encode(RecordMix.toWire(packet)) < 700, "and the byte run travels as one hex string") + +print("[test] 10. a contest with a Ruby player is flagged and skips the wireless standby") +local CL = require("src.core.game3.link.contest_link") +local fakeLink = { + isOpen = function() return true end, + players = function() return { { seat = 0, version = "emerald" }, { seat = 1, version = "ruby" } } end, + send = function(self, m) self.sent = (self.sent or 0) + 1 end, + take = function() return nil end, + getSeat = function() return 0 end, +} +local flags = CL.flagsFor(fakeLink, true) +eq(flags, CL.FLAG.IS_LINK + CL.FLAG.IS_WIRELESS + CL.FLAG.HAS_RS_PLAYER, "IS_LINK | IS_WIRELESS | HAS_RS_PLAYER") +local s = CL.newSession(fakeLink, { flags = flags }) +eq(s:standby(), true, "LinkContest_TryLinkStandby skips the standby with an RS player") +eq(fakeLink.sent, nil, "without sending a standby block") +CL.active = s +NativesContest.linkFlags = flags +eq(select(2, NativesContest.BY_NAME.IsContestWithRSPlayer()), 1, "IsContestWithRSPlayer answers TRUE") +eq(select(1, NativesContest.linkContestWaitForConnection(ctx, adapters)), false, + "LinkContestWaitForConnection does not park the script for an RS contest") +eq(CL.flagsFor({ players = function() return { { seat = 0, version = "emerald" } } end }, false), CL.FLAG.IS_LINK, + "an all-Emerald cable contest is just IS_LINK") +CL.reset() +NativesContest.linkFlags = 0 + +Link.reset() +Lobby.reset() +print(string.format("%s game3_link_rs_entry_test", failed == 0 and "PASS" or ("FAIL " .. failed))) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_link_translation_mods_test.lua b/tests/game3_link_translation_mods_test.lua new file mode 100644 index 00000000..7d61f19c --- /dev/null +++ b/tests/game3_link_translation_mods_test.lua @@ -0,0 +1,121 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +if not _G.love then _G.love = require("tests.love_stub") end + +local Cache = require("tests.game3_cache") +if not Cache.mount("meta.json") then + print("[skip] game3_link_translation_mods_test: " .. tostring(Cache.reason)) + os.exit(0) +end + +local files = {} +local memfs = { + getInfo = function(name) return files[name] and { type = "file" } or nil end, + read = function(name) return files[name] end, + write = function(name, body) files[name] = body return true end, + remove = function(name) files[name] = nil return true end, + createDirectory = function() return true end, + getDirectoryItems = function() return {} end, +} +local SaveData = require("src.core.SaveData") +SaveData.portableFs = function() return memfs end + +require("src.core.GameVersion").set("firered") +local ArenaData = require("src.online.ArenaData") +local Handshake = require("src.link.Handshake") +local Fingerprint = require("src.link.Fingerprint") +local Protocol = require("src.link.Protocol") +local Pokemon = require("src.core.game3.pokemon") +Pokemon.install(nil) + +local vanilla = ArenaData.liveProfile3({ data = { generation = 3 }, version = "firered" }) +T.check(vanilla ~= nil, "a vanilla FireRed has a live profile") + +local function ops(list) + local out = {} + for _, row in ipairs(list) do + out[row.registry] = out[row.registry] or { ops = {} } + local reg = out[row.registry].ops + reg[row.id] = reg[row.id] or {} + local l = reg[row.id] + l[#l + 1] = { op = row.op, value = row.value, owner = row.owner } + end + return out +end + +local function loader(id, content, extra) + extra = extra or {} + return { + mods = { [id] = { manifest = { id = id, permissions = extra.permissions or {} } } }, + content = content, + hooks = { chains = extra.chains or {} }, + events = { listeners = {} }, + migrations = {}, modInput = {}, stepsQueues = {}, + status = function() + return { loaded = { { id = id, version = "1.0", affects_link = extra.affectsLink == true } } } + end, + } +end + +local function game(mods) + return { data = { generation = 3 }, version = "firered", mods = mods } +end + +local translation = loader("espanol", ops({ + { registry = "rom_text", id = "gText_UR_EggTrade", op = "override", value = "HUEVO", owner = "espanol" }, + { registry = "strings", id = "Connect to the Wireless Club?", op = "override", value = "Conectar?", owner = "espanol" }, + { registry = "pokemon", id = "BULBASAUR", op = "patch", value = { name = "BULBIZARRE" }, owner = "espanol" }, + { registry = "moves", id = "TACKLE", op = "patch", value = { name = "PLACAJE" }, owner = "espanol" }, + { registry = "items", id = "POTION", op = "patch", value = { name = "POCION", description = "Cura 20 PS." }, owner = "espanol" }, +})) + +T.eq(ArenaData.modTouchesGameplay(translation, "espanol"), false, "names and text are cosmetic") +local live, why = ArenaData.liveProfile3(game(translation)) +T.check(live ~= nil, "a translation mod keeps the game online: " .. tostring(why)) +T.eq(live and live.fingerprint, vanilla and vanilla.fingerprint, "and leaves the dataset fingerprint alone") + +local rebalance = loader("rebalance", ops({ + { registry = "pokemon", id = "BULBASAUR", op = "patch", value = { name = "BULBA", baseStats = { hp = 99 } }, owner = "rebalance" }, +})) +T.eq(ArenaData.modTouchesGameplay(rebalance, "rebalance"), true, "a stat change is gameplay") +local none, noneWhy = ArenaData.liveProfile3(game(rebalance)) +T.eq(none, nil, "a gameplay mod still blocks online") +T.eq(noneWhy, "mods", "with the mods reason") + +local encounters = loader("wild", ops({ + { registry = "encounters", id = "ROUTE1", op = "override", value = {}, owner = "wild" }, +})) +T.eq(select(2, ArenaData.liveProfile3(game(encounters))), "mods", "an encounter table is gameplay too") + +local hooked = loader("hooky", ops({ + { registry = "strings", id = "Hi", op = "override", value = "Hola", owner = "hooky" }, +}), { chains = { ["battle.damage"] = { { owner = "hooky" } } } }) +T.eq(select(2, ArenaData.liveProfile3(game(hooked))), "mods", "a text mod that also hooks code blocks") + +local permitted = loader("net", ops({}), { permissions = { "network" } }) +T.eq(select(2, ArenaData.liveProfile3(game(permitted))), "mods", "a mod asking for permissions blocks") + +local claimed = loader("overhaul", ops({}), { affectsLink = true }) +T.eq(select(2, ArenaData.liveProfile3(game(claimed))), "mods", "a mod that says it affects link blocks") + +local data = { generation = 3 } +local hello = Handshake.hello({ data = data, mods = translation }, nil) +ArenaData.stripCosmeticMods(hello, { data = data, mods = translation }) +T.eq(hello.linkModified, false, "the room hello does not flag a translation as link-modified") +T.eq(#hello.mods, 0, "and lists no link mods") +T.eq(hello.fingerprint, Fingerprint.compute(data, {}, 3), "and hashes like vanilla") +local plainHello = Handshake.hello({ data = data }, nil) +T.eq(Handshake.checkCompat(hello, plainHello), "full", "so it pairs fully with a vanilla game") + +local gameplayHello = Handshake.hello({ data = data, mods = rebalance }, nil) +ArenaData.stripCosmeticMods(gameplayHello, { data = data, mods = rebalance }) +T.eq(gameplayHello.linkModified, true, "a gameplay mod still flags the hello") + +local mon = { species = 1, nickname = "", level = 5, personality = 1, otId = 1, otName = "RED" } +local packed = Protocol.packMon3(mon) +T.eq(packed.species, 1, "a traded mon travels as its species id") +T.eq(packed.nickname, "", "an unnamed mon sends no species name text") + +T.finish() diff --git a/tests/game3_rs_mystery_event_feed_test.lua b/tests/game3_rs_mystery_event_feed_test.lua new file mode 100644 index 00000000..411fa67f --- /dev/null +++ b/tests/game3_rs_mystery_event_feed_test.lua @@ -0,0 +1,192 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local function rubyRoot() + local roots = {} + if os.getenv("POKEPORT_RS_CACHE") then roots[#roots + 1] = os.getenv("POKEPORT_RS_CACHE") end + local home, id = os.getenv("HOME"), os.getenv("POKEPORT_IDENTITY") + if home and id and id ~= "" then + roots[#roots + 1] = home .. "/Library/Application Support/LOVE/" .. id .. "/ruby/data/generated/gba" + roots[#roots + 1] = home .. "/.local/share/love/" .. id .. "/ruby/data/generated/gba" + end + for _, root in ipairs(roots) do + local f = io.open(root .. "/items/pack.lua", "rb") + if f then f:close() return root end + end + return nil +end + +local root = rubyRoot() +if not root then + print("[skip] game3_rs_mystery_event_feed: no Ruby cache (set POKEPORT_IDENTITY or POKEPORT_RS_CACHE)") + os.exit(0) +end + +local FIXTURE_PUB = "e140eec9ec2b6c4830b64aac9f5578ac4b04eca0ce8703aef3aaace4f068c3db" +local FEED_FRLG = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":2,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1001,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[680,754,755],\"item\":370,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2122,680]},\"iconSpecies\":0,\"idNumber\":1,\"key\":\"mystic_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MYSTIC TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[679,740],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2123,679]},\"iconSpecies\":0,\"idNumber\":2,\"key\":\"aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":1,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1003,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":984,\"kind\":\"egg\",\"moves\":{\"3\":57},\"slotVar\":16565,\"species\":172},\"iconSpecies\":172,\"idNumber\":3,\"key\":\"surf_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":4,\"bodyText\":[\"Thank you for using the STAMP CARD\",\"System.\",\"Trade with other TRAINERS to fill\",\"your STAMP CARD.\"],\"family\":\"frlg\",\"flagId\":1004,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"none\"},\"iconSpecies\":0,\"idNumber\":4,\"key\":\"stamp_card\",\"maxStamps\":7,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"STAMP CARD\",\"type\":1,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"Your BATTLE COUNT CARD keeps\",\"track of your battle record against\",\"TRAINERS with the same CARD.\",\"Look for them and battle!\"],\"family\":\"frlg\",\"flagId\":1005,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":984,\"item\":13,\"kind\":\"item\",\"quantity\":1,\"requireStat\":0,\"requireValue\":3},\"iconSpecies\":0,\"idNumber\":5,\"key\":\"battle_card\",\"maxStamps\":0,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"BATTLE COUNT CARD\",\"type\":2,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":6,\"bodyText\":[\"The POKéMON living in ALTERING\",\"CAVE have changed.\",\"Go and see what turns up\",\"on SIX ISLAND.\"],\"family\":\"frlg\",\"flagId\":1006,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"var\",\"slotVar\":16420,\"varAdd\":1,\"varWrap\":10},\"iconSpecies\":0,\"idNumber\":6,\"key\":\"altering_cave\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ALTERING CAVE\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":0,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":6930,\"otName\":\"MYSTRY\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1000,\"key\":\"ev000_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":1,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metGame\":2,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":0,\"otId\":20043,\"otName\":\"WISHMKR\",\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1001,\"key\":\"ev001_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metLevel\":0,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":2,\"otId\":40122,\"otName\":\"CHANNEL\",\"secretIdRandom\":true,\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1002,\"key\":\"ev002_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":5,\"metGame\":1,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":33,\"2\":45,\"3\":39},\"movesOnly\":true,\"nickname\":\"ジグザグマ\",\"otGender\":1,\"otId\":21121,\"otName\":\"ルビー\",\"shiny\":\"always\",\"species\":288},\"iconSpecies\":288,\"idNumber\":1003,\"key\":\"ev003_zigzagoon\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ZIGZAGOON\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":6,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":20078,\"otName\":\"Aura\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1078,\"key\":\"ev078_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":30,\"metGame\":2,\"metLevel\":30,\"metLocation\":255,\"moves\":{\"1\":36,\"2\":93,\"3\":232,\"4\":287},\"movesOnly\":true,\"nationalRibbon\":true,\"otGender\":0,\"otId\":2005,\"otName\":\"ROCKS\",\"shiny\":\"never\",\"species\":399},\"iconSpecies\":399,\"idNumber\":1079,\"key\":\"ev079_metang\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"METANG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"egg\",\"language\":1,\"metGame\":2,\"moves\":{\"1\":84,\"2\":204,\"3\":298},\"movesOnly\":true,\"otGender\":0,\"otName\":\"オヤNAME\",\"shiny\":\"always\",\"species\":172},\"iconSpecies\":172,\"idNumber\":1127,\"key\":\"ev127_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"egg\",\"moves\":{\"1\":281,\"2\":273},\"movesOnly\":true,\"shiny\":\"random\",\"species\":83},\"iconSpecies\":83,\"idNumber\":1141,\"key\":\"ev141_farfetch_d\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"セレビィ\",\"otGender\":1,\"otId\":31121,\"otName\":\"アゲト\",\"shiny\":\"never\",\"species\":251},\"iconSpecies\":251,\"idNumber\":1178,\"key\":\"ev178_celebi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"CELEBI\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":50,\"metLevel\":50,\"metLocation\":255,\"moves\":{\"1\":85,\"2\":97,\"3\":87,\"4\":113},\"movesOnly\":true,\"otIdMax\":2999,\"otNames\":[\"PCNYb\",\"PCNYc\"],\"shiny\":\"never\",\"species\":25},\"iconSpecies\":25,\"idNumber\":1179,\"key\":\"ev179_pikachu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"PIKACHU\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":5,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"キモリ\",\"otId\":51126,\"otNames\":[\"トウキョー\",\"ヨコハマ\",\"ナゴヤ\",\"オーサカ\",\"フクオカ\",\"サッポロ\"],\"shiny\":\"never\",\"species\":277},\"iconSpecies\":277,\"idNumber\":1229,\"key\":\"ev229_treecko\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"TREECKO\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]}],\"family\":\"frlg\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"617e20e51d71d81dc4651a7ec01e7170cd7a94fe25b1c26bafa9825ed7132770502bcea8d8666e04cb21e77cd13a540cce6e22b02a314a215522ec6c93b75e03","key":"e140eec9ec2b6c4830b64aac9f5578ac4b04eca0ce8703aef3aaace4f068c3db"}]==] +local FEED_RSE = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":1,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1003,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[2227],\"item\":275,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2227]},\"iconSpecies\":0,\"idNumber\":101,\"key\":\"rse_eon_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"EON TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[314,429],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2261,314]},\"iconSpecies\":0,\"idNumber\":102,\"key\":\"rse_aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at LILYCOVE CITY port.\"],\"family\":\"rse\",\"flagId\":1001,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[315,145,146],\"item\":370,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2272,315]},\"iconSpecies\":0,\"idNumber\":103,\"key\":\"rse_mystic_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MYSTIC TICKET\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"We received this OLD SEA MAP\",\"addressed to you.\"],\"family\":\"rse\",\"flagId\":1002,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[316,458],\"item\":376,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2262,316]},\"iconSpecies\":0,\"idNumber\":104,\"key\":\"rse_old_sea_map\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"OLD SEA MAP\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":1,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"rse\",\"flagId\":1004,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":484,\"kind\":\"egg\",\"moves\":{\"3\":57},\"script\":\"surfPichu\",\"slotVar\":16605,\"species\":172},\"iconSpecies\":172,\"idNumber\":105,\"key\":\"rse_surf_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":4,\"bodyText\":[\"Thank you for using the STAMP CARD\",\"System.\",\"Trade with other TRAINERS to fill\",\"your STAMP CARD.\"],\"family\":\"rse\",\"flagId\":1005,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"none\",\"script\":\"stampCard\"},\"iconSpecies\":0,\"idNumber\":106,\"key\":\"rse_stamp_card\",\"maxStamps\":7,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"STAMP CARD\",\"type\":1,\"versions\":[\"emerald\"]},{\"bgType\":5,\"bodyText\":[\"Your BATTLE COUNT CARD keeps\",\"track of your battle record against\",\"TRAINERS with the same CARD.\",\"Look for them and battle!\"],\"family\":\"rse\",\"flagId\":1006,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"doneFlag\":484,\"item\":13,\"kind\":\"item\",\"quantity\":1,\"requireStat\":0,\"requireValue\":3,\"script\":\"battleCard\"},\"iconSpecies\":0,\"idNumber\":107,\"key\":\"rse_battle_card\",\"maxStamps\":0,\"sendType\":1,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"BATTLE COUNT CARD\",\"type\":2,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"The POKéMON living in ALTERING\",\"CAVE have changed.\",\"Go and see what turns up\",\"on ROUTE 103.\"],\"family\":\"rse\",\"flagId\":1007,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"var\",\"script\":\"alteringCave\",\"slotVar\":16446,\"varAdd\":1,\"varWrap\":10},\"iconSpecies\":0,\"idNumber\":108,\"key\":\"rse_altering_cave\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ALTERING CAVE\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":0,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":6930,\"otName\":\"MYSTRY\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1000,\"key\":\"rse_ev000_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":1,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metGame\":2,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":0,\"otId\":20043,\"otName\":\"WISHMKR\",\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1001,\"key\":\"rse_ev001_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":5,\"metLevel\":0,\"metLocation\":255,\"moves\":{\"1\":273,\"2\":93,\"3\":156},\"movesOnly\":true,\"otGender\":2,\"otId\":40122,\"otName\":\"CHANNEL\",\"secretIdRandom\":true,\"shiny\":\"random\",\"species\":409},\"iconSpecies\":409,\"idNumber\":1002,\"key\":\"rse_ev002_jirachi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"JIRACHI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":5,\"metGame\":1,\"metLevel\":5,\"metLocation\":255,\"moves\":{\"1\":33,\"2\":45,\"3\":39},\"movesOnly\":true,\"nickname\":\"ジグザグマ\",\"otGender\":1,\"otId\":21121,\"otName\":\"ルビー\",\"shiny\":\"always\",\"species\":288},\"iconSpecies\":288,\"idNumber\":1003,\"key\":\"rse_ev003_zigzagoon\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"ZIGZAGOON\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":6,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"kind\":\"mon\",\"language\":2,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"moves\":{\"1\":1,\"2\":144},\"movesOnly\":true,\"otGender\":2,\"otId\":20078,\"otName\":\"Aura\",\"shiny\":\"never\",\"species\":151},\"iconSpecies\":151,\"idNumber\":1078,\"key\":\"rse_ev078_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":7,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":30,\"metGame\":2,\"metLevel\":30,\"metLocation\":255,\"moves\":{\"1\":36,\"2\":93,\"3\":232,\"4\":287},\"movesOnly\":true,\"nationalRibbon\":true,\"otGender\":0,\"otId\":2005,\"otName\":\"ROCKS\",\"shiny\":\"never\",\"species\":399},\"iconSpecies\":399,\"idNumber\":1079,\"key\":\"rse_ev079_metang\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"METANG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":7,\"bodyText\":[\"From the POKéMON CENTER we\",\"have a gift, a POKéMON EGG!\",\"Please raise it with love and\",\"kindness.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"egg\",\"language\":1,\"metGame\":2,\"moves\":{\"1\":84,\"2\":204,\"3\":298},\"movesOnly\":true,\"otGender\":0,\"otName\":\"オヤNAME\",\"shiny\":\"always\",\"species\":172},\"iconSpecies\":172,\"idNumber\":1127,\"key\":\"rse_ev127_pichu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"POKéMON EGG\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":2,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"セレビィ\",\"otGender\":1,\"otId\":31121,\"otName\":\"アゲト\",\"shiny\":\"never\",\"species\":251},\"iconSpecies\":251,\"idNumber\":1178,\"key\":\"rse_ev178_celebi\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"CELEBI\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":3,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":2,\"level\":50,\"metLevel\":50,\"metLocation\":255,\"moves\":{\"1\":85,\"2\":97,\"3\":87,\"4\":113},\"movesOnly\":true,\"otIdMax\":2999,\"otNames\":[\"PCNYb\",\"PCNYc\"],\"shiny\":\"never\",\"species\":25},\"iconSpecies\":25,\"idNumber\":1179,\"key\":\"rse_ev179_pikachu\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"PIKACHU\",\"type\":0,\"versions\":[\"emerald\"]},{\"bgType\":5,\"bodyText\":[\"A POKéMON has been sent\",\"to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"rse\",\"flagId\":1019,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"ball\":4,\"fateful\":false,\"kind\":\"mon\",\"language\":1,\"level\":10,\"metGame\":2,\"metLevel\":10,\"metLocation\":255,\"nickname\":\"キモリ\",\"otId\":51126,\"otNames\":[\"トウキョー\",\"ヨコハマ\",\"ナゴヤ\",\"オーサカ\",\"フクオカ\",\"サッポロ\"],\"shiny\":\"never\",\"species\":277},\"iconSpecies\":277,\"idNumber\":1229,\"key\":\"rse_ev229_treecko\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"TREECKO\",\"type\":0,\"versions\":[\"emerald\"]}],\"family\":\"rse\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"81142571417d297a14a0440a7b7d3f0d4f5c238700914b170249e285d9a7b942d77f207705455b85b6d85055d442b0846bbc65cca90fbdf8859d90fe312fc208","key":"e140eec9ec2b6c4830b64aac9f5578ac4b04eca0ce8703aef3aaace4f068c3db"}]==] +local FEED_RS = [==[{"v":1,"payload":"{\"cards\":[],\"events\":[{\"family\":\"rs\",\"key\":\"rs_eon_ticket\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/\",\"title\":\"EON TICKET\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_gift_ribbon\",\"payload\":\"AZsIAAICAAIAAAAEAIABAAAQTGwAALkIAAK9CAACCAABAg==\",\"title\":\"GIFT RIBBON\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev000_mew\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQDIYAAB4AAAKsAAACDCQAAAIC6rS/fxIbAADHv9H/////////AgLH083OzNP/AEt4AAD4ULVeM5lEUPivv/9vr79/yK2/f/jLv3/5ry9/+K+/f9ulv3/4r79/+K+/f/ivv38AAAAACv8pACkAGwAaABgAHAAdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"MEW\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev001_jirachi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQaDcAAB4AAAKsAAACDCQAAAICsPZhQktOAADEw8y7vcLD////AgLRw83Cx8XMAJ9/AABiuWFCZ7hhQvvcYULquTxCZ7hhQvGha0L7uGFC+7hhQvu4YUL7R2Rjsg+fZPu4YUIAAAAABf8ZABkAEAAPABAADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"JIRACHI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev002_jirachi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQUCUAAB4AAAKsAAACDCQAAAICdjgEBbqcnsnEw8y7vcLD////AgK9wrvIyL/GABv3AADdpcfMUKSazMa9kMxVpZrMUKSazMzAmszMpJrMzKSazMykmszMW5ptCxOb0sykmswAAAAABf8ZABkAEQAPAA8ADQAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"JIRACHI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev003_zigzagoon\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQztgAAB4AAAKsAAACDCQAAAICJJWnx4FSAACNiYyJb///////AQJ5l67/////ACFdAAClOCJn4f+i0qXHp8eEx4rHgsenx4bvuceFxqfH2Menx6WBp8elx6fHpcenx6XHp8cAAAAABf8TABMACAAIAAsACAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"ZIGZAGOON\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev078_mew\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ3ekAAB4AAAKsAAACDCQAAAICVb9KzW5OAADHv9H/////////AgK76ebV////AJghAAA7DkDsICyy/zvxSk068drNO/FKzRj7Ss078UrNO/FKzTvxSs2s8UrNC/NKzTuVSs0AAAAACv8qACoAGQAbABoAGgAbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"MEW\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev079_metang\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ0ekAAB4AAAKsAAACDCQAAAICHAHtj9UHAADHv867yMH/////AgLMyb3Fzf//ANaAAABGB+2PH4Xtj8kl7Y/J+fOuUVsRpskG7Y7tBrCPIQbyjt0fzpvJBu2PyQbtj8kG7Y8AAAAAHv9TAFMAOgBHACoALwA1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"METANG\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev127_pichu\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQeKAAAB4AAAKsAAACDCQAAAICag5pDgAAAABgb4v/////////AQZVdMi7x7//AG3tAADGDmkOFw5pDmpIaQ5qDmkOag5pDmoOaQ4+DqUOQA9pDnQafQ5q8WwvRvv+dGoOaQ4AAAAABf8RABEACQAHAAsACAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"POKéMON EGG\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev178_celebi\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQXCYAAB4AAAKsAAACDCQAAAICzHDO1JF5AABeepeA////////AQJRimT/////AED1AABd9sR1ZYfz5F0JztQACafUigkV1EQdy82mCc7UbQvO1F1tztRdCc7UXQnO1F0JztQAAAAACv8qACoAGgAbABsAGQAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"CELEBI\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev179_pikachu\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQzPsAAB4AAAKsAAACDCQAAAICkrJxl94CAADKw8W7vcLP////AgLKvcjT1///ACFCAABMT0O2+j4xsEywcZdVsHGXBFhwl0z2cZcZsBCXG7AAl0Oue4lMsHGXTLBxl0ywcZcAAAAAMv9qAGoARgAkAF8AOgA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"PIKACHU\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_ev229_treecko\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQ5qMAAB4AAAKsAAACDCQAAAICToszm7bHAABXc3j/////////AQJ2Wmpv////ACHVAAD5TBibv0wzm9tSJ5vtTTObyE4zm/gKM5v4TDOb+Ewzm/hMM5v4szm6vWvQvvhMM5sAAAAACv8cABwAEQAMABMAEgARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\",\"title\":\"TREECKO\",\"versions\":[\"ruby\",\"sapphire\"]}],\"family\":\"rs\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"relay_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"727989819e2a1674d24b8f38122b28b3ed1dc044fa96ba1711b173ce3ea87be1a571e0f3e74e1a25c3f6d16ba3c9b45e7b94824ee701edc4ac232b2164331005","key":"e140eec9ec2b6c4830b64aac9f5578ac4b04eca0ce8703aef3aaace4f068c3db"}]==] + +local GameVersion = require("src.core.GameVersion") +GameVersion.set("ruby") +local Dataset = require("src.core.game3.dataset") +Dataset.cacheRootOverride = root +Dataset.mountExtractRoots() +local Space = require("src.core.game3.scripting.space") +Space.bundle = nil +Space.ensureBundle(nil) + +local Json = require("src.link.Json") +local MysteryGift = require("src.core.game3.mystery_gift") +local Event = require("src.core.game3.rs.mystery_event") +local IR = require("src.core.game3.scripting.text_ir") +local Bag = require("src.core.game3.bag") +MysteryGift.GIFT_PUBKEY = FIXTURE_PUB + +local list = assert(MysteryGift.verifyFeed(Json.decode(FEED_RS), "rs", "ruby")) +local function session() + return { version = "ruby", bag = Bag.new(), party = {}, flags = {}, vars = {} } +end + +print("[test] 1. The relay EON TICKET event runs like the e-Reader card") +local s = session() +local r = Event.run(list.events[1].bytes, s) +check(r.valid, "me_checkcompat accepts the block on Ruby") +-- pokeruby/data/debug_mystery_event_scripts.s:134 setmysteryeventstatus 0x2 +eq(r.status, Event.SUCCESS, "the event succeeds") +check(r.save, "and asks for a save") +eq(s.recordMixingGift and s.recordMixingGift.itemId, 275, "the record mixing gift is the EON TICKET") +eq(s.recordMixingGift and s.recordMixingGift.quantity, 5, "five copies spread by record mixing") +check(type(s.ramScriptNativeBytes) == "table" and #s.ramScriptNativeBytes > 0, "Dad's RAM script is installed") +check(IR.toPlain(r.message):find("PETALBURG") ~= nil, "the result says to go see Dad: " .. IR.toPlain(r.message)) +Bag.add(s.bag, 275, 1) +local again = Event.run(list.events[1].bytes, s) +-- pokeruby/data/debug_mystery_event_scripts.s:139 setmysteryeventstatus 0x3 +eq(again.status, Event.FAILURE, "with the ticket in the bag the event refuses") +check(IR.toPlain(again.message):find("only once") ~= nil, "and says it may be played only once") + +print("[test] 2. The ribbon event and the Sapphire header") +local sap = session() +sap.version = "sapphire" +local ribbon = Event.run(list.events[2].bytes, sap) +check(ribbon.valid, "the ribbon block runs on Sapphire") +eq(ribbon.status, Event.SUCCESS, "and succeeds") +local raw = list.events[1].bytes +local rubyOnly = raw:sub(1, 13) .. string.char(0x80, 0, 0, 0) .. raw:sub(18) +local refused = Event.run(rubyOnly, session()) +check(refused.valid, "a Ruby-only header runs on Ruby") +local onSapphire = session() +onSapphire.version = "sapphire" +check(not Event.run(rubyOnly, onSapphire).valid, "but not on Sapphire") + +print("[test] 3. The Mystery Events screen loads the event from the relay") +do + local MenuEvents = require("src.ui.game3.rs.mystery_events") + local transport = { calls = 0 } + function transport:begin(req) self.calls = self.calls + 1 self.url = req.url return 1 end + function transport:poll() return { status = "ok", code = 200, body = FEED_RS } end + function transport:release() end + local writes = 0 + local ui = MenuEvents.new({ session = session(), transport = transport, + writeSave = function() writes = writes + 1 return true end }) + local function step(key) + local inp = { new = {}, held = {} } + if key then inp.new[key] = true end + return ui:frame(inp) + end + for _ = 1, 2000 do + if ui.state == "list" or ui.state == "result_print" then break end + step(ui.printer and ui.printer:isActive() and "a" or nil) + end + eq(ui.state, "list", "the relay events are listed") + check(transport.url and transport.url:find("family=rs", 1, true) and transport.url:find("version=ruby", 1, true), + "the screen asks the relay for the rs feed: " .. tostring(transport.url)) + eq(#ui.rows, #list.events + 1, "every event and the news entry") + eq(ui.rows[#ui.rows].kind, "news", "news rides the same list") + step("a") + for _ = 1, 4000 do + if ui.state == "result" then break end + step((ui.state == "ready" or (ui.printer and ui.printer:isActive())) and "a" or nil) + end + eq(ui.state, "result", "the event ran without a link partner") + eq(ui.result and ui.result.status, Event.SUCCESS, "and succeeded") + eq(writes, 1, "the save was written once") + eq(ui.session.recordMixingGift and ui.session.recordMixingGift.itemId, 275, "the session holds the gift") + + local news = MenuEvents.new({ session = session(), transport = transport, + writeSave = function() writes = writes + 1 return true end }) + ui = news + for _ = 1, 2000 do + if ui.state == "list" then break end + step(ui.printer and ui.printer:isActive() and "a" or nil) + end + for _ = 1, #list.events do step("down") end + step("a") + eq(ui.state, "news_view", "Ruby opens the WONDER NEWS") + step("a") + check(MysteryGift.validateSavedNews(ui.session), "and saves it on the news path") + check(MysteryGift.hasClaimedNews(ui.session, 9), "the news is claimed") + eq(writes, 2, "the news save was written") +end + +print("[test] 4. Every relay event Pokémon arrives as its PKHeX template") +local catalogPath = os.getenv("POKEPORT_GIFT_EVENTS") or "../pokeserver/gifts_events.json" +local f = io.open(catalogPath, "rb") +local rows = {} +if f then + rows = Json.decode(f:read("*a")) + f:close() +else + print("[skip] " .. catalogPath .. " not found, the full catalog run is skipped") +end +local Base64 = require("src.core.Base64") +local Ribbons = require("src.core.game3.rse.ribbons") +local codec = require("src.save_convert.Gen3Save").forVersion("ruby") +local bit = require("bit") +local seen = 0 +for _, row in ipairs(rows) do + if row.payload then + seen = seen + 1 + local g = row.gift + local bytes = Base64.decode(row.payload) + check(MysteryGift.eventHeaderOk(bytes, "ruby") and MysteryGift.eventHeaderOk(bytes, "sapphire"), + row.key .. " passes me_checkcompat on both games") + local s2 = session() + local res = Event.run(bytes, s2) + eq(res.status, Event.SUCCESS, row.key .. " succeeds " .. tostring(res.error)) + local mon = s2.party[1] + local cart = codec.decodePartyMon(bytes:sub(37, 136)) + eq(mon and mon.species, g.species, row.key .. " species") + if mon and g.kind == "mon" then eq(mon.level, g.level, row.key .. " level") end + if mon and g.otName then + local ot = cart.language == 1 and codec.decodeString(cart.otNameRaw, 0, 7, 1, true) or cart.otName + eq(ot, g.otName, row.key .. " OT in the cart bytes") + end + if mon and g.otIdMax then check(mon.otId >= 1 and mon.otId <= g.otIdMax, row.key .. " TID in range") + elseif mon and g.otId then eq(mon.otId, g.otId, row.key .. " TID") end + if mon and g.otId and not g.secretIdRandom then eq(mon.otSecretId, 0, row.key .. " SID 0") end + if mon and g.language then eq(cart.language, g.language, row.key .. " language byte") end + if mon and (g.otGender == 0 or g.otGender == 1) then eq(cart.otGender, g.otGender, row.key .. " OT gender") end + if mon then eq(mon.modernFatefulEncounter == true, g.fateful ~= false, row.key .. " fateful flag") end + if mon and g.nationalRibbon then eq(Ribbons.get(mon, "national"), 1, row.key .. " National Ribbon") end + if mon and g.movesOnly then + for slot = 1, 4 do eq(mon.moves[slot], g.moves[tostring(slot)], row.key .. " move " .. slot) end + end + if mon and g.shiny ~= "random" then + local p = mon.personality % 4294967296 + local v = bit.bxor(bit.bxor(mon.otId % 65536, mon.otSecretId % 65536), bit.bxor(math.floor(p / 65536), p % 65536)) + check((v < 8) == (g.shiny == "always"), row.key .. " shiny rule " .. g.shiny) + end + end +end +if f then check(seen >= 250, "the RS catalog carries every PKHeX event (" .. seen .. ")") end +local full = session() +for i = 1, 6 do full.party[i] = { species = 1 } end +local refused = Event.run(list.events[3].bytes, full) +eq(refused.status, Event.FAILURE, "a full party refuses the gift") + +T.finish("game3_rs_mystery_event_feed") diff --git a/tools/gen3_event_catalog.py b/tools/gen3_event_catalog.py new file mode 100644 index 00000000..18364463 --- /dev/null +++ b/tools/gen3_event_catalog.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +import json +import os +import re +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEV = os.path.abspath(os.path.join(HERE, "..", "..")) +PKHEX = os.environ.get("PKHEX", os.path.join(DEV, "pkhex-master", "PKHeX.Core")) +SERVER = os.environ.get("POKESERVER", os.path.join(DEV, "pokeserver")) +WC3 = "Legality/Encounters/Data/Gen3/EncountersWC3.cs" +RSE = "Legality/Encounters/Data/Gen3/Encounters3RSE.cs" +NY = "Legality/Encounters/Templates/Gen3/Gifts/Distribution3NY.cs" +NY_PKL = "Resources/legality/wild/Gen3/encounter_pcny.pkl" +JPN = "Legality/Encounters/Templates/Gen3/Gifts/Distribution3JPN.cs" + +LANG = {"Japanese": 1, "English": 2, "French": 3, "Italian": 4, "German": 5, "Spanish": 7} +LANG_FILE = {1: "ja", 3: "fr", 4: "it", 5: "de", 7: "es"} +GAME_ID = {"S": 1, "R": 2, "E": 3, "FR": 4, "LG": 5} +ALL = ["firered", "leafgreen", "ruby", "sapphire", "emerald"] +GAMES = {"R": ALL, "S": ALL, "RS": ALL, "Gen3": ALL, "FRLG": ["firered", "leafgreen"], + "EFL": ["firered", "leafgreen", "emerald"]} +GENDER = {"Only0": 0, "RandD3_0": 0, "Only1": 1, "RandD3_1": 1, "Recipient": None} +CONSTS = {"PCJPEggTrainerName": "オヤNAME", "M2WishEggOT": ""} +OWNER = {("Encounters3RSE", 251): {"otId": 31121, "secretIdZero": True, + "note": "owner: SID 0; PKHeX leaves the SID unspecified"}} + + +def read(rel): + with open(os.path.join(PKHEX, rel), encoding="utf-8-sig") as f: + return f.read().split("\n") + + +CATALOG = json.load(open(os.path.join(SERVER, "gifts_catalog.json"), encoding="utf-8")) +INTERNAL = {s["dex"]: s["id"] for s in CATALOG["species"]} +NAME = {s["dex"]: s["name"] for s in CATALOG["species"]} +CHARSET = set(CATALOG["charset"]) +SPECIES_TEXT = {} + + +def species_name(dex, lang): + if lang not in LANG_FILE: + return None + code = LANG_FILE[lang] + if code not in SPECIES_TEXT: + path = os.path.join(PKHEX, "Resources/text/other/%s/text_Species_%s.txt" % (code, code)) + SPECIES_TEXT[code] = open(path, encoding="utf-8-sig").read().split("\n") + name = SPECIES_TEXT[code][dex].strip() + if lang != 1: + name = name.upper() + if not all(ch in CHARSET for ch in name): + return None + return name[:10] + + +def split_top(s): + out, depth, cur = [], 0, "" + for ch in s: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if ch == "," and depth == 0: + out.append(cur.strip()) + cur = "" + else: + cur += ch + if cur.strip(): + out.append(cur.strip()) + return out + + +def value(v): + v = v.strip() + if v.startswith('"'): + return v.strip('"') + if v in CONSTS: + return CONSTS[v] + if v.startswith("(int)"): + return LANG[v[5:]] + if re.match(r"^\d+$", v): + return int(v, 10) + return v + + +def entry(rel, line_no, line): + m = re.match(r"\s*new\((.*?)\)\s*\{(.*)\},?\s*(//\s*(.*))?$", line) + if not m: + return None + args = split_top(m.group(1)) + props = {} + for part in split_top(m.group(2)): + if "=" in part: + k, v = part.split("=", 1) + props[k.strip()] = v.strip() + dex, level, version = int(args[0]), int(args[1]), args[2] + egg = len(args) > 3 and args[3] == "true" + met = None + for a in args[3:]: + if a.startswith("met:"): + met = int(a[4:]) + elif re.match(r"^\d+$", a): + met = int(a) + moves = [] + if "Moves" in props: + moves = [int(x) for x in re.findall(r"\d+", props["Moves"])] + ev = { + "source": "%s:%d" % (rel, line_no), + "comment": (m.group(4) or "").strip(), + "dex": dex, "species": INTERNAL[dex], "level": level, "egg": egg, "version": version, + "moves": [x for x in moves if x], + "ot": value(props.get("OriginalTrainerName", '""')), + "language": value(props["Language"]) if "Language" in props else None, + "shiny": {"Never": "never", "Always": "always"}.get(props.get("Shiny", "Random"), "random"), + "fateful": props.get("FatefulEncounter") == "true", + "nationalRibbon": props.get("RibbonNational") == "true", + "metLevel": met if met is not None else (0 if egg else level), + } + gender = props.get("OriginalTrainerGender") + ev["otGender"] = GENDER[gender] if gender in GENDER else (None if gender is None else 2) + if "ID32" in props: + id32 = int(props["ID32"], 10) + ev["otId"], ev["secretIdRandom"] = id32, False + elif "TID16" in props: + ev["otId"], ev["secretIdRandom"] = int(props["TID16"], 10), True + return ev + + +def wc3(): + out, section = [], None + lines = read(WC3) + for i, line in enumerate(lines, 1): + s = re.match(r"\s*private static readonly EncounterGift3\[\] (\w+)", line) + if s: + section = s.group(1) + ev = entry(WC3, i, line) + if ev: + ev["group"] = section + if ev["language"] is None and not ev["egg"]: + ev["language"] = 2 + if ev["language"] is None and any(ord(ch) > 0x7F for ch in ev["ot"]): + ev["language"] = 1 + out.append(ev) + return out + + +def colo(): + out = [] + lines = read(RSE) + names = {} + for line in lines: + m = re.match(r'\s*private static readonly string\[\] (\w+) = \[(.*)\];', line) + if m: + names[m.group(1)] = [x.strip().strip('"') for x in m.group(2).split(",")] + for i, line in enumerate(lines, 1): + m = re.match(r"\s*new\((\d+), (\d+), (\w+), (\w+)\)\s*\{(.*)\},?\s*//\s*(.*)$", line) + if not m or "Bonus" not in "".join(lines[i - 4:i]) or "without" in "".join(lines[i - 4:i]): + continue + dex = int(m.group(1)) + props = dict((k.strip(), v.strip()) for k, v in (p.split("=", 1) for p in split_top(m.group(5)))) + ev = {"source": "%s:%d" % (RSE, i), "comment": m.group(6).strip(), "group": "ColoBonus", + "dex": dex, "species": INTERNAL[dex], "level": int(m.group(2)), "egg": False, "version": m.group(4), + "moves": [], "ot": names[m.group(3)][1], "language": 1, "shiny": "never", "fateful": False, + "nationalRibbon": False, "metLevel": int(m.group(2)), "otGender": int(props["OriginalTrainerGender"]), + "otId": int(props["TID16"]), "secretIdRandom": True} + fix = OWNER.get(("Encounters3RSE", dex)) + if fix: + ev["otId"], ev["secretIdRandom"], ev["note"] = fix["otId"], False, fix["note"] + out.append(ev) + return out + + +def pcny(): + lines = read(NY) + order = [] + enum_at = next(i for i, l in enumerate(lines) if "public enum Distribution3NY" in l) + for line in lines[enum_at + 2:]: + m = re.match(r"\s*(\w+),\s*$", line) + if not m: + break + order.append(m.group(1)) + names = {} + for i, line in enumerate(lines, 1): + m = re.match(r"\s*(\w+) => (pivot \? (\w) : (\w)|(\w)),", line) + if m and m.group(1) in order and m.group(1) not in names: + picks = [m.group(4), m.group(3)] if m.group(3) else [m.group(5)] + names[m.group(1)] = (["PCNY" + p.lower() for p in picks], i) + data = open(os.path.join(PKHEX, NY_PKL), "rb").read() + out = [] + for k in range(len(data) // 12): + dex, level, dist = struct.unpack_from(" (\d{5}),", text)) + out = [] + for i, line in enumerate(lines, 1): + m = re.match(r"\s*private static ReadOnlySpan Distro(\d) => \[(.*)\];", line) + if not m: + continue + dist = ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"][int(m.group(1)) - 1] + ots = [cities[c] for c in ["Tokyo", "Yokohama", "Nagoya", "Osaka", "Fukuoka", "Sapporo"]] + if dist == "Sixth": + ots = ots[:5] + for dex in [int(x) for x in m.group(2).split(",")]: + out.append({"source": "%s:%d" % (JPN, i), "comment": "PCJP " + dist, "group": "PCJP", "dex": dex, + "species": INTERNAL[dex], "level": 10, "egg": False, "version": "R", "moves": [], + "otNames": ots, "language": 1, "shiny": "never", "fateful": False, + "nationalRibbon": False, "metLevel": 10, "otGender": None, "otId": int(tids[dist]), + "secretIdRandom": False}) + return out + + +def slug(text): + return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") + + +def main(): + events = wc3() + colo() + pcny() + pcjp() + seen = {} + for n, ev in enumerate(events): + base = "ev%03d_%s" % (n, slug(NAME[ev["dex"]])) + ev["key"] = base[:28] + assert ev["key"] not in seen + seen[ev["key"]] = True + ev["idNumber"] = 1000 + n + ev["games"] = GAMES[ev["version"]] + if ev["version"] in GAME_ID: + ev["metGame"] = GAME_ID[ev["version"]] + lang = ev.get("language") + nick = species_name(ev["dex"], lang) if lang and lang != 2 and not ev["egg"] else None + if nick: + ev["nickname"] = nick + title = NAME[ev["dex"]] if not ev["egg"] else "POKéMON EGG" + ev["title"] = title + label = ev["comment"] or title + ev["label"] = ("%s (%s)" % (label, ev.get("ot") or "/".join(ev.get("otNames", [])) or "recipient")) + json.dump(events, sys.stdout, ensure_ascii=False, indent=1) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tools/rs_mystery_event_mons.lua b/tools/rs_mystery_event_mons.lua new file mode 100644 index 00000000..634f9f8a --- /dev/null +++ b/tools/rs_mystery_event_mons.lua @@ -0,0 +1,107 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +print = function() end + +local root, templatesPath = arg[1], arg[2] +assert(root and templatesPath, + "usage: luajit tools/rs_mystery_event_mons.lua ") + +local GameVersion = require("src.core.GameVersion") +GameVersion.set("ruby") +local Dataset = require("src.core.game3.dataset") +Dataset.cacheRootOverride = root +Dataset.mountExtractRoots() +local Pokemon = require("src.core.game3.pokemon") +Pokemon.install(nil) +local Rng = require("src.core.game3.rng") +local Base64 = require("src.core.Base64") +local Json = require("src.link.Json") +local MysteryGift = require("src.core.game3.mystery_gift") +local bit = require("bit") +local codec = require("src.save_convert.Gen3Save").forVersion("ruby") + +local BASE = 0x02000000 +local FATEFUL = 0xFF +local POKE_BALL = 4 + +local function u8(n) return string.char(n % 256) end +local function u16(n) return u8(n) .. u8(math.floor(n / 256)) end +local function u32(n) return u16(n % 65536) .. u16(math.floor(n / 65536)) end + +local function crc16(s) + local crc = 0x1121 + for i = 1, #s do + crc = bit.bxor(crc, s:byte(i)) + for _ = 1, 8 do + if bit.band(crc, 1) ~= 0 then crc = bit.bxor(bit.rshift(crc, 1), 0x8408) else crc = bit.rshift(crc, 1) end + end + end + return bit.band(bit.bnot(crc), 0xFFFF) +end + +local function giftOf(t) + local g = { kind = t.egg and "egg" or "mon", species = t.species, ball = POKE_BALL } + if not t.egg then + g.level, g.metLocation, g.metLevel = t.level, FATEFUL, t.metLevel + end + if #t.moves > 0 then + g.moves, g.movesOnly = {}, true + for i, m in ipairs(t.moves) do g.moves[tostring(i)] = m end + end + if t.ot and t.ot ~= "" then g.otName = t.ot end + if t.otNames then g.otNames = t.otNames end + if t.otIdMax then g.otIdMax = t.otIdMax elseif t.otId then g.otId = t.otId end + if t.secretIdRandom then g.secretIdRandom = true end + if t.otGender then g.otGender = t.otGender end + if t.language then g.language = t.language end + if t.metGame then g.metGame = t.metGame end + if t.nickname then g.nickname = t.nickname end + g.shiny = t.shiny + if not t.fateful then g.fateful = false end + if t.nationalRibbon then g.nationalRibbon = true end + return g +end + +local function partyMon(t, gift, seed) + Rng.SeedRng(seed) + local session = { version = "ruby", party = {}, name = "PLAYER", trainerId = 0, secretId = 0, gender = 0 } + local card = MysteryGift.normalizeCard({ flagId = 1019, idNumber = 1, type = 0, gift = gift }, "ruby") + assert(card, "gift does not normalize: " .. t.key) + local mon = assert(MysteryGift.createEventMon(session, card.gift), "could not build " .. t.key) + local cart = codec.fromPortMon(mon, { trainerId = mon.otId, secretId = mon.otSecretId, playerName = mon.otName, + speciesName = Pokemon.name }, true) + local bytes = codec.encodePartyMon(cart) + assert(#bytes == 100, "party mon is not 100 bytes") + local back = codec.decodePartyMon(bytes) + assert(back.species == t.species and back.otId == mon.otId, "party mon does not round trip: " .. t.key) + if t.language == 1 and mon.otName ~= "" then + assert(codec.decodeString(back.otNameRaw, 0, 7, 1, true) == mon.otName, "Japanese OT lost: " .. t.key) + end + return bytes +end + +-- pokeruby/include/macros/mystery_event_script.inc:5 me_checkcompat +local function payload(monBytes) + local mail = string.rep("\0", 36) + local monAt = 17 + 13 + 5 + 1 + local total = monAt + #monBytes + #mail + local header = u8(1) .. u32(BASE) .. u16(2) .. u32(2) .. u16(4) .. u32(0x180) + local body = u8(12) .. u32(BASE + monAt) .. u8(2) .. monBytes .. mail + local crcOp = u8(16) .. u32(crc16(body)) .. u32(BASE + 30) .. u32(BASE + total) + local out = header .. crcOp .. body + assert(#out == total and #out <= 0x7D4) + return out +end + +local templates = Json.decode(io.open(templatesPath, "rb"):read("*a")) +local out = {} +for i, t in ipairs(templates) do + local gift = giftOf(t) + local row = { key = t.key, label = t.label, title = t.title, games = t.games, source = t.source, + idNumber = t.idNumber, egg = t.egg, gift = gift, note = t.note } + local rs = false + for _, g in ipairs(t.games) do if g == "ruby" or g == "sapphire" then rs = true end end + if rs then row.payload = Base64.encode(payload(partyMon(t, gift, 0x47334500 + i))) end + out[#out + 1] = row +end +io.write(Json.encode(out), "\n") diff --git a/tools/rs_mystery_event_payloads.py b/tools/rs_mystery_event_payloads.py new file mode 100644 index 00000000..eeb3d671 --- /dev/null +++ b/tools/rs_mystery_event_payloads.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import shutil +import struct +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +RUBY = os.path.abspath(os.environ.get("POKERUBY", os.path.join(HERE, "..", "..", "pokeruby"))) +SOURCE = "data/debug_mystery_event_scripts.s" +BASE = 0x02000000 +BOTH_VERSIONS = 0x80 | 0x100 + +EVENTS = [ + ("rs_eon_ticket", "EON TICKET", "gUnknown_Debug_845DAE1", "gUnknown_Debug_845DAE1End"), + ("rs_gift_ribbon", "GIFT RIBBON", "gUnknown_Debug_845E3E0", "gUnknown_Debug_845E3E0End"), +] + + +def tool(name): + for prefix in ("arm-none-eabi-",): + path = shutil.which(prefix + name) + if path: + return path + sys.exit("missing arm-none-eabi-" + name) + + +def crc16(data): + crc = 0x1121 + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return ~crc & 0xFFFF + + +def assemble(work): + preproc = os.path.join(RUBY, "tools", "preproc", "preproc") + charmap = os.path.join(RUBY, "charmap.txt") + src = os.path.join(RUBY, SOURCE) + obj = os.path.join(work, "events.o") + flags = ["-mcpu=arm7tdmi", "-I", "include", "--defsym", "RUBY=1", "--defsym", "REVISION=0", + "--defsym", "DEBUG_FIX=0", "--defsym", "ENGLISH=1", "--defsym", "DEBUG=1", + "--defsym", "MODERN=0"] + a = subprocess.run([preproc, src, charmap], cwd=RUBY, check=True, capture_output=True).stdout + b = subprocess.run([tool("cpp"), "-I", "include"], cwd=RUBY, input=a, check=True, + capture_output=True).stdout + c = subprocess.run([preproc, "-ie", src, charmap], cwd=RUBY, input=b, check=True, + capture_output=True).stdout + subprocess.run([tool("as")] + flags + ["-o", obj], cwd=RUBY, input=c, check=True) + elf = os.path.join(work, "events.elf") + subprocess.run([tool("ld"), "--section-start", ".rodata=0x%08X" % BASE, "-e", "0", "-o", elf, obj], + check=True) + binary = os.path.join(work, "events.bin") + subprocess.run([tool("objcopy"), "-O", "binary", "-j", ".rodata", elf, binary], check=True) + syms = {} + out = subprocess.run([tool("nm"), elf], check=True, capture_output=True, text=True).stdout + for line in out.splitlines(): + parts = line.split() + if len(parts) == 3: + syms[parts[2]] = int(parts[0], 16) + with open(binary, "rb") as f: + return f.read(), syms + + +def fix_crc(block, start): + if block[0] != 1 or block[0x11] != 16 or struct.unpack_from("