From e69b40874467a84bdad25627403fe67f1caa2684 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 7 Oct 2026 14:31:38 -0400 Subject: [PATCH] union for all --- data/scripts/flavor/pokecenter_chansey.lua | 28 + data/scripts/init.lua | 6 +- data/scripts/pokecenter_upstairs.lua | 123 ++ docs/new-features.md | 5 + docs/union-room.md | 197 +++ src/battle/g3u/EffectMap1.lua | 147 ++ src/battle/g3u/EffectMap2.lua | 214 +++ src/battle/g3u/Events.lua | 82 ++ src/battle/g3u/Hash.lua | 156 ++ src/battle/g3u/Match.lua | 452 ++++++ src/battle/g3u/Rng.lua | 29 + src/battle/g3u/Scope.lua | 230 +++ src/battle/g3u/Table.lua | 257 ++++ src/battle/g3u/Wire.lua | 192 +++ src/core/Data.lua | 1 + src/core/Game.lua | 5 +- src/core/Game2.lua | 5 + src/core/Game3.lua | 5 + src/core/SaveData.lua | 30 +- src/core/game3/battle/adapter.lua | 6 + src/core/game3/battle/effects/secondary.lua | 13 + src/core/game3/battle/engine.lua | 8 + src/core/game3/battle/healthbox.lua | 1 + src/core/game3/battle/state.lua | 1 + src/core/game3/battle/ui.lua | 12 +- src/core/game3/field_view.lua | 1 + src/core/game3/link/battle.lua | 200 +-- src/core/game3/link/init.lua | 10 +- src/core/game3/link/union_plaza_map.lua | 134 +- src/core/game3/link/union_room.lua | 453 +++++- src/core/game3/link/union_save_spot.lua | 175 +++ src/core/game3/objects.lua | 16 +- src/core/game3/profiles/emerald_rules.lua | 9 + src/core/game3/profiles/firered_rules.lua | 9 + src/core/game3/profiles/rs.lua | 4 +- src/core/game3/profiles/rs/font.lua | 6 +- src/core/game3/rse/union_rs.lua | 501 +++++++ src/core/game3/rse/union_rs_rules.lua | 51 + src/import/LauncherSettings.lua | 12 + src/import/LauncherView.lua | 6 +- src/link/Wire.lua | 248 ++++ src/online/Client.lua | 45 +- src/online/Protocol2.lua | 59 +- src/online/Trade.lua | 7 + src/online/union/Avatars.lua | 507 +++++++ src/online/union/Badge.lua | 135 ++ src/online/union/BattlePrepModel.lua | 1312 +++++++++++++++++ src/online/union/BattleSession.lua | 598 ++++++++ src/online/union/Caps.lua | 59 + src/online/union/Origin.lua | 36 + src/online/union/Participant.lua | 121 ++ src/online/union/Prep.lua | 363 +++++ src/online/union/Room.lua | 401 +++++ src/online/union/Setting.lua | 26 + src/online/union/TradePrepModel.lua | 386 +++++ src/online/union/TradeTxn.lua | 806 ++++++++++ src/online/xgen/Compat.lua | 304 ++++ src/online/xgen/Datasets.lua | 408 +++++ src/online/xgen/Identity.lua | 129 ++ src/online/xgen/Messages.lua | 146 ++ src/online/xgen/Policy.lua | 131 ++ src/online/xgen/Project.lua | 288 ++++ src/online/xgen/Rentals.lua | 139 ++ src/online/xgen/TradeConvert.lua | 634 ++++++++ src/render/Assets.lua | 14 +- src/render/PaletteFX.lua | 7 +- src/render/TileRenderer.lua | 7 +- src/save_convert/SaveConvert.lua | 62 +- src/ui/g3u/Gen1Screen.lua | 1163 +++++++++++++++ src/ui/g3u/Gen2Facade.lua | 1143 ++++++++++++++ src/ui/g3u/Gen3Presenter.lua | 1057 +++++++++++++ src/ui/g3u/Launch.lua | 239 +++ src/ui/game3/boot.lua | 1 + src/ui/game3/link_tags.lua | 95 +- src/ui/game3/rs/link_records.lua | 7 + src/ui/game3/union_room.lua | 1 + src/ui/gen2/union/Activity.lua | 144 ++ src/ui/gen2/union/Dialog.lua | 110 ++ src/ui/gen2/union/Tag.lua | 57 + src/ui/gen2/union/Talk.lua | 149 ++ src/ui/gen2/union/Text.lua | 90 ++ src/ui/union/Flow.lua | 86 ++ src/ui/union/gen1/Activity.lua | 171 +++ src/ui/union/gen1/Dialog.lua | 95 ++ src/ui/union/gen1/Look.lua | 138 ++ src/ui/union/gen1/Tag.lua | 55 + src/ui/union/gen1/Talk.lua | 102 ++ src/ui/union/gen1/Text.lua | 106 ++ src/ui/union/prep/Gen1BattlePrep.lua | 113 ++ src/ui/union/prep/Gen1TradePrep.lua | 123 ++ src/ui/union/prep/Gen2BattlePrep.lua | 60 + src/ui/union/prep/Gen2TradePrep.lua | 59 + src/ui/union/prep/Gen3BattlePrep.lua | 141 ++ src/ui/union/prep/Gen3TradePrep.lua | 144 ++ src/ui/union/prep/Open.lua | 89 ++ src/ui/union/prep/OpenTrade.lua | 636 ++++++++ src/world/OverworldController.lua | 9 +- src/world/PikachuFollower.lua | 2 + src/world/gen1/UnionCenters.lua | 398 +++++ src/world/gen1/UnionRoomMap.lua | 62 + src/world/gen1/UnionRoomPresence.lua | 596 ++++++++ src/world/gen1/UnionSafety.lua | 124 ++ src/world/gen2/UnionCenter2F.lua | 270 ++++ src/world/gen2/UnionRoomMap.lua | 153 ++ src/world/gen2/UnionRoomPresence.lua | 475 ++++++ src/world/gen2/UnionSafety.lua | 135 ++ src/world/gen2/World.lua | 3 + tests/drivers/em_link_union_plaza.lua | 16 +- tests/drivers/g3link_room_presence.lua | 5 +- tests/drivers/g3link_union_plaza.lua | 4 +- tests/drivers/g3link_union_plaza_perf.lua | 2 +- tests/drivers/g3link_union_room.lua | 2 +- tests/drivers/g3u_gen1_battle.lua | 215 +++ tests/drivers/g3u_gen2_battle.lua | 226 +++ tests/drivers/g3u_gen3_battle.lua | 232 +++ tests/drivers/g3u_relay_xgen.lua | 206 +++ tests/drivers/rs_link_records_machine.lua | 26 + tests/drivers/rs_small_font.lua | 59 + tests/drivers/union_e2e_relay.lua | 754 ++++++++++ tests/drivers/union_gen1_flow.lua | 308 ++++ tests/drivers/union_gen1_layout.lua | 44 + tests/drivers/union_gen1_off.lua | 68 + tests/drivers/union_gen1_pikachu.lua | 112 ++ tests/drivers/union_gen1_room_presence.lua | 289 ++++ tests/drivers/union_gen1_room_relay.lua | 86 ++ tests/drivers/union_gen1_stairs.lua | 88 ++ tests/drivers/union_gen2_center.lua | 329 +++++ tests/drivers/union_gen2_layout.lua | 68 + tests/drivers/union_gen2_off_safety.lua | 105 ++ tests/drivers/union_gen2_room_presence.lua | 228 +++ tests/drivers/union_gen2_room_relay.lua | 116 ++ tests/drivers/union_gen3_centers.lua | 118 ++ tests/drivers/union_gen3_mixed.lua | 64 + tests/drivers/union_gen3_plaza_save.lua | 68 + tests/drivers/union_gen3_rs_room.lua | 153 ++ tests/drivers/union_gen3_util.lua | 168 +++ tests/drivers/union_prep_battle_gen1.lua | 28 + tests/drivers/union_prep_battle_gen2.lua | 28 + tests/drivers/union_prep_battle_gen3.lua | 50 + tests/drivers/union_trade_firered_gold.lua | 33 + tests/drivers/union_trade_gold_red.lua | 20 + tests/drivers/union_trade_red_firered.lua | 24 + tests/drivers/union_w9_yellow_desk.lua | 94 ++ tests/engine/_g3u_fixture.lua | 296 ++++ tests/engine/_union_prep_pair.lua | 65 + tests/engine/_union_trade_fixture.lua | 91 ++ tests/engine/_xgen_fixture.lua | 280 ++++ tests/engine/g3u_gen1_screen_test.lua | 171 +++ tests/engine/g3u_gen2_facade_test.lua | 287 ++++ tests/engine/g3u_gen3_presenter_test.lua | 232 +++ tests/engine/g3u_lockstep_test.lua | 127 ++ tests/engine/g3u_mechanics_test.lua | 193 +++ tests/engine/g3u_scope_test.lua | 131 ++ tests/engine/g3u_table_test.lua | 250 ++++ tests/engine/g3u_wire_test.lua | 104 ++ .../game3_union_rs_trainer_pic_test.lua | 27 + tests/engine/rs_link_records_visible_test.lua | 22 + tests/engine/save_import_retry_bug420.lua | 2 +- .../save_slot_migrate_trade_journal_test.lua | 56 + tests/engine/union_battle_launch_test.lua | 158 ++ tests/engine/union_battle_prep_cache_test.lua | 100 ++ tests/engine/union_battle_prep_flow_test.lua | 375 +++++ .../engine/union_battle_prep_native_test.lua | 44 + tests/engine/union_battle_session_test.lua | 437 ++++++ tests/engine/union_export_safety.lua | 149 ++ tests/engine/union_flow_launch_test.lua | 127 ++ tests/engine/union_gen1_centers.lua | 414 ++++++ tests/engine/union_gen1_centers_cache.lua | 94 ++ tests/engine/union_gen1_presence_test.lua | 569 +++++++ tests/engine/union_gen2_center.lua | 415 ++++++ .../engine/union_gen2_presence_draw_test.lua | 91 ++ tests/engine/union_gen2_presence_test.lua | 479 ++++++ tests/engine/union_gen3_save_spot.lua | 123 ++ tests/engine/union_room_avatars_test.lua | 395 +++++ tests/engine/union_room_prep_test.lua | 283 ++++ tests/engine/union_room_relay_test.lua | 278 ++++ tests/engine/union_room_service_test.lua | 336 +++++ tests/engine/union_setting_test.lua | 88 ++ tests/engine/union_trade_combos_cache.lua | 163 ++ tests/engine/union_trade_flow_test.lua | 135 ++ tests/engine/union_trade_model_test.lua | 148 ++ tests/engine/union_trade_txn_test.lua | 429 ++++++ tests/engine/union_yellow_receptionist.lua | 66 + tests/engine/xgen_compat_battle_test.lua | 126 ++ tests/engine/xgen_identity_test.lua | 73 + tests/engine/xgen_messages_test.lua | 48 + tests/engine/xgen_project_test.lua | 79 + tests/engine/xgen_rentals_test.lua | 66 + tests/engine/xgen_trade_test.lua | 215 +++ tests/engine/yellow_pokecenter_chansey.lua | 64 + tests/game3_link_relay_union_test.lua | 2 +- tests/game3_union_rs_test.lua | 155 ++ tests/game3_union_xgen_test.lua | 287 ++++ tests/support/fake_relay.lua | 454 +++++- tests/support/g3u_loopback.lua | 94 ++ tests/support/union_prep_driver.lua | 172 +++ tests/support/union_prep_loopback.lua | 137 ++ tests/support/union_trade_driver.lua | 267 ++++ 198 files changed, 34391 insertions(+), 285 deletions(-) create mode 100644 data/scripts/flavor/pokecenter_chansey.lua create mode 100644 data/scripts/pokecenter_upstairs.lua create mode 100644 docs/union-room.md create mode 100644 src/battle/g3u/EffectMap1.lua create mode 100644 src/battle/g3u/EffectMap2.lua create mode 100644 src/battle/g3u/Events.lua create mode 100644 src/battle/g3u/Hash.lua create mode 100644 src/battle/g3u/Match.lua create mode 100644 src/battle/g3u/Rng.lua create mode 100644 src/battle/g3u/Scope.lua create mode 100644 src/battle/g3u/Table.lua create mode 100644 src/battle/g3u/Wire.lua create mode 100644 src/core/game3/link/union_save_spot.lua create mode 100644 src/core/game3/rse/union_rs.lua create mode 100644 src/core/game3/rse/union_rs_rules.lua create mode 100644 src/online/union/Avatars.lua create mode 100644 src/online/union/Badge.lua create mode 100644 src/online/union/BattlePrepModel.lua create mode 100644 src/online/union/BattleSession.lua create mode 100644 src/online/union/Caps.lua create mode 100644 src/online/union/Origin.lua create mode 100644 src/online/union/Participant.lua create mode 100644 src/online/union/Prep.lua create mode 100644 src/online/union/Room.lua create mode 100644 src/online/union/Setting.lua create mode 100644 src/online/union/TradePrepModel.lua create mode 100644 src/online/union/TradeTxn.lua create mode 100644 src/online/xgen/Compat.lua create mode 100644 src/online/xgen/Datasets.lua create mode 100644 src/online/xgen/Identity.lua create mode 100644 src/online/xgen/Messages.lua create mode 100644 src/online/xgen/Policy.lua create mode 100644 src/online/xgen/Project.lua create mode 100644 src/online/xgen/Rentals.lua create mode 100644 src/online/xgen/TradeConvert.lua create mode 100644 src/ui/g3u/Gen1Screen.lua create mode 100644 src/ui/g3u/Gen2Facade.lua create mode 100644 src/ui/g3u/Gen3Presenter.lua create mode 100644 src/ui/g3u/Launch.lua create mode 100644 src/ui/gen2/union/Activity.lua create mode 100644 src/ui/gen2/union/Dialog.lua create mode 100644 src/ui/gen2/union/Tag.lua create mode 100644 src/ui/gen2/union/Talk.lua create mode 100644 src/ui/gen2/union/Text.lua create mode 100644 src/ui/union/Flow.lua create mode 100644 src/ui/union/gen1/Activity.lua create mode 100644 src/ui/union/gen1/Dialog.lua create mode 100644 src/ui/union/gen1/Look.lua create mode 100644 src/ui/union/gen1/Tag.lua create mode 100644 src/ui/union/gen1/Talk.lua create mode 100644 src/ui/union/gen1/Text.lua create mode 100644 src/ui/union/prep/Gen1BattlePrep.lua create mode 100644 src/ui/union/prep/Gen1TradePrep.lua create mode 100644 src/ui/union/prep/Gen2BattlePrep.lua create mode 100644 src/ui/union/prep/Gen2TradePrep.lua create mode 100644 src/ui/union/prep/Gen3BattlePrep.lua create mode 100644 src/ui/union/prep/Gen3TradePrep.lua create mode 100644 src/ui/union/prep/Open.lua create mode 100644 src/ui/union/prep/OpenTrade.lua create mode 100644 src/world/gen1/UnionCenters.lua create mode 100644 src/world/gen1/UnionRoomMap.lua create mode 100644 src/world/gen1/UnionRoomPresence.lua create mode 100644 src/world/gen1/UnionSafety.lua create mode 100644 src/world/gen2/UnionCenter2F.lua create mode 100644 src/world/gen2/UnionRoomMap.lua create mode 100644 src/world/gen2/UnionRoomPresence.lua create mode 100644 src/world/gen2/UnionSafety.lua create mode 100644 tests/drivers/g3u_gen1_battle.lua create mode 100644 tests/drivers/g3u_gen2_battle.lua create mode 100644 tests/drivers/g3u_gen3_battle.lua create mode 100644 tests/drivers/g3u_relay_xgen.lua create mode 100644 tests/drivers/rs_link_records_machine.lua create mode 100644 tests/drivers/rs_small_font.lua create mode 100644 tests/drivers/union_e2e_relay.lua create mode 100644 tests/drivers/union_gen1_flow.lua create mode 100644 tests/drivers/union_gen1_layout.lua create mode 100644 tests/drivers/union_gen1_off.lua create mode 100644 tests/drivers/union_gen1_pikachu.lua create mode 100644 tests/drivers/union_gen1_room_presence.lua create mode 100644 tests/drivers/union_gen1_room_relay.lua create mode 100644 tests/drivers/union_gen1_stairs.lua create mode 100644 tests/drivers/union_gen2_center.lua create mode 100644 tests/drivers/union_gen2_layout.lua create mode 100644 tests/drivers/union_gen2_off_safety.lua create mode 100644 tests/drivers/union_gen2_room_presence.lua create mode 100644 tests/drivers/union_gen2_room_relay.lua create mode 100644 tests/drivers/union_gen3_centers.lua create mode 100644 tests/drivers/union_gen3_mixed.lua create mode 100644 tests/drivers/union_gen3_plaza_save.lua create mode 100644 tests/drivers/union_gen3_rs_room.lua create mode 100644 tests/drivers/union_gen3_util.lua create mode 100644 tests/drivers/union_prep_battle_gen1.lua create mode 100644 tests/drivers/union_prep_battle_gen2.lua create mode 100644 tests/drivers/union_prep_battle_gen3.lua create mode 100644 tests/drivers/union_trade_firered_gold.lua create mode 100644 tests/drivers/union_trade_gold_red.lua create mode 100644 tests/drivers/union_trade_red_firered.lua create mode 100644 tests/drivers/union_w9_yellow_desk.lua create mode 100644 tests/engine/_g3u_fixture.lua create mode 100644 tests/engine/_union_prep_pair.lua create mode 100644 tests/engine/_union_trade_fixture.lua create mode 100644 tests/engine/_xgen_fixture.lua create mode 100644 tests/engine/g3u_gen1_screen_test.lua create mode 100644 tests/engine/g3u_gen2_facade_test.lua create mode 100644 tests/engine/g3u_gen3_presenter_test.lua create mode 100644 tests/engine/g3u_lockstep_test.lua create mode 100644 tests/engine/g3u_mechanics_test.lua create mode 100644 tests/engine/g3u_scope_test.lua create mode 100644 tests/engine/g3u_table_test.lua create mode 100644 tests/engine/g3u_wire_test.lua create mode 100644 tests/engine/game3_union_rs_trainer_pic_test.lua create mode 100644 tests/engine/rs_link_records_visible_test.lua create mode 100644 tests/engine/save_slot_migrate_trade_journal_test.lua create mode 100644 tests/engine/union_battle_launch_test.lua create mode 100644 tests/engine/union_battle_prep_cache_test.lua create mode 100644 tests/engine/union_battle_prep_flow_test.lua create mode 100644 tests/engine/union_battle_prep_native_test.lua create mode 100644 tests/engine/union_battle_session_test.lua create mode 100644 tests/engine/union_export_safety.lua create mode 100644 tests/engine/union_flow_launch_test.lua create mode 100644 tests/engine/union_gen1_centers.lua create mode 100644 tests/engine/union_gen1_centers_cache.lua create mode 100644 tests/engine/union_gen1_presence_test.lua create mode 100644 tests/engine/union_gen2_center.lua create mode 100644 tests/engine/union_gen2_presence_draw_test.lua create mode 100644 tests/engine/union_gen2_presence_test.lua create mode 100644 tests/engine/union_gen3_save_spot.lua create mode 100644 tests/engine/union_room_avatars_test.lua create mode 100644 tests/engine/union_room_prep_test.lua create mode 100644 tests/engine/union_room_relay_test.lua create mode 100644 tests/engine/union_room_service_test.lua create mode 100644 tests/engine/union_setting_test.lua create mode 100644 tests/engine/union_trade_combos_cache.lua create mode 100644 tests/engine/union_trade_flow_test.lua create mode 100644 tests/engine/union_trade_model_test.lua create mode 100644 tests/engine/union_trade_txn_test.lua create mode 100644 tests/engine/union_yellow_receptionist.lua create mode 100644 tests/engine/xgen_compat_battle_test.lua create mode 100644 tests/engine/xgen_identity_test.lua create mode 100644 tests/engine/xgen_messages_test.lua create mode 100644 tests/engine/xgen_project_test.lua create mode 100644 tests/engine/xgen_rentals_test.lua create mode 100644 tests/engine/xgen_trade_test.lua create mode 100644 tests/engine/yellow_pokecenter_chansey.lua create mode 100644 tests/game3_union_rs_test.lua create mode 100644 tests/game3_union_xgen_test.lua create mode 100644 tests/support/g3u_loopback.lua create mode 100644 tests/support/union_prep_driver.lua create mode 100644 tests/support/union_prep_loopback.lua create mode 100644 tests/support/union_trade_driver.lua diff --git a/data/scripts/flavor/pokecenter_chansey.lua b/data/scripts/flavor/pokecenter_chansey.lua new file mode 100644 index 00000000..9e34075b --- /dev/null +++ b/data/scripts/flavor/pokecenter_chansey.lua @@ -0,0 +1,28 @@ +-- pokeyellow engine/events/pokecenter_chansey.asm:1 +local function chansey() + return { + { "play_cry", "CHANSEY", true }, + { "show_text", "_NurseChanseyText" }, + } +end + +local TEXTS = { + VIRIDIAN_POKECENTER = "TEXT_VIRIDIANPOKECENTER_CHANSEY", -- scripts/ViridianPokecenter.asm:29 + PEWTER_POKECENTER = "TEXT_PEWTERPOKECENTER_CHANSEY", -- scripts/PewterPokecenter.asm:39 + MT_MOON_POKECENTER = "TEXT_MTMOONPOKECENTER_CHANSEY", -- scripts/MtMoonPokecenter.asm:40 + CERULEAN_POKECENTER = "TEXT_CERULEANPOKECENTER_CHANSEY", -- scripts/CeruleanPokecenter.asm:29 + ROCK_TUNNEL_POKECENTER = "TEXT_ROCKTUNNELPOKECENTER_CHANSEY", -- scripts/RockTunnelPokecenter.asm:29 + VERMILION_POKECENTER = "TEXT_VERMILIONPOKECENTER_CHANSEY", -- scripts/VermilionPokecenter.asm:29 + LAVENDER_POKECENTER = "TEXT_LAVENDERPOKECENTER_CHANSEY", -- scripts/LavenderPokecenter.asm:29 + CELADON_POKECENTER = "TEXT_CELADONPOKECENTER_CHANSEY", -- scripts/CeladonPokecenter.asm:29 + SAFFRON_POKECENTER = "TEXT_SAFFRONPOKECENTER_CHANSEY", -- scripts/SaffronPokecenter.asm:29 + FUCHSIA_POKECENTER = "TEXT_FUCHSIAPOKECENTER_CHANSEY", -- scripts/FuchsiaPokecenter.asm:29 + CINNABAR_POKECENTER = "TEXT_CINNABARPOKECENTER_CHANSEY", -- scripts/CinnabarPokecenter.asm:29 + INDIGO_PLATEAU_LOBBY = "TEXT_INDIGOPLATEAULOBBY_CHANSEY", -- scripts/IndigoPlateauLobby.asm:42 +} + +local M = {} +for mapId, text in pairs(TEXTS) do + M[mapId] = { talk = { [text] = chansey() } } +end +return M diff --git a/data/scripts/init.lua b/data/scripts/init.lua index 42fe74e6..71fc91fb 100644 --- a/data/scripts/init.lua +++ b/data/scripts/init.lua @@ -37,7 +37,8 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2", "data.scripts.story5", "data.scripts.story6", "data.scripts.story7", "data.scripts.flavor_all", "data.scripts.safari", "data.scripts.seafoam", - "data.scripts.gyms" }) do + "data.scripts.gyms", + "data.scripts.pokecenter_upstairs" }) do for mapId, mod in pairs(require(file)) do MapScripts.attachBase(mapId, mod) end @@ -49,7 +50,8 @@ if GameVersion.isYellow() then for _, file in ipairs({ "data.scripts.yellow_gifts", "data.scripts.yellow_jessie_james", "data.scripts.yellow_beach_house", - "data.scripts.yellow_viridian_old_man" }) do + "data.scripts.yellow_viridian_old_man", + "data.scripts.flavor.pokecenter_chansey" }) do for mapId, mod in pairs(require(file)) do MapScripts.attachBase(mapId, mod) end diff --git a/data/scripts/pokecenter_upstairs.lua b/data/scripts/pokecenter_upstairs.lua new file mode 100644 index 00000000..7075ead8 --- /dev/null +++ b/data/scripts/pokecenter_upstairs.lua @@ -0,0 +1,123 @@ +local UnionCenters = require("src.world.gen1.UnionCenters") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") +local Origin = require("src.online.union.Origin") +local Presence = require("src.world.gen1.UnionRoomPresence") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") + +local GATE = UnionCenters.GATE_2F +local STAIRS = UnionCenters.STAIRS_2F + +local WELCOME = Strings("This is the UNION\nROOM. TRAINERS\vfrom near and far\vmeet up inside.\fWould you like to\ngo in?") +local ENJOY = Strings("Step right in!\nHave fun!") + +local function say(game, text, after, opts) + game.stack:push(TextBox.new(game, text, after, opts)) +end + +local function gateIds(game) + local r = UnionCenters.forData(game.data) + return r and r.gateClosed, r and r.gateOpen +end + +local function walkIn(game) + local _, open = gateIds(game) + local ex, ey, facing = UnionRoomMap.entry() + return { + { "replace_block", GATE.bx, GATE.by, open }, + { "play_sound", "Switch" }, + { "wait", 20 }, + { "move_player", "left", 1 }, + { "move_player", "up", 3 }, + { "warp", UnionCenters.UNION_ROOM, ex, ey, facing }, + } +end + +local function walkOut(game) + local closed = gateIds(game) + return { + { "move_player", "down", 3 }, + { "replace_block", GATE.bx, GATE.by, closed }, + { "play_sound", "Switch" }, + { "face_player_dir", "right" }, + { "face_object", UnionCenters.UNION_RECEPTIONIST, "left" }, + { "show_text", "_CableClubNPCPleaseComeAgainText" }, + { "face_object", UnionCenters.UNION_RECEPTIONIST, "down" }, + } +end + +local function unionReceptionist(game, ow, npc, done) + local t = game.data.text + if not game.save.flags.EVENT_GOT_POKEDEX then + say(game, t._CableClubNPCMakingPreparationsText, done) + return + end + local function decline() say(game, t._CableClubNPCPleaseComeAgainText, done) end + say(game, WELCOME, nil, { choice = function(yes) + if not yes then decline() return end + say(game, t._WouldYouLikeToSaveText, nil, { choice = function(save) + if not save then decline() return end + game:writeSave() + say(game, t._GameSavedText, function() + say(game, ENJOY, function() + ow.runner:run(walkIn(game), { npc = npc, onDone = done }) + end) + end, { auto = { + sound = function() return require("src.core.Sound").play(game.data, "Save") end, + delay = 30, + } }) + end }) + end }) +end + +local function recordOrigin(game, fromMapId) + local plan = UnionCenters.planFor(game.data, fromMapId) + if not plan then return end + Origin.record(game.save, { + gen = 1, version = require("src.core.GameVersion").get(), + map = plan.map, warp = plan.warp, x = plan.stairs.x, y = plan.stairs.y, + facing = "down", + }) +end + +local function returnDown(game, ow, x, y) + local o = Origin.get(game.save) + local plan = o and o.gen == 1 and UnionCenters.planFor(game.data, o.map) + Origin.clear(game.save) + if plan and plan.warp == o.warp then + ow:takeWarp({ x = x, y = y, destMap = plan.map, destWarp = plan.warp }) + else + ow:warpToHealPoint() + end +end + +return { + [UnionCenters.FLOOR_2F] = { + onEnter = function(game, ow, fromMapId) + local closed, open = gateIds(game) + if fromMapId == UnionCenters.UNION_ROOM then + ow:replaceBlock(GATE.bx, GATE.by, open) + ow:queueScript(walkOut(game)) + return + end + if ow.map:blockAt(GATE.bx, GATE.by) ~= closed then + ow:replaceBlock(GATE.bx, GATE.by, closed) + end + recordOrigin(game, fromMapId) + end, + onStep = function(game, ow, x, y) + if x ~= STAIRS.x or y ~= STAIRS.y then return false end + returnDown(game, ow, x, y) + return true + end, + talk = { + [UnionCenters.TEXT_UNION] = unionReceptionist, + }, + }, + [UnionCenters.UNION_ROOM] = { + onEnter = function(game, ow) Presence.enter(game, ow) end, + talk = { + [Presence.TEXT] = Presence.talk, + }, + }, +} diff --git a/docs/new-features.md b/docs/new-features.md index 00b9fc77..f8d879b7 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -19,6 +19,11 @@ Features intentionally added to the games themselves, beyond what the original c * **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 +* **Cross-generation Union Room** online for every game: Gen 1, 2 and 3 players share one 40-player room, each shown with their own game's sprite and a circled 1/2/3 badge +* **Union Room in Red, Blue, Yellow, Gold, Silver and Crystal** upstairs in every Pokémon Center, on by default; Launcher Options -> Union Room OFF restores the original Centers +* **Union Room in Ruby and Sapphire**, a door added to every Pokémon Center 2F +* **Cross-generation battles** under one Gen 3 ruleset limited to the older game's Pokémon and moves, with substitutes, move fixes and type-covering rentals that never touch the save +* **Cross-generation trades** with both players shown every permanent change before confirming, journaled so an interrupted trade finishes exactly once * **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) * **Full controller support**: both thumbsticks move and navigate menus, and stick directions, triggers, stick clicks and extra SDL buttons can be bound in CONTROLS diff --git a/docs/union-room.md b/docs/union-room.md new file mode 100644 index 00000000..e6ca8b0e --- /dev/null +++ b/docs/union-room.md @@ -0,0 +1,197 @@ +# Cross-generation Union Room + +Gen 1, 2 and 3 players meet in one Union Room over the relay, battle and +trade. Cross-generation battles and trades are a feature of this app, not of +the original games. + +## Setting + +Launcher Options -> Union Room (default ON; a missing preference counts as +ON, an explicit OFF is kept). OFF restores the original Gen 1 and Gen 2 +Pokemon Centers: no stairs, no 2F additions, the Gen 1 cable club desk back on +1F. Gen 3 Union Rooms always work. Map changes are applied to a fresh copy of +the map data at load, so toggling never accumulates edits. + +## Saves + +A save written anywhere the original game has no such cell (Gen 1 2F, any +added Union Room, Gen 2 2F added columns, the Ruby/Sapphire added door and +room, the FireRed/LeafGreen/Emerald 40-player room) records the player in +front of the origin Pokemon Center's 1F nurse desk, facing up. Loading such a +save from an older build moves the player there too, with the setting ON or +OFF. Cart-format exports never contain an added map or cell. + +## Imports + +- Entering the room, battling and trading need only the game being played. +- Another player's real sprite needs their game family imported (Gen 1: Red, + Blue or Yellow; Gen 2 male: Gold, Silver or Crystal; Gen 2 female: Crystal; + FireRed/LeafGreen classes: FireRed or LeafGreen; Emerald classes: Emerald; + Ruby/Sapphire player: Ruby, Sapphire or Emerald). Otherwise they appear as an + ordinary trainer from your own game, chosen the same way every time, with + their generation badge; talking to them names the import that shows their + real look. + +## Battles + +- Same generation (Gen 1 vs Gen 1, Gen 2 vs Gen 2, Gen 3 vs Gen 3): the + existing link battle for that generation. +- Cross-generation: ruleset `g3u`. Gen 3 battle mechanics, run on every + client from one move and species-type table taken from the lower-generation + player's own game (Gen 1 table against any Gen 2/3 player, Gen 2 table + against a Gen 3 player) and sent once at battle start (numbers only). + Species and moves are limited to that table (151/165 with a Gen 1 player, + 251/251 with Gen 2). With a Gen 1/2 player present: abilities and held items + off, natures neutral. Both clients run the same lockstep core + (`src/battle/g3u/`) and compare a state hash every turn. +- Preparation (temporary, never written to the save): rules, team problems, + substitutes from your party and PC, rentals, move fixes, team size, + confirm. Any change after confirming clears both confirmations. The + opponent's team is never shown. + +Source of truth: `src/online/xgen/Policy.lua` (`Policy.BATTLE`, `Policy.TRADE` +field tables, constants). + +## Identity + +- Species: national dex number. Gen 1/2 caches give it as `dex` on each + `pokemon.lua` row; Gen 3 via `national.lua` (`toSpecies[national]` is the + internal species id). +- Moves: canonical move number. Gen 1/2 rows carry `index`; Gen 3 ids are + already canonical (1..354). Gen 1 = 1..165, Gen 2 = 1..251. +- Names are normalized (upper case, ♀/♂ -> F/M, é -> E, only A-Z0-9 kept) + and `Identity.agree(a, b)` asserts that every shared species and move id + has the same normalized name in both caches (a truncated name that is a + subsequence of the other also counts as the same). Checked over all 55 + pairs of the 11 local caches: zero mismatches. +- Types: canonical names NORMAL..DARK plus MYSTERY. Gen 1/2 `PSYCHIC_TYPE` + -> PSYCHIC, `CURSE_TYPE` -> MYSTERY. Gen 3 type ids map through the pret + `TYPE_*` constants; the cache's truncated display names (ELECTR, PSYCHC) are + checked as subsequences. +- Items: canonical key = normalized display name. Gen 1 has no held items. +- Category (for move suggestions only): status if power 0, else physical for + types below MYSTERY, special above (Gen 3 `IS_TYPE_PHYSICAL`). + +## Learnability (owner's own game data) + +`Datasets.learnSources(data, national, level)` unions over the species and +every pre-evolution: level-up moves at or below the level (level 1 moves +included), TM/HM, tutor (Crystal `tutorMoves`; Emerald/FRLG tutor bitfield; +FRLG Cape Brink for the Kanto starters), egg moves (Gen 2/3). Both Gen 2 +cache layouts (`levelMoves`/`eggMoves`/`tmhm`/`tutorMoves` and +`learnset`/`level1Moves`) are read. + +## Battle projection (g3u, temporary, never written) + +| field | rule | +|---|---| +| species | national dex kept; must be <= ruleset dexMax (`species_not_in_ruleset` otherwise) | +| level | kept | +| ivs | Gen 3 kept. Gen 1/2: IV = 2*DV+1 for Atk/Def/Spe; HP IV = 2*HPDV+1 where HPDV is derived from the four DV low bits; SpA = SpD = 2*Special+1 | +| evs | Gen 3 kept. Gen 1/2: EV = min(255, floor(sqrt(StatExp))); Special Stat Exp feeds both SpA and SpD; 510 total cap applied in HP, Atk, Def, Spe, SpA, SpD order | +| base stats | owner's own game; Gen 1 base Special used for SpA and SpD | +| stats | Gen 3 formula (`CalculateMonStats`, Shedinja HP 1) from the above and nature | +| nature | neutral (0) when a Gen 1/2 player is present; Gen 3 nature only when not | +| ability, item | 0 when a Gen 1/2 player is present | +| types | not in the record: typing comes from the match table (owner decision 2) | +| moves | each must be id <= moveMax, not in the match table's unsupported list, and learnable per the owner's data. Codes: `move_not_in_ruleset`, `move_unsupported`, `move_not_legal`. Player picks a legal replacement or empties the slot; order of the remaining moves kept; at least one move (`no_legal_moves`) | +| pp | owner's base PP + PP Ups * floor(base/5), full | +| gender | Gen 2 DV rule, Gen 3 personality rule, none for Gen 1 | +| shiny | Gen 1/2 DV rule, Gen 3 personality rule | +| hp / status | full / clear | + +Record shape: `{national, species, nickname, level, hp, maxHp, atk, def, +spAtk, spDef, speed, moves={{id,pp,ppUps}}, ability, item, ivs, evs, nature, +gender, shiny, unownLetter, sourceGen, rental}`. Output never aliases input. + +Team size: default min(roster sizes) (or a smaller size both agree); a larger +roster gets `choose_sit_out` with the number to bench and is never trimmed +automatically. Replacement candidates (party then PC order): shares a type, +then same primary type, then level closeness, then source order; only +species in the ruleset with at least one legal move. Move suggestions: same +type, then same category and power band (0, 1-40, 41-70, 71-100, 101+), then +name. + +## Permanent trade conversion (TradeConvert, policy v1) + +Deterministic, no rerolls. Move legality policy: a kept move must exist in +the destination and be learnable by the species in the destination game OR +in the source game (so a legally learned Gen 2 egg move or a FRLG tutor move +survives). Staged replacements must be learnable in the destination game. + +| field | rule | +|---|---| +| species | national kept; `species_missing` if the destination lacks it | +| level | kept | +| exp | kept, clamped into the destination curve's range for that level | +| Gen 1/2 -> Gen 3 IVs/EVs | as in the battle projection | +| Gen 3 -> Gen 1/2 DVs | target floor(IV/2) for Atk/Def/Spe and Special from SpA; the nearest DV set (L1 distance, first in attack-major scan order on ties) that keeps shininess, Gen 2 gender and the Unown letter; HP DV derived; HP IV lost. Unown !/? refused (`traits_unrepresentable`) | +| Gen 3 -> Gen 1/2 Stat Exp | min(65535, EV^2); Special from SpA EV; SpD EV lost when it differs | +| personality (Gen 1/2 -> Gen 3) | seed = FNV-1a of canonical {national, OT ID, DVs, exp, level}; high half starts at seed % 65536 and walks upward; for each high half the low halves are scanned for a personality with pid % 25 == 0 (Hardy), even (ability slot 0), the Gen 2 DV gender under the destination gender ratio, the DV Unown letter, and shiny iff the DVs are shiny with Secret ID 0 | +| nature | from personality | +| ability | Gen 1/2 -> 3: slot 0; Gen 3 -> 3: kept; Gen 3 -> 1/2: lost | +| moves / pp | see legality above; PP at destination max with the same PP Ups | +| nickname | kept when the destination charset encodes it within 10 characters, else refused; a nickname equal to the species name becomes the destination default | +| OT | name (7) and ID (0..65535) kept when encodable, else refused | +| item | Gen 2/3 by canonical name, else refused until removed. Mail refused. Gen 2 -> Gen 1 stored as the catch-rate byte; Gen 1 -> Gen 2 from the catch rate (Time Capsule); Gen 3 item -> Gen 1 refused; Gen 1 catch rate -> Gen 3 lost | +| friendship | kept Gen 2 <-> 3; Gen 1 -> Gen 2 is 70; Gen 1 -> Gen 3 is the species base; lost into Gen 1 | +| pokerus | kept Gen 2 <-> 3; lost into Gen 1 | +| origin | Gen 3 target from Gen 1/2: met location 0xFE (in a trade), met level = level, Poke Ball, destination game id, English, OT gender from Crystal caught gender else 0, no markings/ribbons/contest; Mew gets the modern fateful flag so it obeys. Gen 2 target from another gen: caught data 0 (Time Capsule). Same-gen: kept | +| ribbons / contest / markings / met data | kept Gen 3 -> 3, lost otherwise | +| eggs | refused | +| hp / status | full / clear | +| mod `extra` | kept same-gen, lost cross-gen | +| evolution | not run here | + +Every top-level field of the source record is classified as carried, +changed, derived or lost (`report.accounting`); every changed field has a +`kind='change'` row and every lost field a `kind='loss'` row in `changes`. +Unknown fields default to lost. + +`TradeConvert.canonical(rec)` is a sorted-key JSON-like serialization +(integers as `%.0f`); `TradeConvert.digest(rec)` is two FNV-1a 32 words over it. + +## Rentals v1 + +Per ruleset one rental per ordinary type (g3u-gen1: 15, g3u-gen2/3: 17), +level 50, IVs 20, EVs 0, personality 150 (Hardy, not shiny). Species and +moves were chosen so they are learnable at level 50 in every local cache of +that ruleset's generation and above (all 11 for g3u-gen1). `Rentals.build` +validates each against the providing game's data and excludes and reports +any that fail. + +## Trades: transaction + +- Each offer carries the sender's source record (its game's own link + encoding), the destination game and the converted preview. The receiver + re-converts the source record itself and refuses any mismatch. +- Agreed digest = digest over both offer revisions, both source records, + both converted records and the policy version. It is the readiness digest + and the `trade_confirm` digest; the relay refuses any confirm with another + digest, so changing either offer invalidates both approvals. +- Before confirming, each client rechecks its outgoing record at its slot and + writes a journal entry (`_xtrade.lua`, atomic) holding the outgoing + and incoming records. On the relay's `trade_commit` it applies the swap in + place, runs the destination game's own trade evolution, saves atomically + and drops the journal. `trade_abort` drops the journal; both originals stay. +- Recovery: at boot, committed entries apply without the network; confirming + entries ask `GET /trade/outcome` and apply, drop or wait. Applying is keyed + by room, round and digest and guarded by an applied marker (Gen 1/2) and the + record identity check (all gens), so a commit applies exactly once. No copy + of a traded-away Pokemon is kept for the sender. + +## What the relay verifies + +Room membership, seat, revision freshness, capability shapes, team size +bounds, message size caps, that only the lower-generation seat sends the +battle table, digest equality for trades, the commit barrier and the outcome +ledger. It holds no game data: a matching digest proves both players agreed +on the same exchange, not that a Pokemon is legally owned or legal. Battle +tables and parties are bounds-checked by the receiving client. + +## Deployment order + +Relay first (additive, relay protocol stays 3; older clients keep their own +rooms). Then clients. A new Gen 1/2 client on an old relay is refused at join +and shows an update message; a new Gen 3 client on an old relay detects the +missing generation field and shows the same message. diff --git a/src/battle/g3u/EffectMap1.lua b/src/battle/g3u/EffectMap1.lua new file mode 100644 index 00000000..1fd1d705 --- /dev/null +++ b/src/battle/g3u/EffectMap1.lua @@ -0,0 +1,147 @@ +local E = require("src.core.game3.battle.effect_ids") + +local M = {} + +M.GEN = 1 + +local function e(effect, chance) return { effect = effect, chance = chance or 0 } end + +-- pokered/constants/move_effect_constants.asm:7 +M.EFFECTS = { + NO_ADDITIONAL_EFFECT = e(E.HIT), + -- pokered/engine/battle/effects.asm:101 + POISON_SIDE_EFFECT1 = e(E.POISON_HIT, 20), + -- pokered/engine/battle/effects.asm:104 + POISON_SIDE_EFFECT2 = e(E.POISON_HIT, 40), + DRAIN_HP_EFFECT = e(E.ABSORB), + -- pokered/engine/battle/effects.asm:215 + BURN_SIDE_EFFECT1 = e(E.BURN_HIT, 10), + FREEZE_SIDE_EFFECT1 = e(E.FREEZE_HIT, 10), + PARALYZE_SIDE_EFFECT1 = e(E.PARALYZE_HIT, 10), + -- pokered/engine/battle/effects.asm:218 + BURN_SIDE_EFFECT2 = e(E.BURN_HIT, 30), + FREEZE_SIDE_EFFECT2 = e(E.FREEZE_HIT, 30), + PARALYZE_SIDE_EFFECT2 = e(E.PARALYZE_HIT, 30), + -- pokered/engine/battle/effects.asm:984 + FLINCH_SIDE_EFFECT1 = e(E.FLINCH_HIT, 10), + -- pokered/engine/battle/effects.asm:986 + FLINCH_SIDE_EFFECT2 = e(E.FLINCH_HIT, 30), + -- pokered/engine/battle/effects.asm:1116 + CONFUSION_SIDE_EFFECT = e(E.CONFUSE_HIT, 10), + -- pokered/engine/battle/effects.asm:562 + ATTACK_DOWN_SIDE_EFFECT = e(E.ATTACK_DOWN_HIT, 33), + DEFENSE_DOWN_SIDE_EFFECT = e(E.DEFENSE_DOWN_HIT, 33), + SPEED_DOWN_SIDE_EFFECT = e(E.SPEED_DOWN_HIT, 33), + SPECIAL_DOWN_SIDE_EFFECT = e(E.SPECIAL_DEFENSE_DOWN_HIT, 33), + -- pokered/engine/battle/effects.asm:967 + TWINEEDLE_EFFECT = e(E.TWINEEDLE, 20), + -- pokered/constants/move_effect_constants.asm:14 + EXPLODE_EFFECT = e(E.EXPLOSION), + DREAM_EATER_EFFECT = e(E.DREAM_EATER), + MIRROR_MOVE_EFFECT = e(E.MIRROR_MOVE), + ATTACK_UP1_EFFECT = e(E.ATTACK_UP), + DEFENSE_UP1_EFFECT = e(E.DEFENSE_UP), + SPECIAL_UP1_EFFECT = e(E.SPECIAL_ATTACK_UP), + EVASION_UP1_EFFECT = e(E.EVASION_UP), + PAY_DAY_EFFECT = e(E.PAY_DAY), + SWIFT_EFFECT = e(E.ALWAYS_HIT), + ATTACK_DOWN1_EFFECT = e(E.ATTACK_DOWN), + DEFENSE_DOWN1_EFFECT = e(E.DEFENSE_DOWN), + SPEED_DOWN1_EFFECT = e(E.SPEED_DOWN), + ACCURACY_DOWN1_EFFECT = e(E.ACCURACY_DOWN), + EVASION_DOWN1_EFFECT = e(E.EVASION_DOWN), + -- pokered/constants/move_effect_constants.asm:31 + CONVERSION_EFFECT = e(E.CONVERSION), + HAZE_EFFECT = e(E.HAZE), + BIDE_EFFECT = e(E.BIDE), + THRASH_PETAL_DANCE_EFFECT = e(E.RAMPAGE), + SWITCH_AND_TELEPORT_EFFECT = e(E.ROAR), + TWO_TO_FIVE_ATTACKS_EFFECT = e(E.MULTI_HIT), + SLEEP_EFFECT = e(E.SLEEP), + OHKO_EFFECT = e(E.OHKO), + CHARGE_EFFECT = e(E.RAZOR_WIND), + SUPER_FANG_EFFECT = e(E.SUPER_FANG), + SPECIAL_DAMAGE_EFFECT = e(E.LEVEL_DAMAGE), + TRAPPING_EFFECT = e(E.TRAP), + FLY_EFFECT = e(E.SEMI_INVULNERABLE), + ATTACK_TWICE_EFFECT = e(E.DOUBLE_HIT), + JUMP_KICK_EFFECT = e(E.RECOIL_IF_MISS), + MIST_EFFECT = e(E.MIST), + FOCUS_ENERGY_EFFECT = e(E.FOCUS_ENERGY), + RECOIL_EFFECT = e(E.RECOIL), + CONFUSION_EFFECT = e(E.CONFUSE), + -- pokered/constants/move_effect_constants.asm:57 + ATTACK_UP2_EFFECT = e(E.ATTACK_UP_2), + DEFENSE_UP2_EFFECT = e(E.DEFENSE_UP_2), + SPEED_UP2_EFFECT = e(E.SPEED_UP_2), + SPECIAL_UP2_EFFECT = e(E.SPECIAL_DEFENSE_UP_2), + HEAL_EFFECT = e(E.RESTORE_HP), + TRANSFORM_EFFECT = e(E.TRANSFORM), + ATTACK_DOWN2_EFFECT = e(E.ATTACK_DOWN_2), + DEFENSE_DOWN2_EFFECT = e(E.DEFENSE_DOWN_2), + SPEED_DOWN2_EFFECT = e(E.SPEED_DOWN_2), + SPECIAL_DOWN2_EFFECT = e(E.SPECIAL_DEFENSE_DOWN_2), + LIGHT_SCREEN_EFFECT = e(E.LIGHT_SCREEN), + REFLECT_EFFECT = e(E.REFLECT), + POISON_EFFECT = e(E.POISON), + PARALYZE_EFFECT = e(E.PARALYZE), + -- pokered/constants/move_effect_constants.asm:86 + SUBSTITUTE_EFFECT = e(E.SUBSTITUTE), + HYPER_BEAM_EFFECT = e(E.RECHARGE), + RAGE_EFFECT = e(E.RAGE), + MIMIC_EFFECT = e(E.MIMIC), + METRONOME_EFFECT = e(E.METRONOME), + LEECH_SEED_EFFECT = e(E.LEECH_SEED), + SPLASH_EFFECT = e(E.SPLASH), + DISABLE_EFFECT = e(E.DISABLE), +} + +-- pokered/constants/move_effect_constants.asm:8 +M.UNSUPPORTED = { + EFFECT_01 = "unused", + EFFECT_1E = "unused", + SPEED_UP1_EFFECT = "no_engine_handler", + ACCURACY_UP1_EFFECT = "no_engine_handler", + SPECIAL_DOWN1_EFFECT = "no_engine_handler", + ACCURACY_UP2_EFFECT = "no_engine_handler", + EVASION_UP2_EFFECT = "no_engine_handler", + ACCURACY_DOWN2_EFFECT = "no_engine_handler", + EVASION_DOWN2_EFFECT = "no_engine_handler", +} + +M.MOVES = { + -- pokered/data/battle/critical_hit_moves.asm:1 + [2] = { effect = E.HIGH_CRITICAL }, + [75] = { effect = E.HIGH_CRITICAL }, + [152] = { effect = E.HIGH_CRITICAL }, + [163] = { effect = E.HIGH_CRITICAL }, + -- pokered/engine/battle/core.asm:4566 + [68] = { effect = E.COUNTER }, + -- pokered/engine/battle/core.asm:4647 + [69] = { effect = E.LEVEL_DAMAGE }, + [101] = { effect = E.LEVEL_DAMAGE }, + [49] = { effect = E.SONICBOOM }, + [82] = { effect = E.DRAGON_RAGE }, + [149] = { effect = E.PSYWAVE }, + -- pokered/engine/battle/effects.asm:1036 + [13] = { effect = E.RAZOR_WIND }, + [76] = { effect = E.SOLAR_BEAM }, + [130] = { effect = E.SKULL_BASH }, + [143] = { effect = E.SKY_ATTACK }, + [91] = { effect = E.SEMI_INVULNERABLE }, + -- pokered/engine/battle/effects.asm:836 + [100] = { effect = E.TELEPORT }, + -- pokered/engine/battle/effects.asm:135 + [92] = { effect = E.TOXIC }, + -- pokered/engine/battle/move_effects/heal.asm:22 + [156] = { effect = E.REST }, +} + +-- pokered/engine/battle/core.asm:371 +M.PRIORITY = { + [98] = 1, + -- pokered/engine/battle/core.asm:382 + [68] = -1, +} + +return M diff --git a/src/battle/g3u/EffectMap2.lua b/src/battle/g3u/EffectMap2.lua new file mode 100644 index 00000000..530a88be --- /dev/null +++ b/src/battle/g3u/EffectMap2.lua @@ -0,0 +1,214 @@ +local E = require("src.core.game3.battle.effect_ids") + +local M = {} + +M.GEN = 2 + +local function e(effect) return { effect = effect } end + +-- pokecrystal/constants/move_effect_constants.asm:3 +M.EFFECTS = { + EFFECT_NORMAL_HIT = e(E.HIT), + EFFECT_SLEEP = e(E.SLEEP), + EFFECT_POISON_HIT = e(E.POISON_HIT), + EFFECT_LEECH_HIT = e(E.ABSORB), + EFFECT_BURN_HIT = e(E.BURN_HIT), + EFFECT_FREEZE_HIT = e(E.FREEZE_HIT), + EFFECT_PARALYZE_HIT = e(E.PARALYZE_HIT), + EFFECT_SELFDESTRUCT = e(E.EXPLOSION), + EFFECT_DREAM_EATER = e(E.DREAM_EATER), + EFFECT_MIRROR_MOVE = e(E.MIRROR_MOVE), + EFFECT_ATTACK_UP = e(E.ATTACK_UP), + EFFECT_DEFENSE_UP = e(E.DEFENSE_UP), + EFFECT_SP_ATK_UP = e(E.SPECIAL_ATTACK_UP), + EFFECT_EVASION_UP = e(E.EVASION_UP), + EFFECT_ALWAYS_HIT = e(E.ALWAYS_HIT), + EFFECT_ATTACK_DOWN = e(E.ATTACK_DOWN), + EFFECT_DEFENSE_DOWN = e(E.DEFENSE_DOWN), + EFFECT_SPEED_DOWN = e(E.SPEED_DOWN), + EFFECT_ACCURACY_DOWN = e(E.ACCURACY_DOWN), + EFFECT_EVASION_DOWN = e(E.EVASION_DOWN), + -- pokecrystal/constants/move_effect_constants.asm:28 + EFFECT_RESET_STATS = e(E.HAZE), + EFFECT_BIDE = e(E.BIDE), + EFFECT_RAMPAGE = e(E.RAMPAGE), + EFFECT_FORCE_SWITCH = e(E.ROAR), + EFFECT_MULTI_HIT = e(E.MULTI_HIT), + EFFECT_CONVERSION = e(E.CONVERSION), + EFFECT_FLINCH_HIT = e(E.FLINCH_HIT), + EFFECT_HEAL = e(E.RESTORE_HP), + EFFECT_TOXIC = e(E.TOXIC), + EFFECT_PAY_DAY = e(E.PAY_DAY), + EFFECT_LIGHT_SCREEN = e(E.LIGHT_SCREEN), + EFFECT_TRI_ATTACK = e(E.TRI_ATTACK), + EFFECT_OHKO = e(E.OHKO), + EFFECT_RAZOR_WIND = e(E.RAZOR_WIND), + EFFECT_SUPER_FANG = e(E.SUPER_FANG), + EFFECT_STATIC_DAMAGE = e(E.DRAGON_RAGE), + EFFECT_TRAP_TARGET = e(E.TRAP), + EFFECT_DOUBLE_HIT = e(E.DOUBLE_HIT), + EFFECT_JUMP_KICK = e(E.RECOIL_IF_MISS), + EFFECT_MIST = e(E.MIST), + EFFECT_FOCUS_ENERGY = e(E.FOCUS_ENERGY), + EFFECT_RECOIL_HIT = e(E.RECOIL), + EFFECT_CONFUSE = e(E.CONFUSE), + -- pokecrystal/constants/move_effect_constants.asm:53 + EFFECT_ATTACK_UP_2 = e(E.ATTACK_UP_2), + EFFECT_DEFENSE_UP_2 = e(E.DEFENSE_UP_2), + EFFECT_SPEED_UP_2 = e(E.SPEED_UP_2), + EFFECT_SP_ATK_UP_2 = e(E.SPECIAL_ATTACK_UP_2), + EFFECT_SP_DEF_UP_2 = e(E.SPECIAL_DEFENSE_UP_2), + EFFECT_TRANSFORM = e(E.TRANSFORM), + EFFECT_ATTACK_DOWN_2 = e(E.ATTACK_DOWN_2), + EFFECT_DEFENSE_DOWN_2 = e(E.DEFENSE_DOWN_2), + EFFECT_SPEED_DOWN_2 = e(E.SPEED_DOWN_2), + EFFECT_SP_DEF_DOWN_2 = e(E.SPECIAL_DEFENSE_DOWN_2), + EFFECT_REFLECT = e(E.REFLECT), + EFFECT_POISON = e(E.POISON), + EFFECT_PARALYZE = e(E.PARALYZE), + -- pokecrystal/constants/move_effect_constants.asm:71 + EFFECT_ATTACK_DOWN_HIT = e(E.ATTACK_DOWN_HIT), + EFFECT_DEFENSE_DOWN_HIT = e(E.DEFENSE_DOWN_HIT), + EFFECT_SPEED_DOWN_HIT = e(E.SPEED_DOWN_HIT), + EFFECT_SP_ATK_DOWN_HIT = e(E.SPECIAL_ATTACK_DOWN_HIT), + EFFECT_SP_DEF_DOWN_HIT = e(E.SPECIAL_DEFENSE_DOWN_HIT), + EFFECT_ACCURACY_DOWN_HIT = e(E.ACCURACY_DOWN_HIT), + EFFECT_SKY_ATTACK = e(E.SKY_ATTACK), + EFFECT_CONFUSE_HIT = e(E.CONFUSE_HIT), + EFFECT_POISON_MULTI_HIT = e(E.TWINEEDLE), + EFFECT_SUBSTITUTE = e(E.SUBSTITUTE), + EFFECT_HYPER_BEAM = e(E.RECHARGE), + EFFECT_RAGE = e(E.RAGE), + EFFECT_MIMIC = e(E.MIMIC), + EFFECT_METRONOME = e(E.METRONOME), + EFFECT_LEECH_SEED = e(E.LEECH_SEED), + EFFECT_SPLASH = e(E.SPLASH), + EFFECT_DISABLE = e(E.DISABLE), + -- pokecrystal/constants/move_effect_constants.asm:90 + EFFECT_LEVEL_DAMAGE = e(E.LEVEL_DAMAGE), + EFFECT_PSYWAVE = e(E.PSYWAVE), + EFFECT_COUNTER = e(E.COUNTER), + EFFECT_ENCORE = e(E.ENCORE), + EFFECT_PAIN_SPLIT = e(E.PAIN_SPLIT), + EFFECT_SNORE = e(E.SNORE), + EFFECT_CONVERSION2 = e(E.CONVERSION_2), + EFFECT_LOCK_ON = e(E.LOCK_ON), + EFFECT_SKETCH = e(E.SKETCH), + EFFECT_SLEEP_TALK = e(E.SLEEP_TALK), + EFFECT_DESTINY_BOND = e(E.DESTINY_BOND), + EFFECT_REVERSAL = e(E.FLAIL), + EFFECT_SPITE = e(E.SPITE), + EFFECT_FALSE_SWIPE = e(E.FALSE_SWIPE), + EFFECT_HEAL_BELL = e(E.HEAL_BELL), + EFFECT_PRIORITY_HIT = e(E.QUICK_ATTACK), + EFFECT_TRIPLE_KICK = e(E.TRIPLE_KICK), + EFFECT_THIEF = e(E.THIEF), + EFFECT_MEAN_LOOK = e(E.MEAN_LOOK), + EFFECT_NIGHTMARE = e(E.NIGHTMARE), + EFFECT_FLAME_WHEEL = e(E.THAW_HIT), + EFFECT_CURSE = e(E.CURSE), + -- pokecrystal/constants/move_effect_constants.asm:114 + EFFECT_PROTECT = e(E.PROTECT), + EFFECT_SPIKES = e(E.SPIKES), + EFFECT_FORESIGHT = e(E.FORESIGHT), + EFFECT_PERISH_SONG = e(E.PERISH_SONG), + EFFECT_SANDSTORM = e(E.SANDSTORM), + EFFECT_ENDURE = e(E.ENDURE), + EFFECT_ROLLOUT = e(E.ROLLOUT), + EFFECT_SWAGGER = e(E.SWAGGER), + EFFECT_FURY_CUTTER = e(E.FURY_CUTTER), + EFFECT_ATTRACT = e(E.ATTRACT), + EFFECT_RETURN = e(E.RETURN), + EFFECT_PRESENT = e(E.PRESENT), + EFFECT_FRUSTRATION = e(E.FRUSTRATION), + EFFECT_SAFEGUARD = e(E.SAFEGUARD), + EFFECT_SACRED_FIRE = e(E.THAW_HIT), + EFFECT_MAGNITUDE = e(E.MAGNITUDE), + EFFECT_BATON_PASS = e(E.BATON_PASS), + EFFECT_PURSUIT = e(E.PURSUIT), + EFFECT_RAPID_SPIN = e(E.RAPID_SPIN), + -- pokecrystal/constants/move_effect_constants.asm:135 + EFFECT_MORNING_SUN = e(E.MORNING_SUN), + EFFECT_SYNTHESIS = e(E.SYNTHESIS), + EFFECT_MOONLIGHT = e(E.MOONLIGHT), + EFFECT_HIDDEN_POWER = e(E.HIDDEN_POWER), + EFFECT_RAIN_DANCE = e(E.RAIN_DANCE), + EFFECT_SUNNY_DAY = e(E.SUNNY_DAY), + EFFECT_DEFENSE_UP_HIT = e(E.DEFENSE_UP_HIT), + EFFECT_ATTACK_UP_HIT = e(E.ATTACK_UP_HIT), + EFFECT_ALL_UP_HIT = e(E.ALL_STATS_UP_HIT), + EFFECT_FAKE_OUT = e(E.FAKE_OUT), + EFFECT_BELLY_DRUM = e(E.BELLY_DRUM), + EFFECT_PSYCH_UP = e(E.PSYCH_UP), + EFFECT_MIRROR_COAT = e(E.MIRROR_COAT), + EFFECT_SKULL_BASH = e(E.SKULL_BASH), + EFFECT_TWISTER = e(E.TWISTER), + EFFECT_EARTHQUAKE = e(E.EARTHQUAKE), + EFFECT_FUTURE_SIGHT = e(E.FUTURE_SIGHT), + EFFECT_GUST = e(E.GUST), + EFFECT_STOMP = e(E.FLINCH_MINIMIZE_HIT), + EFFECT_SOLARBEAM = e(E.SOLAR_BEAM), + EFFECT_THUNDER = e(E.THUNDER), + EFFECT_TELEPORT = e(E.TELEPORT), + EFFECT_BEAT_UP = e(E.BEAT_UP), + EFFECT_FLY = e(E.SEMI_INVULNERABLE), + EFFECT_DEFENSE_CURL = e(E.DEFENSE_CURL), +} + +-- pokecrystal/constants/move_effect_constants.asm:15 +M.UNSUPPORTED = { + EFFECT_SPEED_UP = "no_engine_handler", + EFFECT_SP_DEF_UP = "no_engine_handler", + EFFECT_ACCURACY_UP = "no_engine_handler", + EFFECT_SP_ATK_DOWN = "no_engine_handler", + EFFECT_SP_DEF_DOWN = "no_engine_handler", + EFFECT_UNUSED_25 = "unused", + EFFECT_UNUSED_2B = "unused", + EFFECT_ACCURACY_UP_2 = "no_engine_handler", + EFFECT_EVASION_UP_2 = "no_engine_handler", + EFFECT_SP_ATK_DOWN_2 = "no_engine_handler", + EFFECT_ACCURACY_DOWN_2 = "no_engine_handler", + EFFECT_EVASION_DOWN_2 = "no_engine_handler", + EFFECT_EVASION_DOWN_HIT = "no_engine_handler", + EFFECT_UNUSED_4E = "unused", + EFFECT_DEFROST_OPPONENT = "no_engine_handler", + EFFECT_UNUSED_6E = "unused", + EFFECT_UNUSED_82 = "unused", + EFFECT_UNUSED_83 = "unused", +} + +M.MOVES = { + -- pokecrystal/data/moves/critical_hit_moves.asm:1 + [2] = { effect = E.HIGH_CRITICAL }, + [75] = { effect = E.HIGH_CRITICAL }, + [152] = { effect = E.HIGH_CRITICAL }, + [163] = { effect = E.HIGH_CRITICAL }, + [177] = { effect = E.HIGH_CRITICAL }, + [238] = { effect = E.HIGH_CRITICAL }, + -- pokecrystal/data/moves/moves.asm:65 + [49] = { effect = E.SONICBOOM }, + -- pokecrystal/engine/battle/effect_commands.asm:6055 + [156] = { effect = E.REST }, + -- pokecrystal/engine/battle/effect_commands.asm:4251 + [107] = { effect = E.MINIMIZE }, +} + +-- pokecrystal/data/moves/effects_priorities.asm:1 +M.EFFECT_PRIORITY = { + EFFECT_PROTECT = 3, + EFFECT_ENDURE = 3, + EFFECT_PRIORITY_HIT = 2, + EFFECT_FORCE_SWITCH = 0, + EFFECT_COUNTER = 0, + EFFECT_MIRROR_COAT = 0, +} + +-- pokecrystal/engine/battle/core.asm:835 +M.MOVE_PRIORITY = { + [233] = 0, +} + +-- pokecrystal/constants/battle_constants.asm:18 +M.BASE_PRIORITY = 1 + +return M diff --git a/src/battle/g3u/Events.lua b/src/battle/g3u/Events.lua new file mode 100644 index 00000000..f43f8b12 --- /dev/null +++ b/src/battle/g3u/Events.lua @@ -0,0 +1,82 @@ +local Events = {} + +Events.KINDS = { + "msg", "move", "hp", "status", "stage", "faint", "withdraw", "sendout", "weather", + "anim", "need_replacement", "end", +} + +local SEAT = { player = 0, enemy = 1 } + +function Events.seatOf(side) + if side == nil then return nil end + if type(side) == "number" then return side % 2 end + return SEAT[side] +end + +local seatOf = Events.seatOf + +local function isBattler(v) + return type(v) == "table" and (v.side == "player" or v.side == "enemy") and v.mon ~= nil +end + +local function value(v, depth) + local t = type(v) + if t == "string" then + local kind, n = v:match("^{(%a+):(%-?%d+)}$") + if kind then return { [kind] = tonumber(n) } end + return v + end + if t == "number" or t == "boolean" then return v end + if t == "table" then + if isBattler(v) then return { side = seatOf(v.side), index = v.partyIndex } end + if (depth or 0) > 2 then return nil end + local out = {} + for k, x in pairs(v) do + if type(k) == "string" or type(k) == "number" then out[k] = value(x, (depth or 0) + 1) end + end + return out + end + return nil +end + +function Events.fill(fill) + local out = {} + for k, v in pairs(fill or {}) do + if type(k) == "string" then out[k] = value(v, 0) end + end + return out +end + +function Events.normalize(raw, out) + out = out or {} + for _, ev in ipairs(raw or {}) do + local k = ev.kind + if k == "msg" then + out[#out + 1] = { kind = "msg", id = ev.id, fill = Events.fill(ev.fill) } + elseif k == "move" then + out[#out + 1] = { kind = "move", moveId = tonumber(ev.moveId) or ev.moveId, + user = seatOf(ev.attacker), target = seatOf(ev.target), turn = ev.turn } + elseif k == "hp" or k == "hit" then + out[#out + 1] = { kind = "hp", side = seatOf(ev.side), from = ev.from, to = ev.to, max = ev.maxHp, + hit = k == "hit" or nil } + elseif k == "status_apply" then + out[#out + 1] = { kind = "status", side = seatOf(ev.side), status = ev.status } + elseif k == "status_clear" then + out[#out + 1] = { kind = "status", side = seatOf(ev.side), status = "NONE" } + elseif k == "stage" then + out[#out + 1] = { kind = "stage", side = seatOf(ev.side), stat = ev.stat, delta = ev.delta } + elseif k == "faint" then + out[#out + 1] = { kind = "faint", side = seatOf(ev.side) } + elseif k == "switch" then + local seat = seatOf(ev.side) + out[#out + 1] = { kind = "withdraw", side = seat, index = ev.from, reason = ev.reason } + out[#out + 1] = { kind = "sendout", side = seat, index = ev.to, reason = ev.reason } + elseif k == "anim" then + out[#out + 1] = { kind = "anim", anim = ev.anim, name = ev.name, user = seatOf(ev.attacker), + target = seatOf(ev.target), arg = ev.arg } + end + end + return out +end + +return Events diff --git a/src/battle/g3u/Hash.lua b/src/battle/g3u/Hash.lua new file mode 100644 index 00000000..89377253 --- /dev/null +++ b/src/battle/g3u/Hash.lua @@ -0,0 +1,156 @@ +local bit = require("bit") + +local Hash = {} + +Hash.PARTS = { "actives", "volatile", "bench", "field", "rng" } + +local function fnv(text) + local h = 0x811C9DC5 + for i = 1, #text do + h = bit.bxor(h, text:byte(i)) % 4294967296 + h = ((bit.lshift(h, 24) % 4294967296) + h * 403) % 4294967296 + end + return string.format("%08x", h) +end + +Hash.fnv = fnv + +local function scalar(v) + local t = type(v) + if t == "number" then + if v == math.floor(v) then return string.format("%d", v) end + return string.format("%.6f", v) + end + if t == "boolean" then return v and "T" or "F" end + if t == "string" then return v end + if v == nil then return "-" end + return "t" +end + +local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" } + +local VOLATILE_KEYS = { + "confusion", "substitute", "toxicCounter", "focusEnergy", "perishSong", "seeded", "trapped", + "attracted", "disabled", "encore", "taunt", "bide", "rage", "endure", "protect", "destinyBond", + "transformed", "isFirstTurn", + "expCharged", "expCursed", "expDisableTurns", "expDisabledMove", "expEncoreMove", "expEncoreSlot", + "expEncoreTurns", "expFocusEnergy", "expFuryCutter", "expInfatuated", "expIngrain", "expLockedMove", + "expLockedSlot", "expMustRecharge", "expNightmare", "expPerishTurns", "expRampageTurns", + "expRechargeTurns", "expRolloutTimer", "expSeeded", "expTauntedTurns", "expTormented", + "expTransform", "expTrapTurns", "expTrapped", "expTruantCounter", "expUproarTurns", "expYawnTurns", + "expCastformForm", +} + +local function perspective(st) + if st.linkMaster == false then + return { 1, 0, 3, 2 }, { "enemy", "player" } + end + return { 0, 1, 2, 3 }, { "player", "enemy" } +end + +local function battlerOf(st, id) + local State = require("src.core.game3.battle.state") + if State.isAbsent(st, id) then return nil end + return State.battler(st, id) +end + +local function sortedScalars(t, skip, st) + if type(t) ~= "table" then return scalar(t) end + local flip = st and st.linkMaster == false + local keys = {} + for k, v in pairs(t) do + if (type(k) == "string" or type(k) == "number") and not (skip and skip[k]) + and type(v) ~= "table" and type(v) ~= "function" then + keys[#keys + 1] = tostring(k) + end + end + table.sort(keys) + local out = {} + for _, k in ipairs(keys) do + local v = t[k] + if v == nil then v = t[tonumber(k)] end + if flip and type(v) == "number" and k:sub(-2) == "Id" and v >= 0 and v <= 3 then + v = (v % 2 == 0) and (v + 1) or (v - 1) + end + out[#out + 1] = k .. "=" .. scalar(v) + end + return table.concat(out, ",") +end + +local function monPp(mon) + local pp = {} + for i = 1, 4 do pp[i] = scalar(mon and mon.pp and mon.pp[i]) end + return table.concat(pp, "/") +end + +local SIDE_SKIP = { id = true } + +function Hash.parts(st, draws) + local order, sides = perspective(st) + local actives, volatile = {}, {} + for _, id in ipairs(order) do + local b = battlerOf(st, id) + if not b then + actives[#actives + 1] = "-" + volatile[#volatile + 1] = "-" + else + local mon = b.mon or {} + local stages = {} + for i, key in ipairs(STAGES) do stages[i] = scalar(b.stages and b.stages[key] or 0) end + actives[#actives + 1] = table.concat({ + scalar(tonumber(b.species or mon.species)), scalar(tonumber(mon.hp)), scalar(tonumber(mon.maxHp)), + scalar(b.status or mon.status), scalar(mon.sleep or b.sleepTurns), table.concat(stages, "/"), + scalar(b.ability), scalar(tonumber(b.item) or 0), monPp(mon), + }, ":") + local vol = {} + for _, key in ipairs(VOLATILE_KEYS) do vol[#vol + 1] = scalar(b[key]) end + vol[#vol + 1] = sortedScalars(b.volatiles) + volatile[#volatile + 1] = table.concat(vol, ":") + end + end + local bench, field = {}, {} + for _, side in ipairs(sides) do + local party = (side == "player") and st.playerParty or st.foeParty + local active = {} + for _, id in ipairs((side == "player") and { 0, 2 } or { 1, 3 }) do + local b = battlerOf(st, id) + if b and b.partyIndex then active[b.partyIndex] = true end + end + local rows = {} + for i, mon in ipairs(party or {}) do + if active[i] then + rows[#rows + 1] = "*" + else + rows[#rows + 1] = table.concat({ + scalar(tonumber(mon.species or mon.speciesId)), scalar(tonumber(mon.hp)), scalar(mon.status), + scalar(tonumber(mon.item or mon.heldItem) or 0), monPp(mon), + }, ":") + end + end + bench[#bench + 1] = table.concat(rows, ";") + local sideState = (side == "player") and st.playerSide or st.enemySide + field[#field + 1] = sortedScalars(sideState, SIDE_SKIP, st) .. "|" .. sortedScalars(sideState and sideState.hazards, nil, st) + end + table.insert(field, 1, scalar(st.weather) .. ":" .. scalar(st.weatherTurns)) + local raw = { + actives = table.concat(actives, "#"), + volatile = table.concat(volatile, "#"), + bench = table.concat(bench, "#"), + field = table.concat(field, "#"), + } + return { + actives = fnv(raw.actives), + volatile = fnv(raw.volatile), + bench = fnv(raw.bench), + field = fnv(raw.field), + rng = string.format("%d", draws and draws.n or 0), + }, raw +end + +function Hash.value(parts) + local list = {} + for i, key in ipairs(Hash.PARTS) do list[i] = tostring(parts[key] or "") end + return fnv(table.concat(list, "|")) +end + +return Hash diff --git a/src/battle/g3u/Match.lua b/src/battle/g3u/Match.lua new file mode 100644 index 00000000..043f7905 --- /dev/null +++ b/src/battle/g3u/Match.lua @@ -0,0 +1,452 @@ +local Table = require("src.battle.g3u.Table") +local Scope = require("src.battle.g3u.Scope") +local Rng = require("src.battle.g3u.Rng") +local Hash = require("src.battle.g3u.Hash") +local Events = require("src.battle.g3u.Events") + +local Match = {} +Match.__index = Match + +local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" } +local GENDER = { [0] = "M", [1] = "F", [2] = "U" } +local MOVE_STRUGGLE = 165 +local SIDE = { [0] = "player", [1] = "enemy" } + +local function mods() return Scope.modules() end + +local function engineMon(rec) + local moves, pp, ups = {}, {}, {} + for i, m in ipairs(rec.moves or {}) do + moves[i] = m.id + pp[i] = m.pp + ups[i] = m.ppUps or 0 + end + local iv = rec.ivs or {} + return { + species = rec.species, level = rec.level, hp = rec.hp, maxHp = rec.maxHp, + attack = rec.atk, defense = rec.def, spAtk = rec.spAtk, spDef = rec.spDef, speed = rec.speed, + moves = moves, pp = pp, ppUps = ups, nickname = rec.nickname, + ivs = { hp = iv.hp or 0, atk = iv.atk or 0, def = iv.def or 0, spe = iv.spe or 0, spa = iv.spa or 0, + spd = iv.spd or 0 }, + evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, + personality = 0, ability = 0, abilityId = 0, item = 0, heldItem = 0, + gender = GENDER[rec.gender] or "U", friendship = rec.friendship or 0, + } +end + +function Match.new(opts) + local t = opts.table + local ok, why = Table.validate(t) + if not ok then error("g3u table rejected: " .. tostring(why), 2) end + local m = setmetatable({ + t = t, seed = math.floor(tonumber(opts.seed) or 0) % 4294967296, draws = { n = 0 }, + turn = 0, phase = "init", illegal = Table.illegalSet(t), hashes = {}, out = {}, + names = opts.names or {}, stageOwner = {}, stageSnap = {}, + }, Match) + m.parties = { [0] = {}, [1] = {} } + for seat = 0, 1 do + for i, rec in ipairs(opts.parties[seat]) do m.parties[seat][i] = engineMon(rec) end + end + m.stack = Scope.newStack() + m.co = coroutine.create(function() return m:_main() end) + return m +end + +function Match:_resume(arg) + self.legalCache = nil + self.out = {} + local co = self.co + Scope.run(self.t, function() + local ok, err = coroutine.resume(co, arg) + if not ok then error(debug.traceback(co, err), 0) end + end, self.stack) + local out = self.out + self.out = {} + return out +end + +function Match:start() + if self.phase ~= "init" then error("g3u match already started", 2) end + return self:_resume() +end + +function Match:_setup() + local M = mods() + local State = M["src.core.game3.battle.state"] + local Adapter = M["src.core.game3.battle.adapter"] + self.rng = Rng.make(self.seed, self.draws) + local st = State.new({ playerParty = self.parties[0], foeParty = self.parties[1], rng = self.rng }) + st.link = true + st.linkMaster = true + st.terrain = 8 + st.g3u = true + st.moveMax = self.t.moveMax + st.moveExcluded = self.illegal + st.playerName = self.names[0] + st.peerName = self.names[1] + self.st = st + self.ad = Adapter.new(st, function() end) + self.lastWeather = st.weather +end + +function Match:_emit(ev) + self.out[#self.out + 1] = ev +end + +function Match:_drain() + local ad, st = self.ad, self.st + local raw = ad._events + ad._events = {} + local first = #self.out + 1 + Events.normalize(raw, self.out) + local reported = { [0] = {}, [1] = {} } + for i = first, #self.out do + local ev = self.out[i] + if ev.kind == "stage" and ev.side ~= nil then + local r = reported[ev.side] + r[ev.stat] = (r[ev.stat] or 0) + (ev.delta or 0) + end + end + local State = mods()["src.core.game3.battle.state"] + for seat = 0, 1 do + local b = State.battler(st, seat) + if b and b.stages then + local snap = self.stageSnap[seat] + if self.stageOwner[seat] == b and snap then + for _, k in ipairs(STAGES) do + local want = (snap[k] or 0) + (reported[seat][k] or 0) + local cur = b.stages[k] or 0 + if cur ~= want then + self:_emit({ kind = "stage", side = seat, stat = k, delta = cur - want, sync = true }) + end + end + end + local copy = {} + for _, k in ipairs(STAGES) do copy[k] = b.stages[k] or 0 end + self.stageOwner[seat], self.stageSnap[seat] = b, copy + end + end + if st.weather ~= self.lastWeather then + self.lastWeather = st.weather + self:_emit({ kind = "weather", weather = st.weather or "NONE", turns = st.weatherTurns }) + end +end + +function Match:_hashNow() + return Hash.value((Hash.parts(self.st, self.draws))) +end + +function Match:hash(turn) + if turn == nil then turn = self.turn end + return self.hashes[turn] +end + +function Match:_ask(need) + self.phase = "replace" + self.need = need + for seat = 0, 1 do + if need[seat] then self:_emit({ kind = "need_replacement", side = seat, reason = need[seat].kind }) end + end + local picks = coroutine.yield("replace") + self.need = nil + self.phase = "running" + return picks +end + +function Match:_finish(winner, why) + local st = self.st + st.over = true + local result + if winner == "draw" then + result = { draw = true, why = why } + st.result = "draw" + else + result = { winner = winner, why = why } + st.result = (winner == 0) and "win" or "lose" + end + self.result = result + self.phase = "over" + self:_emit({ kind = "end", result = result }) +end + +local function anyFainted(st, State) + return State.isFainted(st.player) or State.isFainted(st.enemy) +end + +-- pokefirered/src/battle_util.c:1144 +function Match:_handleFaints() + local M = mods() + local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + while true do + if st.over then return true end + local pF, eF = State.isFainted(st.player), State.isFainted(st.enemy) + if not pF and not eF then + local r = Engine.checkEnd(st, ad) + if r == "win" then self:_finish(0, "faint") return true end + if r == "lose" then self:_finish(1, "faint") return true end + if r == "draw" then self:_finish("draw", "faint") return true end + return false + end + State.syncBattlerToParty(st.player, st.playerParty) + State.syncBattlerToParty(st.enemy, st.foeParty) + local pL, eL = Engine.hasLivingMons(st.playerParty), Engine.hasLivingMons(st.foeParty) + -- pokefirered/src/battle_script_commands.c:3413 + if not pL and not eL then self:_finish("draw", "faint") return true end + if not pL then self:_finish(1, "faint") return true end + if not eL then self:_finish(0, "faint") return true end + local need = {} + if pF then need[0] = { kind = "faint", candidates = Engine.replacementCandidates(st, 0) } end + if eF then need[1] = { kind = "faint", candidates = Engine.replacementCandidates(st, 1) } end + local picks = self:_ask(need) + for seat = 0, 1 do + if need[seat] then + Engine.performSwitch(st, ad, seat, picks[seat], { reason = "switch", nativeSwitchKind = "replace" }) + Engine.switchInEffects(st, ad, State.battler(st, seat), { spikes = true }) + self:_drain() + end + end + end +end + +-- pokefirered/src/battle_script_commands.c:8337 +function Match:_pursuitRow(pid, targetId) + local M = mods() + local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + local user, target = State.battler(st, pid), State.battler(st, targetId) + if not user or State.isFainted(user) or not target or State.isFainted(target) then return nil end + for _, row in ipairs(st.turnActions or {}) do + if row.battler == pid and row.kind == "move" and not row.done and not row.finished + and Engine.isPursuit(row.move) and not ad:hasStatus(user, "SLP") and not ad:hasStatus(user, "FRZ") + and (tonumber(user.expTruantCounter) or 0) == 0 then + return row + end + end + return nil +end + +function Match:_switchAction(id, slot) + local M = mods() + local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + local prow = self:_pursuitRow(1 - id, id) + if prow then + prow.done = true + st.interactiveChoices = true + Engine.resolveMove(State.battler(st, 1 - id), State.battler(st, id), prow.move, prow.slot, ad, st, {}, + { pursuitSwitch = true }) + st.interactiveChoices = nil + self:_drain() + end + if State.isFainted(State.battler(st, id)) then + if st.monToSwitchInto then st.monToSwitchInto[id] = nil end + return true + end + Engine.performSwitch(st, ad, id, slot, { reason = "switch", nativeSwitchKind = "switch" }) + Engine.switchInEffects(st, ad, State.battler(st, id), { spikes = true }) + self:_drain() + return anyFainted(st, State) +end + +function Match:_moveAction(act) + local M = mods() + local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + local u, tg = State.occupant(st, act.user), State.occupant(st, act.target) + if State.isFainted(u) or State.isFainted(tg) then return false end + act.done = true + st.interactiveChoices = true + local out = Engine.resolveMove(act.user, act.target, act.move, act.slot, ad, st, {}) + self:_drain() + while out and out.pendingChoice do + local req = out.pendingChoice + local seat = Events.seatOf(req.side) + local picks = self:_ask({ [seat] = { kind = "baton_pass", candidates = req.candidates } }) + st.interactiveChoices = true + out = Engine.resumeChoice(st, ad, picks[seat]) + self:_drain() + end + st.interactiveChoices = nil + return anyFainted(st, State) +end + +function Match:_engineAction(seat, act) + local State = mods()["src.core.game3.battle.state"] + local b = State.battler(self.st, seat) + local side = SIDE[seat] + if act.kind == "switch" then return { kind = "switch", slot = act.index, user = side } end + local mon = b.mon or {} + if b.expLockedMove or b.expMustRecharge then + -- pokefirered/src/battle_main.c:3125 + local slot = b.expLockedSlot + return { kind = "move", user = side, locked = true, slot = slot, + move = b.expLockedMove or b.lastMoveId or b.lastMove or (mon.moves and mon.moves[slot or 1]) } + end + if act.slot == 0 then return { kind = "move", move = MOVE_STRUGGLE, slot = nil, user = side } end + return { kind = "move", move = mon.moves[act.slot], slot = act.slot, user = side } +end + +function Match:_turn(acts) + local Engine = mods()["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + st.turn = st.turn + 1 + self.turn = st.turn + local f0, f1 = acts[0].kind == "forfeit", acts[1].kind == "forfeit" + if f0 or f1 then + -- pokeemerald/src/battle_main.c:5061 + self:_finish((f0 and f1) and "draw" or (f0 and 1 or 0), "forfeit") + return + end + local pAct, eAct = self:_engineAction(0, acts[0]), self:_engineAction(1, acts[1]) + st.monToSwitchInto = {} + if pAct.kind == "switch" then st.monToSwitchInto[0] = pAct.slot end + if eAct.kind == "switch" then st.monToSwitchInto[1] = eAct.slot end + local actions, meta = Engine.planTurnFromActions(st, ad, pAct, eAct) + self:_drain() + local midFaint = false + if meta and meta.kind == "switch" then midFaint = self:_switchAction(0, meta.slot) end + if not midFaint then + for _, act in ipairs(actions or {}) do + if st.over then break end + if Engine.actionRunnable(st, act) then + if act.kind == "switch" then + midFaint = self:_switchAction(act.battler, act.slot) + elseif act.kind == "move" then + midFaint = self:_moveAction(act) + end + if midFaint then break end + end + end + end + if midFaint and self:_handleFaints() then return end + -- pokefirered/src/battle_main.c:2953 + Engine.collectResidualEvents(st, ad) + self:_drain() + self:_handleFaints() +end + +function Match:_main() + local M = mods() + local Engine = M["src.core.game3.battle.engine"] + local State = M["src.core.game3.battle.state"] + self:_setup() + for seat = 0, 1 do + local b = State.battler(self.st, seat) + self:_emit({ kind = "sendout", side = seat, index = b.partyIndex, reason = "start" }) + end + -- pokefirered/src/battle_main.c:2856 + Engine.battleStartEffects(self.st, self.ad) + self:_drain() + while true do + self.hashes[self.turn] = self:_hashNow() + if self.phase == "over" then return end + self.phase = "choose" + local acts = coroutine.yield("choose") + self.phase = "running" + self:_turn(acts) + end +end + +local function canSwitch(b) + -- pokefirered/src/battle_main.c:3196 + return not (b.expTrapped or b.escapePrevention or (b.expTrapTurns or 0) > 0 or b.expIngrain) +end + +function Match:_legal(seat) + local M = mods() + local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"] + local st, ad = self.st, self.ad + local b = State.battler(st, seat) + local out = {} + if b.expLockedMove or b.expMustRecharge then + out[1] = { kind = "move", slot = b.expLockedSlot or 0, locked = true } + out[2] = { kind = "forfeit" } + return out + end + local bad = Engine.moveLimitations(b, ad) + local moves = b.mon and b.mon.moves or {} + for i = 1, 4 do + local mv = tonumber(moves[i]) + if mv and not bad[i] and not self.illegal[mv] then out[#out + 1] = { kind = "move", slot = i } end + end + if #out == 0 then out[1] = { kind = "move", slot = 0 } end + if canSwitch(b) then + for _, i in ipairs(Engine.switchCandidates(st, seat)) do out[#out + 1] = { kind = "switch", index = i } end + end + out[#out + 1] = { kind = "forfeit" } + return out +end + +function Match:legalActions(seat) + if self.phase == "replace" then + local need = self.need and self.need[seat] + local out = {} + for _, i in ipairs(need and need.candidates or {}) do out[#out + 1] = { kind = "switch", index = i } end + return out + end + if self.phase ~= "choose" then return {} end + self.legalCache = self.legalCache or {} + local cached = self.legalCache[seat] + if not cached then + cached = Scope.run(self.t, function() return self:_legal(seat) end, self.stack) + self.legalCache[seat] = cached + end + local out = {} + for i, a in ipairs(cached) do out[i] = { kind = a.kind, slot = a.slot, index = a.index, locked = a.locked } end + return out +end + +local function same(a, b) + if a.kind ~= b.kind then return false end + if a.kind == "move" then return a.slot == b.slot end + if a.kind == "switch" then return a.index == b.index end + return true +end + +function Match:isLegal(seat, act) + if type(act) ~= "table" then return false end + for _, l in ipairs(self:legalActions(seat)) do + if same(l, act) then return true end + end + return false +end + +function Match:submit(acts) + if self.phase ~= "choose" then error("g3u match is not choosing actions", 2) end + for seat = 0, 1 do + if not self:isLegal(seat, acts[seat]) then error("g3u illegal action from seat " .. seat, 2) end + end + return self:_resume({ [0] = acts[0], [1] = acts[1] }) +end + +function Match:replace(picks) + if self.phase ~= "replace" then error("g3u match is not waiting for a replacement", 2) end + local clean = {} + for seat = 0, 1 do + if self.need[seat] then + local i = picks and picks[seat] + if not self:isLegal(seat, { kind = "switch", index = i }) then + error("g3u illegal replacement from seat " .. seat, 2) + end + clean[seat] = i + end + end + return self:_resume(clean) +end + +function Match:needs(seat) + return self.phase == "replace" and self.need and self.need[seat] ~= nil +end + +function Match:active(seat) + local State = mods()["src.core.game3.battle.state"] + local b = self.st and State.battler(self.st, seat) + return b and b.partyIndex or nil +end + +function Match:party(seat) + return self.st and ((seat == 0) and self.st.playerParty or self.st.foeParty) or nil +end + +return Match diff --git a/src/battle/g3u/Rng.lua b/src/battle/g3u/Rng.lua new file mode 100644 index 00000000..c6df3d3c --- /dev/null +++ b/src/battle/g3u/Rng.lua @@ -0,0 +1,29 @@ +local Rng32 = require("src.core.game3.rng") + +local Rng = {} + +-- pokefirered/src/random.c:15 ISO_RANDOMIZE1 +function Rng.make(seed, counter) + local value = math.floor(tonumber(seed) or 0) % 4294967296 + local function word() + if counter then counter.n = counter.n + 1 end + value = (Rng32.mulU32(value, 1103515245) + 24691) % 4294967296 + return math.floor(value / 65536) % 65536 + end + return function(lo, hi) + if lo == nil and hi == nil then return word() / 65536 end + if hi == nil then + lo = math.floor(tonumber(lo) or 1) + if lo <= 0 then return 0 end + return 1 + (word() % lo) + end + lo = math.floor(tonumber(lo) or 0) + hi = math.floor(tonumber(hi) or lo) + if hi < lo then lo, hi = hi, lo end + local span = hi - lo + 1 + if span <= 0 then return lo end + return lo + (word() % span) + end +end + +return Rng diff --git a/src/battle/g3u/Scope.lua b/src/battle/g3u/Scope.lua new file mode 100644 index 00000000..cb717e7b --- /dev/null +++ b/src/battle/g3u/Scope.lua @@ -0,0 +1,230 @@ +local Table = require("src.battle.g3u.Table") + +local Scope = {} + +Scope.trips = {} + +local PRELOAD = { + "src.core.game3.rng", "src.core.game3.rom_text", "src.import.CacheFs", "src.core.game3.dataset", + "src.core.game3.pokemon", "src.mods.Runtime", "src.core.game3.battle.link_guard", + "src.core.game3.battle.profile", "src.core.game3.battle.battle_text", "src.core.game3.battle.types", + "src.core.game3.battle.moves", "src.core.game3.battle.damage", "src.core.game3.battle.state", + "src.core.game3.battle.adapter", "src.core.game3.battle.rules", "src.core.game3.battle.effect_ids", + "src.core.game3.battle.effects", "src.core.game3.battle.effects.hit", + "src.core.game3.battle.effects._helpers", "src.core.game3.battle.effects.secondary", + "src.core.game3.battle.effects.hazards", "src.core.game3.battle.effects.special", + "src.core.game3.battle.residuals", "src.core.game3.battle.residual_handlers", + "src.core.game3.battle.held_items", "src.core.game3.battle.abilities", "src.core.game3.battle.kinds", + "src.core.game3.battle.oak_advice", "src.core.game3.battle.commands", + "src.core.game3.battle.effect_ctx", "src.core.game3.battle.switch_seq", "src.core.game3.battle.engine", + "src.core.game3.rs.enigma", "src.mods.Gen3Compat", +} + +local M = {} +local loaded = false + +local function load() + if loaded then return end + for _, name in ipairs(PRELOAD) do M[name] = require(name) end + loaded = true +end + +function Scope.modules() + load() + return M +end + +local SPECIAL_KEYS = { + [0] = "STRINGID_INTROMSG", [1] = "STRINGID_INTROSENDOUT", [2] = "STRINGID_RETURNMON", + [3] = "STRINGID_SWITCHINMON", [4] = "STRINGID_USEDMOVE", [5] = "STRINGID_BATTLEEND", +} + +function Scope.textKey(id) + if type(id) == "number" then return SPECIAL_KEYS[id] or ("STRINGID_" .. id) end + return tostring(id) +end + +local function token(kind) + return setmetatable({}, { __index = function(_, k) + local n = tonumber(k) + if not n then return nil end + return "{" .. kind .. ":" .. n .. "}" + end }) +end + +local function identity() + return setmetatable({}, { __index = function(_, k) return tonumber(k) end }) +end + +local function trip(where) + return function() + Scope.trips[#Scope.trips + 1] = where + error("g3u scope: " .. where .. " during a match", 2) + end +end + +local prepared = setmetatable({}, { __mode = "k" }) + +local function prepare(t) + local p = prepared[t] + if p then return p end + p = { rows = {}, types = {}, stats = {}, byNum = {}, byName = {} } + for id = 1, t.moveMax do + p.rows[id] = Table.engineRow(t, id) + local key = "G3U_" .. id + p.byNum[id] = key + p.byName[key] = id + end + p.byName.STRUGGLE = 165 + for n = 1, t.dexMax do + p.types[n] = Table.speciesTypes(t, n) + p.stats[n] = Table.baseStats(t, n) + end + prepared[t] = p + return p +end + +local function guardModule(list, mod, name, keep) + for k, v in pairs(mod) do + if type(v) == "function" and not (keep and keep[k]) then + list[#list + 1] = { mod, k, trip(name .. "." .. k) } + end + end +end + +local function swaps(t) + load() + local p = prepare(t) + if p.list then return p.list end + local Pokemon = M["src.core.game3.pokemon"] + local Moves = M["src.core.game3.battle.moves"] + local Runtime = M["src.mods.Runtime"] + local BattleText = M["src.core.game3.battle.battle_text"] + local Types = M["src.core.game3.battle.types"] + local Profile = M["src.core.game3.battle.profile"] + local Adapter = M["src.core.game3.battle.adapter"] + local SwitchSeq = M["src.core.game3.battle.switch_seq"] + local Rng32 = M["src.core.game3.rng"] + local guardCache = { read = trip("cache:read") } + local function noop() end + local function defaults() return Profile.DEFAULTS end + local list = { + { Pokemon, "_cache", guardCache }, + { Pokemon, "_names", token("species") }, + { Pokemon, "_types", p.types }, + { Pokemon, "_stats", p.stats }, + { Pokemon, "_abilities", {} }, + { Pokemon, "_abilityNames", token("ability") }, + { Pokemon, "_romAbilityNames", token("ability") }, + { Pokemon, "_moveNames", token("move") }, + { Pokemon, "_romMoveNames", token("move") }, + { Pokemon, "_speciesMeta", {} }, + { Pokemon, "_national", { toNational = identity(), toSpecies = identity() } }, + { Pokemon, "_byName", {} }, + { Pokemon, "_manifest", {} }, + { Pokemon, "_learnsets", {} }, + { Pokemon, "_eggMoves", {} }, + { Pokemon, "_evolutions", {} }, + { Pokemon, "_tmhm", {} }, + { Pokemon, "_dex", {} }, + { Pokemon, "_battleMoves", p.rows }, + { Pokemon, "install", trip("Pokemon.install") }, + { Pokemon, "adjustFriendshipOnBattleFaint", noop }, + { Pokemon, "currentMapSec", noop }, + { Moves, "_rom", p.rows }, + { Moves, "_romLoaded", true }, + { Moves, "_linkRows", false }, + { Moves, "BY_NUM", p.byNum }, + { Moves, "_numByName", p.byName }, + { Moves, "loadRomPack", trip("Moves.loadRomPack") }, + { Runtime, "wants", function() return false end }, + { Runtime, "wantsHook", function() return false end }, + { Runtime, "emit", noop }, + { Runtime, "call", function(_, vanilla, ...) return vanilla(...) end }, + { BattleText, "get", function(id) return Scope.textKey(id) end }, + { BattleText, "key", function(id, fill) return Scope.textKey(id), fill end }, + { BattleText, "ir", trip("BattleText.ir") }, + { BattleText, "context", trip("BattleText.context") }, + { Types, "name", function(id) return "{type:" .. tostring(tonumber(id)) .. "}" end }, + { Types, "get", function(id) return "{type:" .. tostring(tonumber(id)) .. "}" end }, + { Profile, "get", defaults }, + { Profile, "of", defaults }, + { Adapter, "textSink", Scope.textKey }, + { SwitchSeq, "switchInFill", function(_, b) return { side = b and b.side, switchBattler = b and b.id } end }, + { math, "random", trip("math.random") }, + { math, "randomseed", trip("math.randomseed") }, + } + for _, k in ipairs({ "Random", "Random32", "Random2", "compat", "step", "mod", "WildEncounterRandom" }) do + list[#list + 1] = { Rng32, k, trip("rng." .. k) } + end + guardModule(list, M["src.core.game3.rom_text"], "RomText", { key = true }) + guardModule(list, M["src.import.CacheFs"], "CacheFs") + list[#list + 1] = { M["src.core.game3.dataset"], "cache", trip("Dataset.cache") } + list[#list + 1] = { M["src.core.game3.dataset"], "hydrate", trip("Dataset.hydrate") } + p.list = list + return list +end + +function Scope.newStack() + local pool, depth = {}, 0 + local S = {} + function S.push(adapter, user, target, move, moveId, rng, opts) + depth = depth + 1 + local ctx = pool[depth] or {} + pool[depth] = ctx + ctx.adapter, ctx.user, ctx.target, ctx.move, ctx.moveId, ctx.rng, ctx.opts = + adapter, user, target, move, moveId, rng, opts + return ctx + end + function S.pop() + assert(depth > 0, "effect context stack underflow") + local ctx = pool[depth] + ctx.adapter, ctx.user, ctx.target, ctx.move, ctx.moveId, ctx.rng, ctx.opts = nil, nil, nil, nil, nil, nil, nil + pool[depth] = nil + depth = depth - 1 + end + function S.current() return depth > 0 and pool[depth] or nil end + function S.depth() return depth end + function S.reset() while depth > 0 do S.pop() end end + return S +end + +local depth = 0 + +function Scope.run(t, fn, stack) + if depth > 0 then return fn() end + local list = swaps(t) + if stack then + local all = {} + for i, s in ipairs(list) do all[i] = s end + local EffectCtx = M["src.core.game3.battle.effect_ctx"] + for _, k in ipairs({ "push", "pop", "current", "depth", "reset" }) do all[#all + 1] = { EffectCtx, k, stack[k] } end + list = all + end + local saved = {} + for i, s in ipairs(list) do + saved[i] = { s[1], s[2], rawget(s[1], s[2]) } + rawset(s[1], s[2], s[3]) + end + local Guard = M["src.core.game3.battle.link_guard"] + local wasActive, wasTripped = Guard.active, Guard.tripped + Guard.arm() + depth = depth + 1 + local res = { pcall(fn) } + depth = depth - 1 + local guardTrip = Guard.tripped + Guard.active, Guard.tripped = wasActive, wasTripped + for i = #saved, 1, -1 do + local s = saved[i] + rawset(s[1], s[2], s[3]) + end + if guardTrip then Scope.trips[#Scope.trips + 1] = "link_guard:" .. tostring(guardTrip) end + if not res[1] then error(res[2], 0) end + return unpack(res, 2, table.maxn(res)) +end + +function Scope.resetTrips() + Scope.trips = {} +end + +return Scope diff --git a/src/battle/g3u/Table.lua b/src/battle/g3u/Table.lua new file mode 100644 index 00000000..0cd0543b --- /dev/null +++ b/src/battle/g3u/Table.lua @@ -0,0 +1,257 @@ +local E = require("src.core.game3.battle.effect_ids") +local Identity = require("src.online.xgen.Identity") +local Map1 = require("src.battle.g3u.EffectMap1") +local Map2 = require("src.battle.g3u.EffectMap2") + +local Table = {} + +Table.VERSION = 1 + +Table.MOVE_COLS = { "power", "type", "accuracy", "pp", "effect", "chance", "target", "priority", "flags" } +Table.SPECIES_COLS = { "type1", "type2", "hp", "atk", "def", "spe", "spa", "spd" } + +Table.LIMITS = { + [1] = { moveMax = 165, dexMax = 151 }, + [2] = { moveMax = 251, dexMax = 251 }, +} + +local MAPS = { [1] = Map1, [2] = Map2 } +Table.MAPS = MAPS + +-- pokefirered/include/constants/pokemon.h:239 +local FLAG_PROTECT_AFFECTED = 2 +local FLAG_MIRROR_MOVE_AFFECTED = 16 + +-- pokefirered/include/battle.h:59 +local TARGET_SELECTED, TARGET_DEPENDS, TARGET_USER, TARGET_OPPONENTS_FIELD = 0, 1, 16, 64 + +local USER_EFFECTS = {} +for _, id in ipairs({ + E.ATTACK_UP, E.DEFENSE_UP, E.SPEED_UP, E.SPECIAL_ATTACK_UP, E.SPECIAL_DEFENSE_UP, E.ACCURACY_UP, + E.EVASION_UP, E.HAZE, E.BIDE, E.CONVERSION, E.RESTORE_HP, E.LIGHT_SCREEN, E.REST, E.MIST, + E.FOCUS_ENERGY, E.ATTACK_UP_2, E.DEFENSE_UP_2, E.SPEED_UP_2, E.SPECIAL_ATTACK_UP_2, + E.SPECIAL_DEFENSE_UP_2, E.ACCURACY_UP_2, E.EVASION_UP_2, E.REFLECT, E.SUBSTITUTE, E.METRONOME, + E.SPLASH, E.CONVERSION_2, E.SLEEP_TALK, E.DESTINY_BOND, E.HEAL_BELL, E.MINIMIZE, E.CURSE, + E.PROTECT, E.SANDSTORM, E.ENDURE, E.SAFEGUARD, E.BATON_PASS, E.MORNING_SUN, E.SYNTHESIS, + E.MOONLIGHT, E.RAIN_DANCE, E.SUNNY_DAY, E.BELLY_DRUM, E.TELEPORT, E.DEFENSE_CURL, E.SOFTBOILED, + E.MIRROR_MOVE, +}) do USER_EFFECTS[id] = true end + +local NO_MIRROR = { [E.MIRROR_MOVE] = true, [E.METRONOME] = true, [E.SLEEP_TALK] = true } + +-- pokefirered/src/data/battle_moves.h:465 +local CERTAIN_SECONDARY = { [E.TRAP] = true, [E.PAY_DAY] = true } + +local function targetOf(effect) + if effect == E.COUNTER or effect == E.MIRROR_COAT then return TARGET_DEPENDS end + if effect == E.SPIKES then return TARGET_OPPONENTS_FIELD end + if USER_EFFECTS[effect] then return TARGET_USER end + return TARGET_SELECTED +end + +local function flagsOf(effect, target) + local f = 0 + if target == TARGET_SELECTED or target == TARGET_DEPENDS then f = f + FLAG_PROTECT_AFFECTED end + if target == TARGET_SELECTED and not NO_MIRROR[effect] then f = f + FLAG_MIRROR_MOVE_AFFECTED end + return f +end + +local SUPPORTED = {} +for _, map in pairs(MAPS) do + for _, row in pairs(map.EFFECTS) do SUPPORTED[row.effect] = true end + for _, row in pairs(map.MOVES) do SUPPORTED[row.effect] = true end +end +Table.SUPPORTED_EFFECTS = SUPPORTED + +local function gen3Type(name) + return name and Identity.GEN3_TYPE_ID[name] or nil +end + +function Table.mapMove(gen, id, mv) + local map = MAPS[gen] + if not map then return nil, "bad_gen" end + if type(mv) ~= "table" then return nil, "missing_move" end + local name = mv.effect + local base = map.EFFECTS[name] + if not base then + if map.UNSUPPORTED[name] then return nil, "unsupported_effect", name end + return nil, "unknown_effect", name + end + local over = map.MOVES[id] or {} + local effect = over.effect or base.effect + local chance + if gen == 1 then + chance = base.chance or 0 + else + chance = math.floor(tonumber(mv.effectChance) or 0) + end + if over.chance then chance = over.chance end + if chance == 0 and CERTAIN_SECONDARY[effect] then chance = 100 end + local priority + if gen == 1 then + priority = map.PRIORITY[id] or 0 + else + local raw = map.MOVE_PRIORITY[id] or map.EFFECT_PRIORITY[name] or map.BASE_PRIORITY + priority = raw - map.BASE_PRIORITY + end + local typeId = gen3Type(mv.type) + if not typeId then return nil, "unknown_type", mv.type end + local target = targetOf(effect) + return { + math.floor(tonumber(mv.power) or 0), typeId, math.floor(tonumber(mv.accuracy) or 0), + math.floor(tonumber(mv.pp) or 0), effect, chance, target, priority, flagsOf(effect, target), + } +end + +function Table.unsupportedMoves(data) + local gen = data and data.generation + local lim = Table.LIMITS[gen] + local out = {} + if not lim then return out end + for id = 1, lim.moveMax do + local mv = data.moves[id] + if mv then + local row, why, detail = Table.mapMove(gen, id, mv) + if not row then out[#out + 1] = { id = id, why = why, effect = detail } end + end + end + return out +end + +local function speciesRow(sp) + local t1 = gen3Type(sp.types and sp.types[1]) + local t2 = gen3Type(sp.types and (sp.types[2] or sp.types[1])) + if not t1 or not t2 then return nil end + local b = sp.base or {} + local row = { t1, t2 } + for _, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do + local v = math.floor(tonumber(b[k]) or 0) + if v < 1 then return nil end + row[#row + 1] = v + end + return row +end + +function Table.build(data, gen) + if type(data) ~= "table" then return nil, "no_data" end + gen = gen or data.generation + local lim = Table.LIMITS[gen] + if not lim or data.generation ~= gen then return nil, "bad_gen" end + local t = { v = Table.VERSION, gen = gen, moveMax = lim.moveMax, dexMax = lim.dexMax, + moves = {}, species = {}, illegal = {} } + for id = 1, lim.moveMax do + local mv = data.moves[id] + if not mv then return nil, "missing_move", id end + local row, why, detail = Table.mapMove(gen, id, mv) + if not row then + if why ~= "unsupported_effect" then return nil, why, { id = id, detail = detail } end + row = { math.floor(tonumber(mv.power) or 0), gen3Type(mv.type) or 0, 0, 1, E.HIT, 0, TARGET_SELECTED, 0, 0 } + t.illegal[#t.illegal + 1] = id + end + t.moves[id] = row + end + for n = 1, lim.dexMax do + local sp = data.species[n] + if not sp then return nil, "missing_species", n end + local row = speciesRow(sp) + if not row then return nil, "bad_species", n end + t.species[n] = row + end + return t +end + +local GEN1_TYPES, GEN2_TYPES = {}, {} +for _, name in ipairs(Identity.GEN1_TYPES) do GEN1_TYPES[Identity.GEN3_TYPE_ID[name]] = true end +for _, name in ipairs(Identity.TYPES) do GEN2_TYPES[Identity.GEN3_TYPE_ID[name]] = true end +GEN2_TYPES[Identity.GEN3_TYPE_ID.MYSTERY] = true +local TYPE_SETS = { [1] = GEN1_TYPES, [2] = GEN2_TYPES } + +local VALID_TARGETS = { [TARGET_SELECTED] = true, [TARGET_DEPENDS] = true, [TARGET_USER] = true, + [TARGET_OPPONENTS_FIELD] = true } + +local function int(v, lo, hi) + return type(v) == "number" and v == v and v == math.floor(v) and v >= lo and v <= hi +end + +local function exact(row, n) + if type(row) ~= "table" or #row ~= n then return false end + local count = 0 + for _ in pairs(row) do count = count + 1 end + return count == n +end + +local MOVE_KEYS = { v = true, gen = true, moveMax = true, dexMax = true, moves = true, species = true, illegal = true } + +function Table.validate(t, gen) + if type(t) ~= "table" then return nil, "not_a_table" end + for k in pairs(t) do + if not MOVE_KEYS[k] then return nil, "unknown_key" end + end + if t.v ~= Table.VERSION then return nil, "bad_version" end + if gen ~= nil and t.gen ~= gen then return nil, "gen_mismatch" end + local lim = Table.LIMITS[t.gen] + if not lim then return nil, "bad_gen" end + if t.moveMax ~= lim.moveMax or t.dexMax ~= lim.dexMax then return nil, "bad_limits" end + local types = TYPE_SETS[t.gen] + if not exact(t.moves, lim.moveMax) then return nil, "bad_move_count" end + for id = 1, lim.moveMax do + local r = t.moves[id] + if not exact(r, #Table.MOVE_COLS) then return nil, "bad_move_row", id end + if not int(r[1], 0, 255) then return nil, "bad_power", id end + if not int(r[2], 0, 17) or not types[r[2]] then return nil, "bad_type", id end + if not int(r[3], 0, 100) then return nil, "bad_accuracy", id end + if not int(r[4], 1, 64) then return nil, "bad_pp", id end + if not int(r[5], 0, 255) or not SUPPORTED[r[5]] then return nil, "bad_effect", id end + if not int(r[6], 0, 100) then return nil, "bad_chance", id end + if not int(r[7], 0, 255) or not VALID_TARGETS[r[7]] then return nil, "bad_target", id end + if not int(r[8], -6, 6) then return nil, "bad_priority", id end + if not int(r[9], 0, 63) then return nil, "bad_flags", id end + end + if not exact(t.species, lim.dexMax) then return nil, "bad_species_count" end + for n = 1, lim.dexMax do + local r = t.species[n] + if not exact(r, #Table.SPECIES_COLS) then return nil, "bad_species_row", n end + if not int(r[1], 0, 17) or not types[r[1]] or not int(r[2], 0, 17) or not types[r[2]] then + return nil, "bad_species_type", n + end + for i = 3, 8 do + if not int(r[i], 1, 255) then return nil, "bad_base_stat", n end + end + end + if type(t.illegal) ~= "table" or #t.illegal > lim.moveMax then return nil, "bad_illegal" end + local count, prev = 0, 0 + for _ in pairs(t.illegal) do count = count + 1 end + if count ~= #t.illegal then return nil, "bad_illegal" end + for _, id in ipairs(t.illegal) do + if not int(id, prev + 1, lim.moveMax) then return nil, "bad_illegal" end + prev = id + end + return true +end + +function Table.illegalSet(t) + local out = {} + for _, id in ipairs(t.illegal or {}) do out[id] = true end + return out +end + +function Table.engineRow(t, id) + local r = t.moves[id] + if not r then return nil end + return { power = r[1], type = r[2], accuracy = r[3], pp = r[4], effect = r[5], + secondaryChance = r[6], target = r[7], priority = r[8], flags = r[9] } +end + +function Table.speciesTypes(t, n) + local r = t.species[n] + if not r then return nil end + return { r[1], r[2] } +end + +function Table.baseStats(t, n) + local r = t.species[n] + if not r then return nil end + return { hp = r[3], atk = r[4], def = r[5], spe = r[6], spa = r[7], spd = r[8] } +end + +return Table diff --git a/src/battle/g3u/Wire.lua b/src/battle/g3u/Wire.lua new file mode 100644 index 00000000..6f397235 --- /dev/null +++ b/src/battle/g3u/Wire.lua @@ -0,0 +1,192 @@ +local Table = require("src.battle.g3u.Table") + +local Wire = {} + +Wire.TYPES = { "g3u_table", "g3u_party", "g3u_action", "g3u_replace", "g3u_hash", "g3u_bye" } + +Wire.MAX_BYTES = { + g3u_table = 16384, g3u_party = 4096, g3u_action = 128, g3u_replace = 96, g3u_hash = 96, g3u_bye = 128, +} + +Wire.BYE = { forfeit = true, desync = true, illegal = true, timeout = true, quit = true, error = true, + bad_table = true, bad_party = true } + +Wire.MAX_TURN = 4096 +Wire.MAX_NICK_CHARS = 10 + +local function int(v, lo, hi) + return type(v) == "number" and v == v and v == math.floor(v) and v >= lo and v <= hi +end + +local function keys(t, allowed) + for k in pairs(t) do + if not allowed[k] then return false end + end + return true +end + +local function chars(s) + local n = 0 + for i = 1, #s do + local b = s:byte(i) + if b < 0x80 or b >= 0xC0 then n = n + 1 end + end + return n +end + +function Wire.size(msg) + return #require("src.link.Json").encode(msg) +end + +function Wire.table(t) + return { type = "g3u_table", table = t } +end + +function Wire.party(records) + return { type = "g3u_party", records = records } +end + +function Wire.action(turn, act) + local m = { type = "g3u_action", turn = turn, kind = act.kind } + if act.kind == "move" then m.slot = act.slot end + if act.kind == "switch" then m.index = act.index end + return m +end + +function Wire.replace(turn, index) + return { type = "g3u_replace", turn = turn, index = index } +end + +function Wire.hash(turn, hash) + return { type = "g3u_hash", turn = turn, hash = hash } +end + +function Wire.bye(why) + return { type = "g3u_bye", why = why } +end + +function Wire.toAction(m) + if m.kind == "move" then return { kind = "move", slot = m.slot } end + if m.kind == "switch" then return { kind = "switch", index = m.index } end + return { kind = "forfeit" } +end + +local V = {} + +function V.g3u_table(m, ctx) + if not keys(m, { type = true, table = true }) then return nil, "unknown_key" end + local ok, why = Table.validate(m.table, ctx and ctx.gen) + if not ok then return nil, "bad_table:" .. tostring(why) end + return m +end + +local REC_KEYS = { species = true, level = true, hp = true, maxHp = true, atk = true, def = true, + spAtk = true, spDef = true, speed = true, moves = true, nickname = true, gender = true, + friendship = true, ivs = true } +local IV_KEYS = { hp = true, atk = true, def = true, spe = true, spa = true, spd = true } +local MOVE_KEYS = { id = true, pp = true, ppUps = true } + +-- pokefirered/src/pokemon.c:2093 +local function statCap(level) return math.floor((2 * 255 + 31 + 63) * level / 100) + 5 end +local function hpCap(level) return math.floor((2 * 255 + 31 + 63) * level / 100) + level + 10 end + +function Wire.record(r, t) + if type(r) ~= "table" or not keys(r, REC_KEYS) then return nil, "record_shape" end + if not int(r.species, 1, t.dexMax) then return nil, "species" end + if not int(r.level, 1, 100) then return nil, "level" end + if not int(r.maxHp, 1, hpCap(r.level)) or not int(r.hp, 1, r.maxHp) then return nil, "hp" end + for _, k in ipairs({ "atk", "def", "spAtk", "spDef", "speed" }) do + if not int(r[k], 1, statCap(r.level)) then return nil, "stat_" .. k end + end + if type(r.moves) ~= "table" or #r.moves < 1 or #r.moves > 4 then return nil, "moves" end + local count, seen = 0, {} + for _ in pairs(r.moves) do count = count + 1 end + if count ~= #r.moves then return nil, "moves" end + local illegal = Table.illegalSet(t) + for _, mv in ipairs(r.moves) do + if type(mv) ~= "table" or not keys(mv, MOVE_KEYS) then return nil, "move_shape" end + if not int(mv.id, 1, t.moveMax) or seen[mv.id] then return nil, "move_id" end + if illegal[mv.id] then return nil, "move_unsupported" end + seen[mv.id] = true + local base = t.moves[mv.id][4] + if not int(mv.ppUps or 0, 0, 3) then return nil, "pp_ups" end + if not int(mv.pp, 0, math.min(64, base + math.floor(base * 3 / 5))) then return nil, "pp" end + end + if r.nickname ~= nil and (type(r.nickname) ~= "string" or #r.nickname > Wire.MAX_NICK_CHARS * 4 + or chars(r.nickname) > Wire.MAX_NICK_CHARS) then + return nil, "nickname" + end + if r.gender ~= nil and not int(r.gender, 0, 2) then return nil, "gender" end + if r.friendship ~= nil and not int(r.friendship, 0, 255) then return nil, "friendship" end + if r.ivs ~= nil then + if type(r.ivs) ~= "table" or not keys(r.ivs, IV_KEYS) then return nil, "ivs" end + for _, v in pairs(r.ivs) do + if not int(v, 0, 31) then return nil, "ivs" end + end + end + return r +end + +function V.g3u_party(m, ctx) + if not keys(m, { type = true, records = true }) then return nil, "unknown_key" end + local t = ctx and ctx.table + if type(t) ~= "table" then return nil, "no_table" end + local recs = m.records + if type(recs) ~= "table" or #recs < 1 or #recs > 6 then return nil, "party_size" end + local count = 0 + for _ in pairs(recs) do count = count + 1 end + if count ~= #recs then return nil, "party_size" end + for i, r in ipairs(recs) do + local ok, why = Wire.record(r, t) + if not ok then return nil, "record_" .. i .. ":" .. why end + end + return m +end + +function V.g3u_action(m) + if not int(m.turn, 1, Wire.MAX_TURN) then return nil, "turn" end + if m.kind == "move" then + if not keys(m, { type = true, turn = true, kind = true, slot = true }) then return nil, "unknown_key" end + if not int(m.slot, 0, 4) then return nil, "slot" end + elseif m.kind == "switch" then + if not keys(m, { type = true, turn = true, kind = true, index = true }) then return nil, "unknown_key" end + if not int(m.index, 1, 6) then return nil, "index" end + elseif m.kind == "forfeit" then + if not keys(m, { type = true, turn = true, kind = true }) then return nil, "unknown_key" end + else + return nil, "kind" + end + return m +end + +function V.g3u_replace(m) + if not keys(m, { type = true, turn = true, index = true }) then return nil, "unknown_key" end + if not int(m.turn, 0, Wire.MAX_TURN) then return nil, "turn" end + if not int(m.index, 1, 6) then return nil, "index" end + return m +end + +function V.g3u_hash(m) + if not keys(m, { type = true, turn = true, hash = true }) then return nil, "unknown_key" end + if not int(m.turn, 0, Wire.MAX_TURN) then return nil, "turn" end + if type(m.hash) ~= "string" or not m.hash:match("^%x%x%x%x%x%x%x%x$") then return nil, "hash" end + return m +end + +function V.g3u_bye(m) + if not keys(m, { type = true, why = true }) then return nil, "unknown_key" end + if type(m.why) ~= "string" or not Wire.BYE[m.why] then return nil, "why" end + return m +end + +Wire.VALIDATORS = V + +function Wire.validate(m, ctx) + if type(m) ~= "table" or type(m.type) ~= "string" then return nil, "not_a_message" end + local fn = V[m.type] + if not fn then return nil, "unknown_type" end + if ctx and ctx.bytes and ctx.bytes > Wire.MAX_BYTES[m.type] then return nil, "too_big" end + return fn(m, ctx) +end + +return Wire diff --git a/src/core/Data.lua b/src/core/Data.lua index 4575d883..2afa2cf9 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -182,6 +182,7 @@ function Data:seedDefaults(version) Data.seedMtMoonB2FSuperNerd(self) -- #189: 1F cabin door order vs rooms map (survey zoom) require("src.world.SsAnneLayout").apply(self.maps) + if gen == 1 then require("src.world.gen1.UnionCenters").seed(self) end end -- The Karate Master (FightingDojo.asm) is a text_asm object: his object has diff --git a/src/core/Game.lua b/src/core/Game.lua index 2e67e762..75ca457f 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -356,6 +356,7 @@ function Game:step(dt) else self.stack:update(dt) end + if self.unionPresence then self.unionPresence:tick() end -- play time for the trainer card / save screen self.save.playTime = (self.save.playTime or 0) + dt -- Music.update is NOT serviced here: it decrements fade counters and @@ -1363,7 +1364,7 @@ function Game:writeSave() if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) end - local written = SaveData.save(self.save) + local written = require("src.world.gen1.UnionSafety").write(self.save, self.data, SaveData.save) if written then local eng = self:syncEngine() if eng then pcall(eng.noteSaveWritten, eng) end @@ -1510,6 +1511,7 @@ function Game:restoreSave(loaded, recovered, opts) local Pokemon = require("src.pokemon.Pokemon") for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end end + require("src.world.gen1.UnionSafety").settle(loaded, self.data) local modsDiff = SaveData.modsDiff(loaded, activeMods) local report = SaveData.validate(loaded, self.data) report.recovered = recovered @@ -1537,6 +1539,7 @@ function Game:restoreSave(loaded, recovered, opts) self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, facing, { via = "boot", freshBoot = opts and opts.freshBoot }) + require("src.online.union.TradeTxn").resumePending(self) self.saveReport = report if not SaveData.emptyReport(report) then -- the report screen is a Screens id so mods (or the ui milestone) own diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 10fd86ce..6d4e7727 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -342,7 +342,9 @@ function Game2:continueGame(save) self:applyOptions() self.stack:clear() self.world = nil + require("src.world.gen2.UnionSafety").settle(save, self.data) self:startWorld() + require("src.online.union.TradeTxn").resumePending(self) -- After the adopt and after the world is standing, which is where Gen 1 -- emits it (src/core/Game.lua:1127, once the stack has been rebuilt). if modsDiff then @@ -1002,6 +1004,7 @@ function Game2:snapshotSave() -- WRAM on the cart, so a save made on POKECENTER_2F must still know which -- centre's stairs lead back down -- World:loadPlayerData reads it back. self.save.backupWarp = world.backupWarp or self.save.backupWarp + require("src.world.gen2.UnionSafety").seal(self.save, self.data) end self.save.options = self.options return self.save @@ -1137,6 +1140,7 @@ function Game2:load(opts) self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua") self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua") self.data.gen2Text = loadGenerated("data/generated/text.lua") + require("src.world.gen2.UnionCenter2F").apply(self.data) -- The engine's own strings, keyed by the disassembly's label. gen2Text -- above is the script text and is keyed by bank:address for the overworld -- VM, so the two are different tables and both are loaded. This one is @@ -1302,6 +1306,7 @@ function Game2:load(opts) -- open the same frame), rather than once per render frame in World:draw. local world = self.world if world and world.tickFrameClocks then world:tickFrameClocks() end + if world then require("src.world.gen2.UnionRoomPresence").tick(self) end -- Not the audio tick: _UpdateSound runs once per frame off VBlank -- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock. local top = self.stack:top() diff --git a/src/core/Game3.lua b/src/core/Game3.lua index a19cc260..fce8f2e7 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -1045,6 +1045,11 @@ function Game3:saveGame() local P = package.loaded["src.core.game3.player"] if P and P.facing and not P.moving and P.cellX == self.session.x and P.cellY == self.session.y then self.session.facing = P.facing end local save = Schema.toSaveTable(self.session) + local rules = Schema.rulesFor(self.session.version) + if rules.saveLocation then + local map, x, y, facing = rules.saveLocation(self.session, self) + if map then save.map, save.x, save.y, save.facing = map, x, y, facing end + end if SaveData.buildMeta then save.meta = SaveData.buildMeta( self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 0798aedf..cd26417c 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1385,6 +1385,18 @@ local function tryMigrateLegacy(key, fs) -- refuse to delete the originals unless the new slot is loadable (from -- the main copy or, failing that, the backup) if not decodeSlot(fs, key, id) then return nil end + local lbase, dbase = lmain:gsub("%.lua$", ""), dmain:gsub("%.lua$", "") + for _, suffix in ipairs({ "_trade.lua", "_xtrade.lua", "_xtrade.lua.tmp" }) do + local from = lbase .. suffix + local body = fs.getInfo(from) and fs.read(from) + if body then + fs.write(dbase .. suffix, body) + if not fs.getInfo(dbase .. suffix) then return nil end + end + end + for _, suffix in ipairs({ "_trade.lua", "_xtrade.lua", "_xtrade.lua.tmp" }) do + if fs.getInfo(lbase .. suffix) then remove(fs, lbase .. suffix) end + end remove(fs, lmain) remove(fs, lbak) remove(fs, ltmp) @@ -1905,12 +1917,18 @@ local function copyTree(fs, from, to) end local function pendingTradeSent(fs, main) - local journal = main:gsub("%.lua$", "") .. "_trade.lua" - if not fs.getInfo(journal) then return false end - local data = SaveSerializer.decode(fs.read(journal) or "") - if type(data) ~= "table" or type(data.entries) ~= "table" then return false end - for _, e in ipairs(data.entries) do - if type(e) == "table" and type(e.sent) == "table" then return true end + local base = main:gsub("%.lua$", "") + for _, journal in ipairs({ base .. "_trade.lua", base .. "_xtrade.lua" }) do + if fs.getInfo(journal) then + local data = SaveSerializer.decode(fs.read(journal) or "") + if type(data) == "table" and type(data.entries) == "table" then + for _, e in ipairs(data.entries) do + if type(e) == "table" and (type(e.sent) == "table" or type(e.out) == "table") then + return true + end + end + end + end end return false end diff --git a/src/core/game3/battle/adapter.lua b/src/core/game3/battle/adapter.lua index 1564a25b..2b93735d 100644 --- a/src/core/game3/battle/adapter.lua +++ b/src/core/game3/battle/adapter.lua @@ -423,6 +423,12 @@ function Adapter.new(battleState, sayFn) if faintPolicy and fill and (id == "STRINGID_ATTACKERFAINTED" or id == "STRINGID_TARGETFAINTED") then self:prepareFaintAnnouncement(fill.atk or fill.def) end + if Adapter.textSink then + local key = Adapter.textSink(id, fill) + self:pushEvent({ kind = "msg", text = key, id = key, fill = fill or {} }) + self._say(key) + return key + end local BattleText = require("src.core.game3.battle.battle_text") fill = Adapter.fill(self._st, fill) local text = BattleText.get(id, fill) diff --git a/src/core/game3/battle/effects/secondary.lua b/src/core/game3/battle/effects/secondary.lua index e0e72b03..4a1f755e 100644 --- a/src/core/game3/battle/effects/secondary.lua +++ b/src/core/game3/battle/effects/secondary.lua @@ -29,6 +29,7 @@ end -- src/battle_message.c:437 function Secondary.statName(stat) + if require("src.core.game3.battle.adapter").textSink then return stat end return RomText.at("gStatNamesTable", Secondary.STAT_ID[stat]) end @@ -48,6 +49,15 @@ end -- src/battle_script_commands.c:6758 local function stat_text(ad, battler, stat, delta, isUser) + if require("src.core.game3.battle.adapter").textSink then + local id + if delta > 0 then + id = isUser and "STRINGID_ATTACKERSSTATROSE" or "STRINGID_DEFENDERSSTATROSE" + else + id = isUser and "STRINGID_ATTACKERSSTATFELL" or "STRINGID_DEFENDERSSTATFELL" + end + return ad:sayText(id, { atk = battler, def = battler, stat = stat, delta = delta }) + end local change if delta >= 2 then change = Secondary.sharpChange("STRINGID_STATSHARPLY", "STRINGID_STATROSE") elseif delta >= 1 then change = RomText.plain("STRINGID_STATROSE") @@ -112,6 +122,9 @@ function Secondary.changeStat(ad, battler, stat, delta, flags) local nxt = cur + delta if nxt < -6 then nxt = -6 elseif nxt > 6 then nxt = 6 end battler.stages[stat] = nxt + if require("src.core.game3.battle.adapter").textSink then + ad:pushEvent({ kind = "stage", side = battler.side, battler = battler.id, stat = stat, delta = nxt - cur }) + end if not flags.noAnim then ad:playAnim("general", "STATS_CHANGE", battler, battler, Secondary.statAnimArg(stat, delta)) end diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 27f77da6..99166474 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -802,6 +802,14 @@ end -- pokefirered/src/battle_script_commands.c:7519 local function pick_metronome(M) + local cap = tonumber(M.st and M.st.moveMax) + if cap then + local pool, skip = {}, M.st.moveExcluded or {} + for m = 1, cap do + if not forbidden(m, false) and not skip[m] then pool[#pool + 1] = m end + end + return pool[roll(M.adapter, 1, #pool)] + end for _ = 1, 64 do local m = roll(M.adapter, 1, 511) if m < 355 and not forbidden(m, false) then return m end diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index 9be661c7..f459faa2 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -206,6 +206,7 @@ end local function healthbox_gender(mon) if not mon then return nil end local g = mon.gender + if g == "U" then return nil end if g ~= "M" and g ~= "F" then local Pokemon = require("src.core.game3.pokemon") local species = tonumber(mon.species or mon.speciesId) diff --git a/src/core/game3/battle/state.lua b/src/core/game3/battle/state.lua index d8031959..4a32468c 100644 --- a/src/core/game3/battle/state.lua +++ b/src/core/game3/battle/state.lua @@ -463,6 +463,7 @@ end function State.prefixedName(st, battler, name) name = name or State.displayName(battler) if battler and battler.side == "player" then return name end + if require("src.core.game3.battle.adapter").textSink then return name end local RomText = require("src.core.game3.rom_text") local prefix = (st ~= nil and not st.wild) and "sText_FoePkmnPrefix" or "sText_WildPkmnPrefix" local ok, pre = pcall(RomText.plain, prefix) diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 8a7a2233..cd8cd4f1 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -1758,6 +1758,12 @@ local PP_STATE_TO_COLOR_INDEX = { [3] = 0, } +function Ui.moveTypeOf(mon, slot, def) + local t = mon and mon.moveTypes and mon.moveTypes[slot] + if t ~= nil then return t end + return def.type +end + function Ui.ppColorState(currentPp, maxPp) currentPp = tonumber(currentPp) or 0 maxPp = tonumber(maxPp) or 0 @@ -2335,7 +2341,7 @@ local function draw_move_menu_rs(st) F.draw(RomText.plain("gText_MoveInterfacePP"), 184, 120, { colors = colors }) F.draw(string.char(0xfc, 0x11, 2, 0xfc, 0x14, 6) .. string.format("%2d/%2d", pp, maxPp), 200, 120, { colors = colors }) - F.draw(Types.name(def.type), 184, 136, { colors = colors }) + F.draw(Types.name(Ui.moveTypeOf(mon, slot, def)), 184, 136, { colors = colors }) end end @@ -2373,7 +2379,7 @@ local function draw_move_menu_rse(st) rse_text(W.MOVE_TYPE, typeLabel) local F = battle_font() local tw = F.measure(typeLabel, { font = "narrow" }) - rse_text(W.MOVE_TYPE, Types.name(def.type), tw, { narrow = false }) + rse_text(W.MOVE_TYPE, Types.name(Ui.moveTypeOf(mon, slot, def)), tw, { narrow = false }) end end @@ -2446,7 +2452,7 @@ local function draw_move_menu(st) -- pokefirered/src/battle_controller_player.c:1402 draw_menu_text(string.format("%2d/%2d", pp, maxPp), 202, 122, { small = false, colors = ppColors }) -- pokefirered/src/battle_controller_player.c:1413 - draw_menu_text(RomText.plain("gText_MoveInterfaceType") .. Types.name(def.type), 168, 138, + draw_menu_text(RomText.plain("gText_MoveInterfaceType") .. Types.name(Ui.moveTypeOf(mon, slot, def)), 168, 138, { small = true, colors = FrlgFont.COLOR.NORMAL }) end end diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 187a1c1c..fd5318bb 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -648,6 +648,7 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk a.sprite = eo.sprite or spriteNameForObj(eo.def or {}) a.graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)) a.alpha = Objects.fadeAlpha(eo) + a.draw = eo.draw a.priority = nil a.subpriority = nil actors[#actors + 1] = a diff --git a/src/core/game3/link/battle.lua b/src/core/game3/link/battle.lua index 1322891c..f41368d9 100644 --- a/src/core/game3/link/battle.lua +++ b/src/core/game3/link/battle.lua @@ -1,4 +1,6 @@ local Rng = require("src.core.game3.rng") +local G3uRng = require("src.battle.g3u.Rng") +local G3uHash = require("src.battle.g3u.Hash") local LB = {} @@ -47,7 +49,7 @@ LB.MSG = { FORFEIT = "forfeit", } -LB.HASH_PARTS = { "actives", "volatile", "bench", "field", "rng" } +LB.HASH_PARTS = G3uHash.PARTS LB.MODE_OF = { single = 1, double = 2, multi = 5 } @@ -114,29 +116,7 @@ end LB.copy = copyTable --- pokefirered/src/random.c:15 ISO_RANDOMIZE1 -function LB.makeRng(seed, counter) - local value = math.floor(tonumber(seed) or 0) % 4294967296 - local function word() - if counter then counter.n = counter.n + 1 end - value = (Rng.mulU32(value, 1103515245) + 24691) % 4294967296 - return math.floor(value / 65536) % 65536 - end - return function(lo, hi) - if lo == nil and hi == nil then return word() / 65536 end - if hi == nil then - lo = math.floor(tonumber(lo) or 1) - if lo <= 0 then return 0 end - return 1 + (word() % lo) - end - lo = math.floor(tonumber(lo) or 0) - hi = math.floor(tonumber(hi) or lo) - if hi < lo then lo, hi = hi, lo end - local span = hi - lo + 1 - if span <= 0 then return lo end - return lo + (word() % span) - end -end +LB.makeRng = G3uRng.make function LB.dealSeed() return Rng.Random32() % 4294967296 @@ -336,6 +316,14 @@ end function LB.battleParty(s) s = s or session() local party = partyOf(s) + if type(LB._team) == "table" and #LB._team > 0 then + local out = {} + for _, slot in ipairs(LB._team) do + slot = tonumber(slot) + if slot and party[slot] then out[#out + 1] = copyTable(party[slot]) end + end + if #out > 0 then return out end + end local okT, Tower = pcall(require, "src.core.game3.trainer_tower") local order = okT and Tower and Tower.selectedOrder and Tower.selectedOrder(s) or nil if type(order) == "table" and (tonumber(order[1]) or 0) ~= 0 then @@ -478,6 +466,14 @@ LB.TRAINER_PIC_LEAF = 136 -- pokefirered/include/constants/union_room.h:19 LB.NUM_UNION_ROOM_CLASSES = 8 +function LB.hasUnionRoomClasses() + if LB._unionRoomClasses then return true end + local ok, src = pcall(function() + return require("src.core.game3.dataset").cache():read("data/generated/gba/trainers/union_room_classes.lua") + end) + return ok and src ~= nil +end + function LB.unionRoomClasses() if not LB._unionRoomClasses then local rel = "data/generated/gba/trainers/union_room_classes.lua" @@ -506,7 +502,7 @@ end -- pokefirered/src/battle_controller_link_opponent.c:1172 function LB.peerPicId(setup) - if LB.unionRoom then return LB.unionRoomTrainerPic(setup) end + if LB.unionRoom and LB.hasUnionRoomClasses() then return LB.unionRoomTrainerPic(setup) end if tonumber(setup and setup.gender) == 1 then return LB.TRAINER_PIC_LEAF end return LB.TRAINER_PIC_RED end @@ -627,7 +623,7 @@ function LB.beginBattle(setup, onDone) [2] = {enigmaBerry = ownEnigmaPacket()}, [3] = setup}), hostRules = hostVersion, double = double, - unionRoom = LB.unionRoom, + unionRoom = LB.unionRoom and LB.hasUnionRoomClasses(), trainerId = nil, rng = LB.makeRng(LB.seed, LB._draws), peerName = setup.name, @@ -822,155 +818,14 @@ local function battleState() return Battle and Battle.getState and Battle.getState() or nil, Battle end -local bit = require("bit") - -local function fnv(text) - local h = 0x811C9DC5 - for i = 1, #text do - h = bit.bxor(h, text:byte(i)) % 4294967296 - h = ((bit.lshift(h, 24) % 4294967296) + h * 403) % 4294967296 - end - return string.format("%08x", h) -end - -LB.fnv = fnv - -local function scalar(v) - local t = type(v) - if t == "number" then - if v == math.floor(v) then return string.format("%d", v) end - return string.format("%.6f", v) - end - if t == "boolean" then return v and "T" or "F" end - if t == "string" then return v end - if v == nil then return "-" end - return "t" -end - -local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" } - -local VOLATILE_KEYS = { - "confusion", "substitute", "toxicCounter", "focusEnergy", "perishSong", "seeded", "trapped", - "attracted", "disabled", "encore", "taunt", "bide", "rage", "endure", "protect", "destinyBond", - "transformed", "isFirstTurn", - "expCharged", "expCursed", "expDisableTurns", "expDisabledMove", "expEncoreMove", "expEncoreSlot", - "expEncoreTurns", "expFocusEnergy", "expFuryCutter", "expInfatuated", "expIngrain", "expLockedMove", - "expLockedSlot", "expMustRecharge", "expNightmare", "expPerishTurns", "expRampageTurns", - "expRechargeTurns", "expRolloutTimer", "expSeeded", "expTauntedTurns", "expTormented", - "expTransform", "expTrapTurns", "expTrapped", "expTruantCounter", "expUproarTurns", "expYawnTurns", - "expCastformForm", -} - -local function perspective(st) - if st.linkMaster == false then - return { 1, 0, 3, 2 }, { "enemy", "player" } - end - return { 0, 1, 2, 3 }, { "player", "enemy" } -end - -local function battlerOf(st, id) - local State = require("src.core.game3.battle.state") - if State.isAbsent(st, id) then return nil end - return State.battler(st, id) -end - -local function sortedScalars(t, skip, st) - if type(t) ~= "table" then return scalar(t) end - local flip = st and st.linkMaster == false - local keys = {} - for k, v in pairs(t) do - if (type(k) == "string" or type(k) == "number") and not (skip and skip[k]) - and type(v) ~= "table" and type(v) ~= "function" then - keys[#keys + 1] = tostring(k) - end - end - table.sort(keys) - local out = {} - for _, k in ipairs(keys) do - local v = t[k] - if v == nil then v = t[tonumber(k)] end - if flip and type(v) == "number" and k:sub(-2) == "Id" and v >= 0 and v <= 3 then - v = (v % 2 == 0) and (v + 1) or (v - 1) - end - out[#out + 1] = k .. "=" .. scalar(v) - end - return table.concat(out, ",") -end - -local function monPp(mon) - local pp = {} - for i = 1, 4 do pp[i] = scalar(mon and mon.pp and mon.pp[i]) end - return table.concat(pp, "/") -end - -local SIDE_SKIP = { id = true } +LB.fnv = G3uHash.fnv function LB.hashParts(st) - local order, sides = perspective(st) - local actives, volatile = {}, {} - for _, id in ipairs(order) do - local b = battlerOf(st, id) - if not b then - actives[#actives + 1] = "-" - volatile[#volatile + 1] = "-" - else - local mon = b.mon or {} - local stages = {} - for i, key in ipairs(STAGES) do stages[i] = scalar(b.stages and b.stages[key] or 0) end - actives[#actives + 1] = table.concat({ - scalar(tonumber(b.species or mon.species)), scalar(tonumber(mon.hp)), scalar(tonumber(mon.maxHp)), - scalar(b.status or mon.status), scalar(mon.sleep or b.sleepTurns), table.concat(stages, "/"), - scalar(b.ability), scalar(tonumber(b.item) or 0), monPp(mon), - }, ":") - local vol = {} - for _, key in ipairs(VOLATILE_KEYS) do vol[#vol + 1] = scalar(b[key]) end - vol[#vol + 1] = sortedScalars(b.volatiles) - volatile[#volatile + 1] = table.concat(vol, ":") - end - end - local bench, field = {}, {} - for _, side in ipairs(sides) do - local party = (side == "player") and st.playerParty or st.foeParty - local active = {} - for _, id in ipairs((side == "player") and { 0, 2 } or { 1, 3 }) do - local b = battlerOf(st, id) - if b and b.partyIndex then active[b.partyIndex] = true end - end - local rows = {} - for i, mon in ipairs(party or {}) do - if active[i] then - rows[#rows + 1] = "*" - else - rows[#rows + 1] = table.concat({ - scalar(tonumber(mon.species or mon.speciesId)), scalar(tonumber(mon.hp)), scalar(mon.status), - scalar(tonumber(mon.item or mon.heldItem) or 0), monPp(mon), - }, ":") - end - end - bench[#bench + 1] = table.concat(rows, ";") - local sideState = (side == "player") and st.playerSide or st.enemySide - field[#field + 1] = sortedScalars(sideState, SIDE_SKIP, st) .. "|" .. sortedScalars(sideState and sideState.hazards, nil, st) - end - table.insert(field, 1, scalar(st.weather) .. ":" .. scalar(st.weatherTurns)) - local raw = { - actives = table.concat(actives, "#"), - volatile = table.concat(volatile, "#"), - bench = table.concat(bench, "#"), - field = table.concat(field, "#"), - } - return { - actives = fnv(raw.actives), - volatile = fnv(raw.volatile), - bench = fnv(raw.bench), - field = fnv(raw.field), - rng = string.format("%d", LB._draws and LB._draws.n or 0), - }, raw + return G3uHash.parts(st, LB._draws) end function LB.hashValue(parts) - local list = {} - for i, key in ipairs(LB.HASH_PARTS) do list[i] = tostring(parts[key] or "") end - return fnv(table.concat(list, "|")) + return G3uHash.value(parts) end local function firstDiff(mine, theirs) @@ -1834,7 +1689,8 @@ function LB.enterColosseumPlayerSpot(ctx, adapters) end -- pokefirered/src/union_room.c:1811 StartUnionRoomBattle -function LB.startUnionRoomBattle(onDone) +function LB.startUnionRoomBattle(onDone, opts) + opts = opts or {} local L = link() local lk = L.link if not (lk and lk:isOpen()) then return false, "no_link" end @@ -1844,6 +1700,7 @@ function LB.startUnionRoomBattle(onDone) LB.unionRoom = true LB.state = "setup" LB.freshBattle() + LB._team = type(opts.team) == "table" and opts.team or nil if LB.onRelay() then LB.seed = LB.seedFromRelay() elseif lk.role == "host" then @@ -1897,6 +1754,7 @@ function LB.freshBattle() LB._drained = nil LB._myPacked = nil LB._myParty = nil + LB._team = nil LB._reported = false LB._relay = nil LB._turn = nil diff --git a/src/core/game3/link/init.lua b/src/core/game3/link/init.lua index b70c9711..e37c920f 100644 --- a/src/core/game3/link/init.lua +++ b/src/core/game3/link/init.lua @@ -622,13 +622,21 @@ function Link.adapterConnected() return false end +function Link.avatarStyle(version, trainerId) + if Family.isRubySapphire(version) then return "player" end + -- pokefirered/src/union_room_player_avatar.c:129 + return "g3:" .. ((tonumber(trainerId) or 0) % 8) +end + function Link.avatar() local s = Link.session() or {} + local trainerId = (tonumber(s.trainerId or s.id) or 0) % 65536 return { name = tostring(s.name or s.playerName or ""):sub(1, 7), - trainerId = (tonumber(s.trainerId or s.id) or 0) % 65536, + trainerId = trainerId, gender = (s.gender == 1 or s.gender == "female") and 1 or 0, version = Link.version(), + style = Link.avatarStyle(Link.version(), trainerId), -- pokeemerald/src/link_rfu_3.c:679 canLinkNationally = Family.canLinkNationally(s, Link.version()) and true or false, } diff --git a/src/core/game3/link/union_plaza_map.lua b/src/core/game3/link/union_plaza_map.lua index b31cc4ae..c3b71f41 100644 --- a/src/core/game3/link/union_plaza_map.lua +++ b/src/core/game3/link/union_plaza_map.lua @@ -1,10 +1,34 @@ local Family = require("src.core.game3.link.family") +local function rsActive() + return Family.isRubySapphire(Family.activeVersion()) +end + +local function enginePrefix() + local row = require("src.core.game3.profile").of(Family.activeVersion()) + return row and row.map and row.map.enginePrefix or "FR_" +end + local Plaza = setmetatable({}, { - __index = function(_, k) - if k == "SOURCE_ID" then return Family.mapId(nil, "unionRoom") end - if k == "MAP_ID" then return Family.mapId(nil, "unionRoom") .. "_PLAZA" end - if k == "EXITS" then local t = rawget(_, "FAMILY_EXITS"); return t[Family.of()] or t.frlg end + __index = function(t, k) + if k == "KIND" then return rsActive() and "rs" or "cart" end + if k == "SOURCE_ID" then + if rsActive() then return nil end + return Family.mapId(nil, "unionRoom") + end + if k == "TILE_SOURCE_ID" then + if rsActive() then return Family.mapId(nil, "recordCorner") end + return Family.mapId(nil, "unionRoom") + end + if k == "MAP_ID" then + if rsActive() then return enginePrefix() .. "UNION_ROOM" end + return Family.mapId(nil, "unionRoom") .. "_PLAZA" + end + if k == "EXITS" then + if rsActive() then return rawget(t, "RS").EXITS end + local ex = rawget(t, "FAMILY_EXITS") + return ex[Family.of()] or ex.frlg + end return nil end, }) @@ -69,6 +93,57 @@ Plaza.BLOCKED = { ["#"] = true, } +Plaza.RS = { + -- pokeruby/data/maps/RecordCorner/map.json warp_events + EXITS = { { x = 11, y = 24, source = 1 }, { x = 12, y = 24, source = 2 }, { x = 13, y = 24, source = 3 } }, + ROLES = { + "ABTTTTTTTTTEFFGTTTTTTTTHI", + "abttttttttteffgtttttttthi", + "JKLLLLLLLLLMNNOLLLLLLLLPQ", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "RS.....................UV", + "WX.....................YZ", + "###########ppp###########", + }, + -- pokeruby/data/layouts/RecordCorner/map.bin + SOURCE = { + A = { 3, 0 }, B = { 4, 0 }, T = { 5, 0 }, E = { 8, 0 }, F = { 9, 0 }, G = { 11, 0 }, H = { 15, 0 }, I = { 16, 0 }, + a = { 3, 1 }, b = { 4, 1 }, t = { 5, 1 }, e = { 8, 1 }, f = { 9, 1 }, g = { 11, 1 }, h = { 15, 1 }, i = { 16, 1 }, + J = { 3, 2 }, K = { 4, 2 }, L = { 5, 2 }, M = { 8, 2 }, N = { 9, 2 }, O = { 11, 2 }, P = { 15, 2 }, Q = { 16, 2 }, + R = { 3, 3 }, S = { 4, 3 }, ["."] = { 5, 3 }, U = { 15, 3 }, V = { 16, 3 }, + W = { 3, 8 }, X = { 4, 8 }, Y = { 15, 8 }, Z = { 16, 8 }, + ["#"] = { 0, 9 }, p = { 8, 9 }, + }, + BLOCKED = { + A = true, B = true, T = true, E = true, F = true, G = true, H = true, I = true, + a = true, b = true, t = true, e = true, f = true, g = true, h = true, i = true, + J = true, Q = true, R = true, V = true, W = true, Z = true, ["#"] = true, + }, +} + +function Plaza.tables(kind) + if (kind or Plaza.KIND) == "rs" then return Plaza.RS.ROLES, Plaza.RS.SOURCE, Plaza.RS.BLOCKED end + return Plaza.ROLES, Plaza.SOURCE, Plaza.BLOCKED +end + local CELL_XS = { 3, 6, 9, 12, 15, 18, 21 } local CELL_YS = { 5, 8, 11, 14, 17, 20 } local DOOR_X, DOOR_Y = 12, 23 @@ -114,8 +189,9 @@ function Plaza.entry() return Plaza.EXIT_X, Plaza.EXIT_Y, "up" end -function Plaza.roleAt(x, y) - local row = Plaza.ROLES[y + 1] +function Plaza.roleAt(x, y, kind) + local roles = Plaza.tables(kind) + local row = roles[y + 1] if not row or x < 0 or x >= #row then return nil end return row:sub(x + 1, x + 1) end @@ -126,13 +202,14 @@ local function copyRow(row) return out end -function Plaza.buildDecoded(src) +function Plaza.buildDecoded(src, kind) + local _, source = Plaza.tables(kind) local cellsOut = {} for y = 0, Plaza.HEIGHT - 1 do for x = 0, Plaza.WIDTH - 1 do - local s = Plaza.SOURCE[Plaza.roleAt(x, y)] + local s = source[Plaza.roleAt(x, y, kind)] if s[1] >= (src.trueWidth or src.width or 0) or s[2] >= (src.trueHeight or src.height or 0) then - error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.SOURCE_ID, s[1], s[2])) + error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.TILE_SOURCE_ID, s[1], s[2])) end local c = src:cellAt(s[1], s[2]) cellsOut[y * Plaza.WIDTH + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev } @@ -172,31 +249,34 @@ end function Plaza.ensure(game) local maps = game and game.data and game.data.maps if type(maps) ~= "table" then error("union plaza: no map table to build " .. Plaza.MAP_ID .. " into", 2) end - local srcDef = maps[Plaza.SOURCE_ID] + local kind = Plaza.KIND + local sourceId = Plaza.TILE_SOURCE_ID + local srcDef = maps[sourceId] if not srcDef then - error("union plaza: " .. Plaza.SOURCE_ID .. " is missing from the cache; re-import the ROM", 2) + error("union plaza: " .. sourceId .. " is missing from the cache; re-import the ROM", 2) end if not srcDef.midLayout then local okM, Map = pcall(require, "src.core.game3.map") - if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, Plaza.SOURCE_ID, srcDef) end + if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, sourceId, srcDef) end end local src = srcDef.midLayout if not src then - error("union plaza: " .. Plaza.SOURCE_ID .. " has no layout in the cache; re-import the ROM", 2) + error("union plaza: " .. sourceId .. " has no layout in the cache; re-import the ROM", 2) end - local def = maps[Plaza.MAP_ID] + local mapId = Plaza.MAP_ID + local def = maps[mapId] if not (def and def._plazaSource == src) then local LayoutNative = require("src.core.game3.layout_native") local pair = src.pair or srcDef.pair def = {} for k, v in pairs(srcDef) do def[k] = v end - def.id = Plaza.MAP_ID - def.name = Plaza.MAP_ID + def.id = mapId + def.name = mapId def.width = Plaza.WIDTH def.height = Plaza.HEIGHT def.connections = {} - def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src), Plaza.MAP_ID, pair) + def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src, kind), mapId, pair) def.pair = pair def.warps = {} for i, exit in ipairs(Plaza.EXITS) do @@ -205,24 +285,30 @@ function Plaza.ensure(game) warp.x, warp.y = exit.x, exit.y def.warps[i] = warp end - def.objects = nil + def.objects = kind == "rs" and {} or nil def.objectEvents = nil def.bgEvents = {} def.coordEvents = {} + if kind == "rs" then def.mapScripts = nil end def._plazaSource = src - maps[Plaza.MAP_ID] = def + maps[mapId] = def + end + + if kind == "rs" then + require("src.core.game3.rse.union_rs").install(game) + return def end local Space = package.loaded["src.core.game3.scripting.space"] local bundle = Space and Space.bundle local events = bundle and bundle.events - if type(events) == "table" and events[Plaza.SOURCE_ID] - and events[Plaza.MAP_ID] == nil then - events[Plaza.MAP_ID] = buildEvents(events[Plaza.SOURCE_ID]) + if type(events) == "table" and events[sourceId] + and events[mapId] == nil then + events[mapId] = buildEvents(events[sourceId]) end - local ev = events and events[Plaza.MAP_ID] + local ev = events and events[mapId] if ev and def.objects == nil and Space.attachEventsToMaps then - Space.attachEventsToMaps({ [Plaza.MAP_ID] = def }, bundle) + Space.attachEventsToMaps({ [mapId] = def }, bundle) end return def end diff --git a/src/core/game3/link/union_room.lua b/src/core/game3/link/union_room.lua index 0f2de809..468cc5df 100644 --- a/src/core/game3/link/union_room.lua +++ b/src/core/game3/link/union_room.lua @@ -215,9 +215,14 @@ end Union.plazaMap = plazaMap +function Union.isRs() + return Family.isRubySapphire(Family.activeVersion()) +end + function Union.isUnionMap(id) - if not Family.hasWireless() then return false end if type(id) ~= "string" then return false end + if Union.isRs() then return id == plazaMap().MAP_ID end + if not Family.hasWireless() then return false end return id == Union.MAP or id == plazaMap().MAP_ID end @@ -271,6 +276,28 @@ function Union.graphicsIdFor(gender, trainerId) return row[((tonumber(trainerId) or 0) % 8) + 1] end +function Union.gfxFor(p) + if Union.isRs() then + -- pokeruby/src/overworld.c:2726 + return (Family.linkPlayerGfx(nil, p.game, p.gender)) + end + return Union.graphicsIdFor(p.gender, p.trainerId) +end + +function Union.foreignEntry(p) + local f = Union.foreignAvatar(p) + return require("src.online.union.Avatars").resolve(f, f.host) +end + +function Union.isForeign(p) + return type(p) == "table" and (p.sourceGen == 1 or p.sourceGen == 2) +end + +function Union.foreignAvatar(p) + return { game = p.game, gen = p.sourceGen, gender = p.gender, style = p.style, + trainerId = p.trainerId, name = p.name, id = p.id, host = { version = Family.activeVersion() } } +end + -- pokefirered/src/union_room_player_avatar.c:448 function Union.memberFacing(member, activity) if (member or 0) ~= 0 then return Union.avatarData().member_facing[member + 1] end @@ -362,7 +389,13 @@ function Union.showAvatar(slot, p) v.y2 = -Union.FLY_HEIGHT end v.waiting = nil - v.gfx = Union.graphicsIdFor(p.gender, p.trainerId) + if Union.isForeign(p) then + v.foreign = Union.foreignAvatar(p) + v.gfx = nil + else + v.foreign = nil + v.gfx = Union.gfxFor(p) + end v.x, v.y = x, y if Union._talkSlot ~= slot then v.dir = Union.cellFacing(slot, p) end Union._vobjDirty = true @@ -539,6 +572,7 @@ function Union.animateVobjs() if rec then local w = v.wander rec.graphicsId = v.gfx + rec.foreign = v.foreign 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 @@ -593,7 +627,11 @@ function Union.tryInteractWithMember() local p = Union.players[slot] local v = Union.vobj(slot) if not (v and v.visible and v.anim == nil and p and not p.gone) then return nil end - v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] + if Union.isRs() then + v.dir = Union.FACE_DIR[wanderMod().OPPOSITE[P.facing or "down"]] or Union.DIR.SOUTH + else + v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] + end Union._talkSlot = slot Union._vobjDirty = true return slot, 0 @@ -711,6 +749,7 @@ function Union.run(ctx, adapters) Union._plazaInstance, Union._plazaRev = nil, nil Union._offlineSince, Union._offlineWiped = nil, nil Union._upgradeShown = nil + Union._serverOutdated = nil Union.flow = nil Union.memberIds = {} Union.partnerId = nil @@ -720,10 +759,15 @@ function Union.run(ctx, adapters) Union.invite = nil Union.incoming = nil Union._await = nil + Union._xg = nil + Union._xgSession = nil Union.state = "init" local L = link() Union.relay = true - L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar(), plazaMap().CAP) + Union.hookJoinErrors() + local Participant = require("src.online.union.Participant") + L.clientCall("joinPlaza", "union", L.liveProfile(), Participant.wireAvatar(L.avatar()), plazaMap().CAP, + { xgen = require("src.online.Protocol2").XGEN, caps = Union.caps() }) L.setStatus("idle") Union._refresh = 0 Union._synced = false @@ -732,9 +776,40 @@ function Union.run(ctx, adapters) return false end +function Union.caps() + local L = link() + local game = L.game() + local memo = Union._capsMemo + if memo and memo.game == game and memo.version == L.version() then return memo.caps end + local Caps = require("src.online.union.Caps") + local ok, caps = pcall(Caps.compute, { version = L.version(), game = game }) + if not ok or type(caps) ~= "table" then caps = Caps.compute({ version = L.version(), gameplayMods = true }) end + Union._capsMemo = { game = game, version = L.version(), caps = caps } + return caps +end + +Union.JOIN_ERRORS = { bad_profile = true, bad_avatar = true, bad_caps = true } + +function Union.hookJoinErrors() + if Union._onJoinError then return end + Union._onJoinError = function(e) + if type(e) ~= "table" or e.scope ~= "join" or not Union.JOIN_ERRORS[e.reason] then return end + if Union.state ~= "off" then Union._serverOutdated = e.reason end + end + link().clientCall("on", "error", Union._onJoinError) +end + +function Union.unhookJoinErrors() + if not Union._onJoinError then return end + link().clientCall("off", "error", Union._onJoinError) + Union._onJoinError = nil +end + function Union.stop(reason) if Union.state == "off" then return false end local L = link() + if Union._xgSession then Union.endXg(Union._xgSession, "left") end + Union.unhookJoinErrors() if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end L.clientCall("leavePlaza", "union") L.setStatus("busy") @@ -798,6 +873,13 @@ function Union.cancelActivity() return true end +local function pressedB() + local game = link().game() + local input = game and game.input + if not (input and input.wasPressed) then return false end + return input:wasPressed("b") and true or false +end + local function pressedA() local game = link().game() local input = game and game.input @@ -1379,6 +1461,7 @@ function Union.answerRequest(accept) Union.beginAwaitRoom(nil) else Union.activity = nil + if shown and Union._xg then return Union.printAndExit(Union.xgText("you_declined")) end if shown then -- pokefirered/src/union_room.c:887 local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD) @@ -1554,6 +1637,8 @@ function Union.activityForInvite(wire, detail) if wire == "battle_double" then return A.BATTLE_DOUBLE end if wire == "chat" then return A.CHAT + U end if wire == "card" then return A.CARD end + if wire == "xg_battle" then return A.BATTLE_SINGLE + U end + if wire == "xg_trade" then return A.TRADE + U end if wire == "trade" then if type(detail) == "table" and type(detail.board) == "table" then return A.TRADE + U end return A.TRADE @@ -1606,14 +1691,21 @@ end local function avatarRow(m) local av = type(m.avatar) == "table" and m.avatar or {} + local part = require("src.online.union.Participant").fromMember(m) return { id = m.id, name = av.name or m.name, gender = tonumber(av.gender) or 0, trainerId = tonumber(av.trainerId) or 0, + game = part and part.game or av.version, + sourceGen = part and part.gen or 3, + legacy = part == nil or part.legacy, + style = part and part.style or nil, } end +Union.avatarRow = avatarRow + local NO_MEMBERS = {} -- pokefirered/src/union_room.c:2781 @@ -1638,6 +1730,10 @@ function Union.syncPlaza() for i = 1, #members do local m = members[i] local slot = type(m) == "table" and tonumber(m.slot) or nil + if slot and m.id ~= nil and (m.id == me or slot == mySlot) then + local av = type(m.avatar) == "table" and m.avatar or nil + if not (av and av.gen ~= nil) and not Union._serverOutdated then Union._serverOutdated = "legacy_shard" end + end if slot and slot >= 1 and slot <= cap and m.id ~= nil and m.id ~= me and slot ~= mySlot then local p = Union.players[slot] if p and p.id ~= m.id then @@ -1687,12 +1783,17 @@ end function Union.checkUpgrade() if Union._upgradeShown or Union.state ~= "main" or Union.flow then return end local up = link().clientCall("upgradeRequired") - if not up then return end + local text + if up then + text = require("src.online.Protocol2").upgradeText(up) + elseif Union._serverOutdated then + text = Union.xgText("server_outdated") + else + return + end Union._upgradeShown = true local M = message() - if M and M.show then - Union.printAndExit(require("src.online.Protocol2").upgradeText(up)) - end + if M and M.show then Union.printAndExit(text) end end -- pokefirered/src/union_room.c:3106 @@ -1704,13 +1805,19 @@ function Union.pollIncoming() if id ~= nil and not Union._answered[id] then Union._answered[id] = true local activity = Union.activityForInvite(inv.activity, inv.detail) + local xg = require("src.online.Protocol2").XG_ACTIVITIES[inv.activity or ""] and inv.activity or nil + if Union.isRs() and not xg then activity = nil end if Union.state == "main" and not Union.incoming and activity then local from = type(inv.from) == "table" and inv.from or {} Union.incoming = inv Union._lastIncoming = inv Union.activity = activity + Union._xg = xg Union._requestName = type(from.avatar) == "table" and from.avatar.name or from.name Union.partnerId = Union.slotForId(from.id) + if xg and not Union.partnerId then + Union._partner = avatarRow({ id = from.id, name = from.name, slot = 1, avatar = from.avatar }) + end playSe("SE_DING_DONG") Union.state = "player_contacted_you" else @@ -1732,6 +1839,7 @@ function Union.relayTick(_dt) Union._offlineSince, Union._offlineWiped = nil, nil Union.syncPlaza() Union.animateAll() + if Union._serverOutdated and not Union._upgradeShown then Union.checkUpgrade() end Union.pollIncoming() if Union.state == "main" and L.link then Union.noteLeftRoom() @@ -1847,6 +1955,7 @@ end function Union.toMain() Union.flow = nil Union.activity = nil + Union._xg = nil Union.state = "main" if Union._talkSlot then Union.updateMemberFacing(Union._talkSlot) end Union.releaseScript() @@ -1991,6 +2100,8 @@ end -- pokefirered/src/union_room.c:2805 function Union.talkTo(slot) local p = Union.players[slot] + if p and not p.gone and Union.usesXg(p) then return Union.xgTalk(slot) end + if Union.isRs() then return Union.printAndExit(Union.xgText("busy", p), slot) end if not p or p.gone then return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot) end @@ -2013,6 +2124,296 @@ function Union.talkTo(slot) return Union.printAndExit(Union.reactionText(p.activity, g, p.name), slot) end +Union.XG_ITEMS = { + { key = "XG_BATTLE", wire = "xg_battle", activity = "BATTLE_SINGLE" }, + { key = "XG_TRADE", wire = "xg_trade", activity = "TRADE" }, + { key = "EXIT" }, +} + +function Union.gameName(version) + local info = require("src.core.GameVersion").VERSIONS[version or ""] + return info and tostring(info.label or version):upper() or "" +end + +local function nameList(versions) + local names = {} + for _, v in ipairs(versions or {}) do names[#names + 1] = Union.gameName(v) end + local Strings = require("src.core.Strings") + if #names <= 1 then return names[1] or "" end + return Strings("%s or %s", table.concat(names, ", ", 1, #names - 1), names[#names]) +end + +function Union.xgText(key, p, extra) + local Strings = require("src.core.Strings") + p = type(p) == "table" and p or {} + local name, game = tostring(p.name or ""), Union.gameName(p.game) + if key == "talk" then return Strings("%s from POKéMON %s is here.\nWhat would you like to do?", name, game) end + if key == "standin" then + return Strings("Import POKéMON %s to see how %s really looks.", nameList(extra), name) + end + if key == "wait_battle" then return Strings("Waiting for %s to answer\nthe battle request...", name) end + if key == "wait_trade" then return Strings("Waiting for %s to answer\nthe trade request...", name) end + if key == "declined_battle" then return Strings("%s turned down the battle.", name) end + if key == "declined_trade" then return Strings("%s turned down the trade.", name) end + if key == "busy" then return Strings("%s seems to be busy right now.", name) end + if key == "link_lost" then return Strings("The link with %s was lost.", name) end + if key == "ask_battle" then return Strings("%s from POKéMON %s wants to battle!\nWill you accept?", name, game) end + if key == "ask_trade" then return Strings("%s from POKéMON %s wants to trade!\nWill you accept?", name, game) end + if key == "you_declined" then return Strings("You turned down the request.") end + if key == "preparing_battle" then return Strings("Getting ready to battle %s...", name) end + if key == "preparing_trade" then return Strings("Getting ready to trade with %s...", name) end + if key == "unavailable" then + return Strings("Battles and trades between different games aren't open yet. The request was canceled.") + end + if key == "peer_canceled" then return Strings("%s canceled the request.", name) end + if key == "canceled" then return Strings("The request was canceled.") end + if key == "blocked" then return Strings("Your games can't link up for this. The request was canceled.") end + if key == "server_outdated" then + return Strings("The UNION ROOM server needs an update before trainers from other games can join you.") + end + if key == "goodbye" then return Strings("See you around!") end + if key == "label_battle" then return Strings("BATTLE") end + if key == "label_trade" then return Strings("TRADE") end + if key == "label_exit" then return Strings("EXIT") end + return "" +end + +function Union.xgItems() + local s = screen() + local out = {} + for i, item in ipairs(Union.XG_ITEMS) do + local label + if item.wire == "xg_trade" then + label = Union.xgText("label_trade") + elseif not Union.isRs() and s and s.labelFor then + label = s.labelFor({ key = item.wire and "BATTLE" or "EXIT" }) + else + label = Union.xgText(item.wire and "label_battle" or "label_exit") + end + out[i] = { key = item.key, wire = item.wire, activity = item.activity, label = label } + end + return out +end + +function Union.usesXg(p) + if type(p) ~= "table" then return false end + if Union.isRs() then return true end + return Union.isForeign(p) or Family.isRubySapphire(p.game) +end + +function Union.xgTalk(slot) + local p = Union.players[slot] + Union.partnerId = slot + Union._partner = p + if not Union.memberIdle(p) then return Union.printAndExit(Union.xgText("busy", p), slot) end + local steps = {} + if Union.isForeign(p) then + local entry = Union.foreignEntry(p) + if entry.standin or entry.hostStandin then steps[#steps + 1] = Union.sayStep(Union.xgText("standin", p, entry.need)) end + end + steps[#steps + 1] = Union.stayStep(Union.xgText("talk", p)) + steps[#steps + 1] = Union.doStep(function() + local s = screen() + if not s then return Union.toMain() end + Union.state = "handle_do_something_prompt_input" + s.showActivities(Union.xgItems(), { + partner = p, + onChoose = function(index) Union.chooseXg(index) end, + onCancel = function() Union.chooseXg(#Union.XG_ITEMS) end, + }) + end) + return Union.runFlow(steps, "do_something_prompt") +end + +function Union.chooseXg(index) + local item = Union.XG_ITEMS[tonumber(index) or 0] + local p = Union.partnerRow() or {} + local M = message() + if not (item and item.wire) then + Union.activity = nil + Union.partnerId = nil + if Union.isRs() then return Union.printAndExit(Union.xgText("goodbye", p)) end + -- pokefirered/src/union_room.c:2916 + local text = RomText.ascii(RomText.key("gTexts_UR_IfYouWantToDoSomething", tonumber(p.gender) == 1 and 1 or 0)) + return Union.printAndExit(text) + end + local L = link() + Union.activity = Union.ACTIVITY[item.activity] + Union.IN_UNION_ROOM + Union._xg = item.wire + Union._role = "child" + Union.invite = p.id ~= nil and L.clientCall("invite", p.id, item.wire, {}, L.liveProfile()) or nil + if not Union.invite then return Union.printAndExit(Union.xgText("busy", p)) end + Union.state = "send_activity_request" + if M and M.show then + M.show(Union.xgText(item.wire == "xg_trade" and "wait_trade" or "wait_battle", p), { stay = true }) + end + return true +end + +function Union.xgMode() + return require("src.online.Protocol2").XG_ACTIVITIES[Union._xg or ""] +end + +Union.xgScreens = { battle = nil, trade = nil } + +local XgActivity = {} +XgActivity.__index = XgActivity +Union.XgActivity = XgActivity + +local function xgPeer(roomInfo, mySeat, partner) + for _, row in ipairs(type(roomInfo.players) == "table" and roomInfo.players or {}) do + if type(row) == "table" and row.seat ~= nil and row.seat ~= mySeat then + local av = type(row.avatar) == "table" and row.avatar or {} + return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat } + end + end + partner = type(partner) == "table" and partner or {} + return { id = partner.id, name = partner.name or "?", gen = partner.sourceGen, game = partner.game } +end + +function XgActivity:release() + if Union._xgSession == self then Union._xgSession = nil end + Union.noteLeftRoom(self.roomId) + link().setStatus("idle") +end + +function XgActivity:finish(why, text) + if self.done or self.state == "done" then return end + local Flow = require("src.ui.union.Flow") + if why == "go" and not self.launched then + self.state = "battle" + if Flow.launch(self, 3) then return end + why, text = "error", require("src.ui.g3u.Launch").resultText(3, "error") + end + self.why = why + self.state = "done" + Flow.leaveRoom(self) + self:release() + local M = message() + if M and M.isOpen and M.isOpen() then M.close() end + Union.partnerId = nil + self.done = true + if text and M and M.show then return Union.printAndExit(text) end + Union.toMain() +end + +function XgActivity:cancel(why) + local prep = self.prep + if prep then + if prep:open() then prep:cancel(why or "cancel") end + prep:leave() + end + self:finish(why or "cancel", Union.xgText("canceled", self.peer)) +end + +function XgActivity:abort(why) + if self.done then return end + local prep = self.prep + if prep and prep:open() then + prep:cancel(why or "left") + prep:leave() + end + self.state = "done" + self.why = why or "left" + self.done = true + self:release() +end + +function XgActivity:handle(e) + if e.kind == "closed" then + local mine = e.seat ~= nil and e.seat == self.prep:seat() + if mine then return self:finish("closed", Union.xgText("canceled", self.peer)) or true end + playSe("SE_FAILURE") + self:finish("peer", Union.xgText("peer_canceled", self.peer)) + return true + end + if e.kind == "blocked" then + self.prep:cancel("blocked") + self.prep:leave() + self:finish("blocked", Union.xgText("blocked", self.peer)) + return true + end + return false +end + +function XgActivity:tick(dt) + if self.done or self.state == "done" or self.state == "screen" or self.state == "battle" then return end + local prep = self.prep + if not prep then return self:finish("gone", Union.xgText("link_lost", self.peer)) end + for _, e in ipairs(prep:poll()) do + if self:handle(e) then return end + end + local screen = Union.xgScreens[self.mode] + if not screen then + self.t = (self.t or 0) + (tonumber(dt) or 0) + if self.t >= Union.XG_NOTICE_SECONDS then + if prep:open() then prep:cancel("unavailable") end + prep:leave() + return self:finish("unavailable", Union.xgText("unavailable", self.peer)) + end + return + end + if prep.state == "prep" and self.state == "preparing" then + self.state = "screen" + local M = message() + if M and M.isOpen and M.isOpen() then M.close() end + screen(self) + return + end + if pressedB() then self:cancel("cancel") end +end + +Union.XG_NOTICE_SECONDS = 1 + +function Union.beginXg(roomInfo, mode) + local L = link() + if not Union.xgInstalled then + Union.xgInstalled = true + require("src.ui.union.Flow").install({ screens = Union.xgScreens }) + end + Union._await = nil + roomInfo = type(roomInfo) == "table" and roomInfo or {} + mode = mode or roomInfo.mode or (type(roomInfo.xg) == "table" and roomInfo.xg.mode) or "battle" + mode = mode == "trade" and "trade" or "battle" + local client = L.client() + local prep + if client and roomInfo.room ~= nil then + local Room = require("src.online.union.Room") + prep = require("src.online.union.Prep").new(Room.adapter(client, roomInfo.room), { mode = mode }) + end + local mySeat = client and client.seat and client.seat() or nil + local act = setmetatable({ + game = L.game(), room = roomInfo, roomId = roomInfo.room, mode = mode, prep = prep, + peer = xgPeer(roomInfo, mySeat, Union.partnerRow()), state = "preparing", done = false, why = nil, + role = Union._role, wire = Union._xg, + }, XgActivity) + Union._xgSession = act + Union.state = "xg_prep" + local M = message() + if M and M.show then + M.show(Union.xgText(mode == "trade" and "preparing_trade" or "preparing_battle", act.peer), { stay = true }) + end + return act +end + +function Union.endXg(act, why) + act = act or Union._xgSession + if not act then return false end + act:abort(why) + return true +end + +function Union.xgFail(key) + local p = Union.partnerRow() or {} + Union._await = nil + Union.invite = nil + local M = message() + if M and M.isOpen and M.isOpen() then M.close() end + if M and M.show then return Union.printAndExit(Union.xgText(key, p)) end + Union.state = "print_and_exit" +end + + -- pokefirered/src/union_room.c:3027 function Union.joinChat(slot) local L = link() @@ -2059,6 +2460,15 @@ end function Union.pollMain(ctx) local L = link() + if Union.isRs() then + local Hud = package.loaded["src.ui.game3.hud"] + if Hud and Hud.busy and Hud.busy() then return end + if not pressedA() then return end + local slot = Union.tryInteractWithMember() + if not slot then return end + playSe("SE_SELECT") + return Union.talkTo(slot) + end Union.armScriptWait(ctx) local result = Union._scriptResult or L.getVar(ctx, L.VAR_RESULT) Union._scriptResult = nil @@ -2108,6 +2518,13 @@ function Union.contactedYou() local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM Union.state = "handle_activity_request" Union._asked = true + if Union._xg then + local p = Union.partnerRow() or { name = name } + return Union.runFlow(stepsOf( + Union.stayStep(Union.xgText(Union._xg == "xg_trade" and "ask_trade" or "ask_battle", p)), + Union.yesNoStep(function(yes) Union.answerRequest(yes) end) + ), "handle_activity_request") + end local named = Union.namedRequest(name) local steps if named then @@ -2189,6 +2606,8 @@ function Union.relayUpdate(dt, ctx) Union.startActivity() elseif st == "in_activity" then Union.pollInActivity() + elseif st == "xg_prep" then + if Union._xgSession then Union._xgSession:tick(dt) else Union.toMain() end elseif st == "print_and_exit" then local Mo = message() if not (Mo and Mo.isOpen and Mo.isOpen()) then Union.toMain() end @@ -2259,6 +2678,13 @@ function Union.pollInvite() local M = message() local text local p = Union.partnerRow() + if Union._xg then + Union._joining = nil + Union.lastResult = (not dropped and h.why == "declined") and "declined" or "busy" + if dropped then return Union.xgFail("link_lost") end + if h.why == "declined" then return Union.xgFail(Union._xg == "xg_trade" and "declined_trade" or "declined_battle") end + return Union.xgFail("busy") + end if dropped then Union._joining = nil Union.lastResult = "busy" @@ -2308,6 +2734,13 @@ function Union.pollAwaitRoom(dt) local room = L.clientCall("room") local id = type(room) == "table" and room.room or nil local stale = id ~= nil and Union._leftRooms and Union._leftRooms[id] + if Union._xg then + if room and not stale and room.intent == "xg" and (a.room == nil or id == a.room) then + return Union.beginXg(room, Union.xgMode()) + end + if not L.online() or a.t >= Union.AWAIT_ROOM_SECONDS then return Union.xgFail("busy") end + return + end if room and not stale and (a.room == nil or id == a.room) and Union.matchStarted(room) then local M = message() if M and M.isOpen and M.isOpen() then M.close() end @@ -3408,6 +3841,10 @@ function Union.reset() Union._held = nil Union._scriptResult = nil Union._leftRooms = nil + Union._xg = nil + Union._xgSession = nil + Union._serverOutdated = nil + Union._capsMemo = nil local Screen = package.loaded["src.ui.game3.union_room"] if Screen and Screen.reset then Screen.reset() end local LinkMenu = package.loaded["src.ui.game3.link_menu"] diff --git a/src/core/game3/link/union_save_spot.lua b/src/core/game3/link/union_save_spot.lua new file mode 100644 index 00000000..880b8dc2 --- /dev/null +++ b/src/core/game3/link/union_save_spot.lua @@ -0,0 +1,175 @@ +local Spot = {} + +local DOOR = { x = 2, y = 1 } +-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:63 +local BAY_DOOR = { x = 5, y = 1 } + +local function profileOf(version) + return require("src.core.game3.profile").of(version) +end + +local function isRs(version) + return require("src.core.game3.link.family").isRubySapphire(version) +end + +function Spot.prefix(version) + local row = profileOf(version) + return row and row.map and row.map.enginePrefix or "FR_" +end + +function Spot.roomId(version) + if isRs(version) then return Spot.prefix(version) .. "UNION_ROOM" end + return require("src.core.game3.link.family").mapId(version, "unionRoom") .. "_PLAZA" +end + +function Spot.colosseumId(version) + return require("src.core.game3.link.family").mapId(version, "colosseum2P") +end + +function Spot.nurseGfx(version) + return require("src.core.game3.constants").of(version):require("event_objects", "OBJ_EVENT_GFX_NURSE") +end + +function Spot.isRsCenter(def, colosseum) + for _, w in ipairs(type(def) == "table" and def.warps or {}) do + if tonumber(w.x) == BAY_DOOR.x and tonumber(w.y) == BAY_DOOR.y and w.destMap == colosseum then return true end + end + return false +end + +function Spot.isAdded(version, lookup, map, x, y) + if type(map) ~= "string" then return false end + if map == Spot.roomId(version) then return true end + if not isRs(version) or tonumber(x) ~= DOOR.x or tonumber(y) ~= DOOR.y then return false end + return Spot.isRsCenter(lookup(map), Spot.colosseumId(version)) +end + +-- pokefirered/data/maps/ViridianCity_PokemonCenter_1F/map.json:21 +function Spot.nurseFront(def, nurse) + if type(def) ~= "table" or type(def.collAt) ~= "function" then return nil end + for _, o in ipairs(def.objects or {}) do + if tonumber(o.graphicsId or o.graphics) == nurse then + local x, y = tonumber(o.x), tonumber(o.y) + for dy = 1, 3 do + if def.collAt(x, y + dy) == 0 then return x, y + dy end + end + end + end + return nil +end + +function Spot.oneFOf(lookup, twoF, nurse) + local def = lookup(twoF) + for _, w in ipairs(type(def) == "table" and def.warps or {}) do + local dest = type(w.destMap) == "string" and lookup(w.destMap) or nil + if dest and Spot.nurseFront(dest, nurse) then + for _, back in ipairs(dest.warps or {}) do + if back.destMap == twoF then return w.destMap end + end + end + end + return nil +end + +function Spot.resolve(version, lookup, save) + if type(save) ~= "table" or not Spot.isAdded(version, lookup, save.map, save.x, save.y) then return nil end + local nurse = Spot.nurseGfx(version) + local tries = {} + if save.map ~= Spot.roomId(version) then tries[#tries + 1] = save.map end + local dw = save.dynamicWarp + if type(dw) == "table" and type(dw.map) == "string" then tries[#tries + 1] = dw.map end + for _, twoF in ipairs(tries) do + local oneF = Spot.oneFOf(lookup, twoF, nurse) + local x, y + if oneF then x, y = Spot.nurseFront(lookup(oneF), nurse) end + if x then return { map = oneF, x = x, y = y, facing = "up" } end + end + local heal = type(save.healMap) == "string" and save.healMap or nil + local x, y = Spot.nurseFront(heal and lookup(heal), nurse) + if x then return { map = heal, x = x, y = y, facing = "up" } end + return nil +end + +function Spot.liveLookup(game) + local maps = game and game.data and game.data.maps or {} + local memo = {} + return function(mapId) + if memo[mapId] ~= nil then return memo[mapId] or nil end + local def = maps[mapId] + if type(def) ~= "table" then + memo[mapId] = false + return nil + end + if not def.midLayout then + local okM, Map = pcall(require, "src.core.game3.map") + if okM and Map and Map.ensureMidLayout then pcall(Map.ensureMidLayout, game, mapId, def) end + end + local objects = def.objects + if objects == nil then + local Space = package.loaded["src.core.game3.scripting.space"] + local ev = Space and Space.bundle and Space.bundle.events and Space.bundle.events[mapId] + objects = ev and (ev.objects or ev.objectEvents) or {} + end + local L = def.midLayout + local row = { + warps = def.warps or {}, objects = objects, + collAt = L and function(x, y) return L:collAt(x, y) end or nil, + } + memo[mapId] = row + return row + end +end + +function Spot.live(session, game) + if type(session) ~= "table" then return nil end + local version = session.version or require("src.core.game3.link.family").activeVersion() + if not game then + local rt = package.loaded["src.core.game3.runtime"] + game = rt and rt._game or nil + end + if not (game and game.data and game.data.maps) then return nil end + return Spot.resolve(version, Spot.liveLookup(game), session) +end + +local function u16(s, i) + local a, b = s:byte(i, i + 1) + if not b then return nil end + return a + b * 256 +end + +-- pokefirered/include/global.fieldmap.h:8 +local COLLISION_SHIFT, COLLISION_MASK = 10, 3 + +function Spot.cacheLookup(read, slotOf, mapOf) + local Json = require("src.link.Json") + local memo = {} + return function(mapId) + if memo[mapId] ~= nil then return memo[mapId] or nil end + memo[mapId] = false + local slot = slotOf(mapId) + local header = slot and read("map_tree/maps/" .. slot .. "/header.json") + local events = slot and read("map_tree/maps/" .. slot .. "/events.json") + local grid = slot and read("map_tree/maps/" .. slot .. "/grid.bin") + if not (header and events and grid) then return nil end + local okH, h = pcall(Json.decode, header) + local okE, e = pcall(Json.decode, events) + if not (okH and okE and type(h) == "table" and type(e) == "table") then return nil end + local w, hgt = tonumber(h.width) or 0, tonumber(h.height) or 0 + local warps = {} + for i, wp in ipairs(e.warps or {}) do + warps[i] = { x = wp.x, y = wp.y, destMap = mapOf(wp.mapGroup, wp.mapNum) } + end + local row = { + warps = warps, objects = e.objects or {}, + collAt = function(x, y) + if x < 0 or y < 0 or x >= w or y >= hgt then return 1 end + local v = u16(grid, (y * w + x) * 2 + 1) + return v and math.floor(v / 2 ^ COLLISION_SHIFT) % (COLLISION_MASK + 1) or 1 + end, + } + memo[mapId] = row + return row + end +end + +return Spot diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index cc2efa57..177641b1 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -947,12 +947,15 @@ function Objects.forDraw() vrecs[vo.id] = vrec end local gid = tonumber(vo.graphicsId) or 0 + local foreign = vo.foreign vrec.cellX = tonumber(vo.x) or 0 vrec.cellY = tonumber(vo.y) or 0 vrec.elevation = tonumber(vo.elevation) or 3 vrec.facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down" - vrec.sprite = GfxIds.spriteFor(gid) - vrec.graphicsId = gid + vrec.foreign = foreign + vrec.draw = foreign and Objects.drawForeign or nil + vrec.sprite = not foreign and GfxIds.spriteFor(gid) or nil + vrec.graphicsId = not foreign and gid or nil vrec.raiseY = tonumber(vo.y2) or 0 vrec.px, vrec.py, vrec.moving = vo.px, vo.py, vo.moving == true vrec.targetX, vrec.targetY = vo.targetX, vo.targetY @@ -967,6 +970,15 @@ function Objects.forDraw() return list end +function Objects.drawForeign(a, camX, camY) + local eo = a and a.eventObject + local p = eo and eo.foreign + if not p then return false end + local Avatars = require("src.online.union.Avatars") + return Avatars.draw(Avatars.resolve(p, p.host), math.floor(a.x - camX + CELL / 2), math.floor(a.y - camY + CELL), + a.facing, a.walkPhase, a.stepFlip, 1) +end + --- First visible EventObject standing on (tx, ty), or nil if moving onto it. function Objects.at(tx, ty) tx, ty = tonumber(tx), tonumber(ty) diff --git a/src/core/game3/profiles/emerald_rules.lua b/src/core/game3/profiles/emerald_rules.lua index bc64df93..34962c5d 100644 --- a/src/core/game3/profiles/emerald_rules.lua +++ b/src/core/game3/profiles/emerald_rules.lua @@ -131,6 +131,9 @@ function Rules.saveWarpFields(session) local f = tonumber(session.specialSaveWarpFlags) or 0 local w = session.continueGameWarp local dw = session.dynamicWarp + if require("src.core.game3.link.union_save_spot").live(session) then + return require("bit").band(f, require("bit").bnot(CONTINUE_GAME_WARP)), w + end if LINK_ROOMS[session.map] and type(dw) == "table" and type(dw.map) == "string" and tonumber(dw.x) and tonumber(dw.y) then -- pokeemerald/src/overworld.c:735 SetContinueGameWarpToDynamicWarp @@ -139,4 +142,10 @@ function Rules.saveWarpFields(session) return f, w end +function Rules.saveLocation(session, game) + local o = require("src.core.game3.link.union_save_spot").live(session, game) + if o then return o.map, o.x, o.y, o.facing end + return nil +end + return Rules diff --git a/src/core/game3/profiles/firered_rules.lua b/src/core/game3/profiles/firered_rules.lua index 26e87bf8..f8529543 100644 --- a/src/core/game3/profiles/firered_rules.lua +++ b/src/core/game3/profiles/firered_rules.lua @@ -98,6 +98,9 @@ function Rules.saveWarpFields(session) local f = tonumber(session.specialSaveWarpFlags) or 0 local w = session.continueGameWarp local dw = session.dynamicWarp + if require("src.core.game3.link.union_save_spot").live(session) then + return require("bit").band(f, require("bit").bnot(CONTINUE_GAME_WARP)), w + end if LINK_ROOMS[session.map] and type(dw) == "table" and type(dw.map) == "string" and tonumber(dw.x) and tonumber(dw.y) then -- pokefirered/src/overworld.c:701 SetContinueGameWarpToDynamicWarp @@ -106,6 +109,12 @@ function Rules.saveWarpFields(session) return f, w end +function Rules.saveLocation(session, game) + local o = require("src.core.game3.link.union_save_spot").live(session, game) + if o then return o.map, o.x, o.y, o.facing end + return nil +end + -- pokefirered/include/constants/region_map_sections.h:211 KANTO_MAPSEC_START Rules.OWN_MON_MET_LOCATION = 88 diff --git a/src/core/game3/profiles/rs.lua b/src/core/game3/profiles/rs.lua index 17423fba..4e0b8d61 100644 --- a/src/core/game3/profiles/rs.lua +++ b/src/core/game3/profiles/rs.lua @@ -14,7 +14,7 @@ function Rs.new(game, label, prefix) scriptConnections = true, scriptStepEvents = false, -- pokeruby/src/field_fadetransition.c:195 onFrameAfterWarpExit = true, - fieldModules = {}, + fieldModules = { unionPlaza = true }, -- pokeruby/src/overworld.c:496 escapeWarp = { delta = 1 }, semantics = { @@ -74,7 +74,7 @@ function Rs.new(game, label, prefix) daycare = require("src.core.game3.rs.daycare"), audio = require("src.core.game3.profiles.rs.audio").new(game), save = require("src.core.game3.profiles.rs.save"), - saveRules = require("src.core.game3.profiles.rs.saveRules"), + saveRules = "src.core.game3.rse.union_rs_rules", mail = { exportEmpty = true, newRecord = function(session, record) diff --git a/src/core/game3/profiles/rs/font.lua b/src/core/game3/profiles/rs/font.lua index a560ec75..360d39ef 100644 --- a/src/core/game3/profiles/rs/font.lua +++ b/src/core/game3/profiles/rs/font.lua @@ -1,8 +1,10 @@ local faces = {} for id = 0, 6 do faces["native_" .. id] = { id = id } end -- pokeruby/src/text.c:622 -faces.normal, faces.small = faces.native_3, faces.native_1 -faces.short, faces.narrow, faces.small_narrow = faces.native_0, faces.native_4, faces.native_1 +faces.normal = faces.native_3 +faces.short, faces.narrow = faces.native_0, faces.native_4 +-- pokeruby/src/contest_2.c:883 +faces.small, faces.small_narrow = faces.native_4, faces.native_4 return { module = "src.ui.game3.frlg_font", diff --git a/src/core/game3/rse/union_rs.lua b/src/core/game3/rse/union_rs.lua new file mode 100644 index 00000000..64afaf9c --- /dev/null +++ b/src/core/game3/rse/union_rs.lua @@ -0,0 +1,501 @@ +local Family = require("src.core.game3.link.family") + +local UnionRs = {} + +-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:63 +UnionRs.BAY_DOOR = { x = 5, y = 1 } +-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:16 +UnionRs.BAY_ATTENDANT = { x = 4, y = 2 } +UnionRs.DOOR = { x = 2, y = 1 } +UnionRs.ATTENDANT = { x = 1, y = 2 } +UnionRs.FRONT = { x = 2, y = 2 } +UnionRs.VOBJ_ID = 0x7D01 +UnionRs.MIN_MONS = 2 +UnionRs.LOCK = "union_rs" + +UnionRs.centers = {} +UnionRs.flow = nil +UnionRs._task = nil + +local DIR = { down = 1, up = 2, left = 3, right = 4 } +local OPPOSITE = { down = "up", up = "down", left = "right", right = "left" } +local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } + +local function Strings(...) return require("src.core.Strings")(...) end +local function link() return require("src.core.game3.link") end +local function message() return require("src.ui.game3.message") end +local function plaza() return require("src.core.game3.link.union_plaza_map") end + +function UnionRs.active() + return Family.isRubySapphire(Family.activeVersion()) +end + +function UnionRs.text(key) + if key == "welcome" then + return Strings("Welcome to the UNION ROOM!\fTrainers from every POKéMON game meet here to battle and trade.\fWould you like to go in?") + end + if key == "come_again" then return Strings("Please come again!") end + if key == "need_two" then return Strings("You need at least two POKéMON that aren't EGGS to go in.") end + if key == "connect" then return Strings("The UNION ROOM is online.\nWould you like to connect?") end + if key == "connecting" then return Strings("Connecting...") end + if key == "save_first" then return Strings("Your progress will be saved before you go in.") end + if key == "enjoy" then return Strings("Please enjoy the UNION ROOM!") end + return "" +end + +local function backLink(dest, mapId) + for _, w in ipairs(type(dest) == "table" and dest.warps or {}) do + if w.destMap == mapId then return true end + end + return false +end + +function UnionRs.discover(maps) + local colosseum = Family.mapId(nil, "colosseum2P") + local out = {} + for mapId, def in pairs(maps or {}) do + local warps = type(def) == "table" and def.warps or nil + local bay = false + for _, w in ipairs(warps or {}) do + if tonumber(w.x) == UnionRs.BAY_DOOR.x and tonumber(w.y) == UnionRs.BAY_DOOR.y and w.destMap == colosseum then + bay = true + end + end + if bay then + local oneF + for _, w in ipairs(warps) do + local destId = w.destMap + if type(destId) == "string" and destId ~= colosseum and backLink(maps[destId], mapId) then oneF = destId end + end + out[mapId] = { id = mapId, oneF = oneF } + end + end + return out +end + +local function copyCells(L) + local cells = {} + local w, h = L.width, L.height + for y = 0, h - 1 do + for x = 0, w - 1 do + local c = L:cellAt(x, y) + cells[y * w + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev } + end + end + return cells +end + +function UnionRs.patchLayout(def, mapId) + if def._unionRsDoor then return def.midLayout end + local L = def.midLayout + if not L then error("union room: " .. tostring(mapId) .. " has no layout in the cache; re-import the ROM", 2) end + local cells = copyCells(L) + local w = L.width + local function put(dx, dy, sx, sy) + local c = cells[sy * w + sx + 1] + cells[dy * w + dx + 1] = { mid = c.mid, coll = c.coll, elev = c.elev } + end + put(UnionRs.DOOR.x, UnionRs.DOOR.y, UnionRs.BAY_DOOR.x, UnionRs.BAY_DOOR.y) + put(UnionRs.DOOR.x, UnionRs.DOOR.y - 1, UnionRs.BAY_DOOR.x, UnionRs.BAY_DOOR.y - 1) + local border = {} + for i, m in ipairs(L.borderMids or { 0 }) do border[i] = m end + local layout = require("src.core.game3.layout_native").fromDecoded({ + width = L.width, height = L.height, trueWidth = L.trueWidth, trueHeight = L.trueHeight, + borderWidth = L.borderWidth, borderHeight = L.borderHeight, borderMids = border, cells = cells, + }, mapId, L.pair or def.pair) + def.midLayout = layout + def._unionRsDoor = true + return layout +end + +function UnionRs.install(game) + local maps = game and game.data and game.data.maps + if type(maps) ~= "table" then return 0 end + UnionRs._game = game + UnionRs.centers = UnionRs.discover(maps) + local n = 0 + for mapId in pairs(UnionRs.centers) do + UnionRs.patchLayout(maps[mapId], mapId) + n = n + 1 + end + UnionRs.arm() + return n +end + +function UnionRs.isCenter(mapId) + return type(mapId) == "string" and UnionRs.centers[mapId] ~= nil +end + +local function maps() + local game = link().game() or UnionRs._game + return game and game.data and game.data.maps or {} +end + +function UnionRs.attendantGfx(mapId) + local def = maps()[mapId] + for _, o in ipairs(def and def.objects or {}) do + if tonumber(o.x) == UnionRs.BAY_ATTENDANT.x and tonumber(o.y) == UnionRs.BAY_ATTENDANT.y then + return tonumber(o.graphicsId or o.graphics) + end + end + return nil +end + +function UnionRs.nurseFront(oneF) + local def = maps()[oneF] + if not def then return nil end + local nurse = require("src.core.game3.constants").of(Family.activeVersion()):require("event_objects", "OBJ_EVENT_GFX_NURSE") + local L = def.midLayout + for _, o in ipairs(def.objects or {}) do + if tonumber(o.graphicsId or o.graphics) == nurse then + local x, y = tonumber(o.x), tonumber(o.y) + for dy = 1, 3 do + if L and L:collAt(x, y + dy) == 0 then return x, y + dy end + end + end + end + return nil +end + +function UnionRs.originFor(centerId) + local c = UnionRs.centers[centerId] + if not (c and c.oneF) then return nil end + local x, y = UnionRs.nurseFront(c.oneF) + if not x then return nil end + return { map = c.oneF, x = x, y = y, facing = "up" } +end + +local function currentMap() + local Map = package.loaded["src.core.game3.map"] + return Map and Map.current or nil +end + +local function player() return package.loaded["src.core.game3.player"] end + +function UnionRs.spawnAttendant(mapId) + local V = require("src.core.game3.virtual_objects") + local vo = V.get(UnionRs.VOBJ_ID) + if vo and vo.map == mapId then return vo end + local gfx = UnionRs.attendantGfx(mapId) + if not gfx then return nil end + vo = V.spawn(UnionRs.VOBJ_ID, gfx, UnionRs.ATTENDANT.x, UnionRs.ATTENDANT.y, 3, DIR.down) + vo.solid = true + vo.map = mapId + return vo +end + +local function busy() + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.vm and Space.vm.isRunning and Space.vm:isRunning() then return true end + local Field = package.loaded["src.core.game3.field"] + if Field and Field.locked then return true end + local Hud = package.loaded["src.ui.game3.hud"] + if Hud and Hud.busy and Hud.busy() then return true end + local Warp = package.loaded["src.core.game3.warp"] + return (Warp and Warp.isBusy and Warp.isBusy()) and true or false +end + +local function pressedA() + local game = link().game() + local input = game and game.input + return input and input.wasPressed and input:wasPressed("a") and true or false +end + +function UnionRs.facingAttendant() + local P = player() + if not P or P.moving then return false end + local d = DELTA[P.facing or "down"] + return d ~= nil and (tonumber(P.cellX) or 0) + d[1] == UnionRs.ATTENDANT.x + and (tonumber(P.cellY) or 0) + d[2] == UnionRs.ATTENDANT.y +end + +function UnionRs.tick() + if not UnionRs.active() then return end + if UnionRs.flow then return UnionRs.stepFlow() end + local mapId = currentMap() + if not UnionRs.isCenter(mapId) then return end + local Warp = package.loaded["src.core.game3.warp"] + if Warp and Warp.isBusy and Warp.isBusy() then return end + UnionRs.spawnAttendant(mapId) + if busy() or not pressedA() or not UnionRs.facingAttendant() then return end + UnionRs.talk(mapId) +end + +function UnionRs.arm() + if UnionRs._task and not UnionRs._task.done then return end + UnionRs._task = require("src.core.game3.task").spawn(function() + local ok, err = pcall(UnionRs.tick) + if not ok then + print("[union_rs] " .. tostring(err)) + UnionRs.endFlow() + end + return false + end) +end + +function UnionRs.runFlow(steps) + UnionRs.flow = { steps = steps, i = 1, started = false } + UnionRs.stepFlow() +end + +function UnionRs.stepFlow() + for _ = 1, 16 do + local f = UnionRs.flow + if not f then return end + local step = f.steps[f.i] + if not step then + if UnionRs.flow == f then UnionRs.endFlow() end + return + end + if not f.started then + f.started = true + if step.start then step.start() end + if UnionRs.flow ~= f then return end + end + if step.poll and not step.poll() then return end + if UnionRs.flow ~= f then return end + f.i = f.i + 1 + f.started = false + end +end + +function UnionRs.endFlow() + UnionRs.flow = nil + local Field = package.loaded["src.core.game3.field"] + if Field and Field.unlock then Field.unlock(UnionRs.LOCK) end + local vo = require("src.core.game3.virtual_objects").get(UnionRs.VOBJ_ID) + if vo then vo.direction = DIR.down end +end + +local function say(text) + return { + start = function() message().show(text) end, + poll = function() return not message().isOpen() end, + } +end + +local function stay(text) + return { + start = function() message().show(text, { stay = true }) end, + poll = function() + local M = message() + return M.isOpen() and M.isWaiting() and (M._page or 1) >= #(M._pages or {}) + end, + } +end + +local function yesNo(cb) + local asked, answered = false, false + return { + poll = function() + if answered then return true end + local Choice = require("src.ui.game3.choice") + if not asked and not Choice.isOpen() then + asked = true + Choice.yesNo(function(yes) + answered = true + message().close() + cb(yes and true or false) + end) + end + return answered + end, + } +end + +local function act(fn) return { start = fn } end + +local function bye() + UnionRs.runFlow({ say(UnionRs.text("come_again")) }) +end + +function UnionRs.countMons(session) + local n = 0 + for _, mon in ipairs(type(session) == "table" and session.party or {}) do + if type(mon) == "table" and not (mon.isEgg or mon.egg) and (tonumber(mon.species) or 0) > 0 then n = n + 1 end + end + return n +end + +function UnionRs.talk(mapId) + local Field = require("src.core.game3.field") + Field.lock(UnionRs.LOCK) + local P = player() + local vo = require("src.core.game3.virtual_objects").get(UnionRs.VOBJ_ID) + if vo and P then vo.direction = DIR[OPPOSITE[P.facing or "down"]] or DIR.down end + UnionRs.runFlow({ + stay(UnionRs.text("welcome")), + yesNo(function(yes) + if not yes then return bye() end + UnionRs.checkParty(mapId) + end), + }) +end + +function UnionRs.checkParty(mapId) + local session = link().session() + if UnionRs.countMons(session) < UnionRs.MIN_MONS then + return UnionRs.runFlow({ say(UnionRs.text("need_two")) }) + end + if link().adapterConnected() then return UnionRs.askSave(mapId) end + UnionRs.runFlow({ + stay(UnionRs.text("connect")), + yesNo(function(yes) + if not yes then return bye() end + UnionRs.connect(mapId) + end), + }) +end + +function UnionRs.connect(mapId) + local L = link() + local ok, err = L.connect() + if not ok then return UnionRs.runFlow({ say(L.reasonText(err)) }) end + local failed + UnionRs.runFlow({ + act(function() message().show(UnionRs.text("connecting"), { stay = true }) end), + { + poll = function() + if L.adapterConnected() then return true end + local state = L.connectState() + if state == "error" or state == "offline" then + failed = L.connectError() or state + return true + end + return false + end, + }, + act(function() + message().close() + if failed then return UnionRs.runFlow({ say(L.reasonText(failed)) }) end + UnionRs.askSave(mapId) + end), + }) +end + +function UnionRs.askSave(mapId) + local saved + UnionRs.runFlow({ + say(UnionRs.text("save_first")), + act(function() + local SaveMenu = require("src.ui.game3.save_menu") + SaveMenu.show({ + session = link().session(), game = link().game(), + onClose = function() saved = SaveMenu._phase == "saved" end, + }) + end), + { poll = function() return saved ~= nil end }, + act(function() + if not saved then return bye() end + UnionRs.runFlow({ + say(UnionRs.text("enjoy")), + act(function() UnionRs.enter(mapId) end), + }) + end), + }) +end + +local ENTER = { surfing = false } + +local function pathTo(fx, fy, tx, ty) + local Coll = package.loaded["src.core.game3.collision"] + local Objects = package.loaded["src.core.game3.objects"] + local function free(x, y) + if x == UnionRs.ATTENDANT.x and y == UnionRs.ATTENDANT.y then return false end + if Objects and Objects.blocks and Objects.blocks(x, y) then return false end + return Coll ~= nil and Coll.canEnter(link().game(), x, y, ENTER) == true + end + local key = function(x, y) return y * 1024 + x end + local prev = { [key(fx, fy)] = false } + local queue, head = { { fx, fy } }, 1 + while queue[head] do + local c = queue[head] + head = head + 1 + if c[1] == tx and c[2] == ty then + local dirs = {} + local k = key(tx, ty) + while prev[k] do + local p = prev[k] + table.insert(dirs, 1, p.dir) + k = key(p.x, p.y) + end + return dirs + end + for _, dir in ipairs({ "up", "left", "right", "down" }) do + local d = DELTA[dir] + local nx, ny = c[1] + d[1], c[2] + d[2] + local nk = key(nx, ny) + if prev[nk] == nil and math.abs(nx - fx) <= 4 and math.abs(ny - fy) <= 4 and free(nx, ny) then + prev[nk] = { x = c[1], y = c[2], dir = dir } + queue[#queue + 1] = { nx, ny } + end + end + end + return nil +end + +function UnionRs.walkSteps(mapId) + local P = player() + local dirs = pathTo(tonumber(P.cellX) or 0, tonumber(P.cellY) or 0, UnionRs.FRONT.x, UnionRs.FRONT.y) or {} + local steps = {} + for _, dir in ipairs(dirs) do + local done = false + steps[#steps + 1] = { + start = function() + if not P.forceStep(dir, function() done = true end) then done = true end + end, + poll = function() return done end, + } + end + steps[#steps + 1] = act(function() + if P.scriptFace then P.scriptFace("up") else P.facing = "up" end + end) + return steps +end + +function UnionRs.enter(mapId) + local L = link() + local session = L.session() + require("src.core.game3.party").healAll(session.party) + local origin = UnionRs.originFor(mapId) + if origin then + require("src.online.union.Origin").record(session, { + gen = 3, version = Family.activeVersion(), map = origin.map, x = origin.x, y = origin.y, + facing = origin.facing, warp = mapId, + }) + end + session.dynamicWarp = { map = mapId, warpId = 0xFF, x = UnionRs.DOOR.x, y = UnionRs.DOOR.y } + local steps = UnionRs.walkSteps(mapId) + local arrived = false + steps[#steps + 1] = act(function() + local rt = package.loaded["src.core.game3.runtime"] + local P = plaza() + local x, y = P.entry() + require("src.core.game3.warp").scripted(rt and rt._mod, L.game(), "warpdoor", P.MAP_ID, x, y, "up", + function() arrived = true end) + end) + steps[#steps + 1] = { poll = function() return arrived end } + steps[#steps + 1] = act(function() + UnionRs.endFlow() + require("src.core.game3.field").unlock() + L.union().run(nil) + end) + UnionRs.runFlow(steps) +end + +function UnionRs.inRoom(session) + return type(session) == "table" and session.map ~= nil and session.map == plaza().MAP_ID +end + +function UnionRs.saveOrigin(session) + local Origin = require("src.online.union.Origin") + local o = Origin.get(session) + if o and o.gen == 3 then return { map = o.map, x = o.x, y = o.y, facing = o.facing or "up" } end + return nil +end + +function UnionRs.reset() + UnionRs.flow = nil +end + +return UnionRs diff --git a/src/core/game3/rse/union_rs_rules.lua b/src/core/game3/rse/union_rs_rules.lua new file mode 100644 index 00000000..5ed16d80 --- /dev/null +++ b/src/core/game3/rse/union_rs_rules.lua @@ -0,0 +1,51 @@ +local Base = require("src.core.game3.profiles.rs_rules") + +local Rules = setmetatable({}, { __index = Base }) + +local function union() return require("src.core.game3.rse.union_rs") end + +local function healCenterFront(session) + local heal = type(session.healMap) == "string" and session.healMap or nil + if not heal then return nil end + local U = union() + for id, c in pairs(U.centers) do + if c.oneF == heal then return U.originFor(id) end + end + return nil +end + +function Rules.unionReturn(session, game) + if not union().inRoom(session) then + return require("src.core.game3.link.union_save_spot").live(session, game) + end + return union().saveOrigin(session) or healCenterFront(session) +end + +function Rules.saveLocation(session, game) + local o = Rules.unionReturn(session, game) + if not o then return nil end + return o.map, o.x, o.y, o.facing +end + +-- pokeruby/src/load_save.c:38 +function Rules.saveWarpFields(session) + local o = Rules.unionReturn(session) + if o then return 1, { map = o.map, warpId = -1, x = o.x, y = o.y } end + return Base.saveWarpFields(session) +end + +-- pokeruby/src/load_save.c:38 +function Rules.useContinueGameWarp(session, mounted) + local fromRoom = union().inRoom(session) + local map, x, y, facing = session.map, tonumber(session.x), tonumber(session.y), session.facing + Base.useContinueGameWarp(session, mounted) + if session.map == map and tonumber(session.x) == x and tonumber(session.y) == y then session.facing = facing end + local o = Rules.unionReturn(session) + if o then + session.map, session.x, session.y, session.facing = o.map, o.x, o.y, o.facing + elseif fromRoom then + session.facing = "up" + end +end + +return Rules diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index d1fb670b..df075007 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -897,6 +897,18 @@ function LauncherSettings.open(hooks, version) action = function() hooks.openExtras(); return false end, }) end + local UnionSetting = require("src.online.union.Setting") + launcher.rows[#launcher.rows + 1] = { + label = Strings("Union Room"), + note = Strings("Adds a Union Room upstairs in Gen 1 and Gen 2 Pokemon Centers. OFF restores the original Centers. Gen 3 Union Rooms always work."), + value = function() + return UnionSetting.enabledIn(opts) and Strings("ON") or Strings("OFF") + end, + step = function() + opts[UnionSetting.KEY] = not UnionSetting.enabledIn(opts) + return true + end, + } local RomSources = require("src.import.RomSources") launcher.rows[#launcher.rows + 1] = { label = Strings("Auto Re-import"), diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index bc31d724..bdd5ff26 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -5765,7 +5765,8 @@ local function buildSettingsModal(imp, m) item.stacked = item.row.choices ~= nil or Kit.textWidth("small", item.label) + math.floor(210 * m.s) > inner item.labelH = item.stacked and Kit.wrapHeight("small", item.label, inner) or Kit.textHeight("small") - item.h = 2 * inset + m.btnH + (item.stacked and item.labelH + gap or 0) + item.noteH = item.row.note and Kit.wrapHeight("micro", item.row.note, inner) + gap or 0 + item.h = 2 * inset + m.btnH + (item.stacked and item.labelH + gap or 0) + item.noteH end total = total + item.h + gap end @@ -5809,6 +5810,9 @@ local function buildSettingsModal(imp, m) if visible then Kit.card(x, ry, rowW, item.h, "row") Kit.textWrapped("small", item.label, ix, labelY, inner, PAL.text) + if row.note then + Kit.textWrapped("micro", row.note, ix, ctlY + m.btnH + gap, inner, PAL.muted) + end end if row.choices then local cw = (inner - gap) / 2 diff --git a/src/link/Wire.lua b/src/link/Wire.lua index 1496edae..f458961f 100644 --- a/src/link/Wire.lua +++ b/src/link/Wire.lua @@ -332,6 +332,9 @@ local MAX_PLAZA = 40 local MAX_DIRECT = 64 local MAX_GROUPS = 64 local MAX_AVATAR_NAME = 16 +local MAX_AVATAR_STYLE = 16 +local MAX_CAPS_FP = 64 +local MAX_CAPS_ENTRIES = 11 local MAX_MON_TEXT = 32 local MAX_CHAT_NAME = 7 local MAX_CHAT_TEXT = 15 @@ -447,6 +450,15 @@ end Wire.party3 = party3 +local function avatarStyle(v) + if type(v) ~= "string" or not v:match("^[a-z0-9:_%-]+$") or #v > MAX_AVATAR_STYLE then + return nil + end + return v +end + +Wire.avatarStyle = avatarStyle + function Wire.avatar(v) if type(v) ~= "table" then return nil end return { @@ -454,6 +466,46 @@ function Wire.avatar(v) trainerId = Wire.num(v.trainerId, 0, 0, 65535), gender = Wire.num(v.gender, 0, 0, 1), version = Wire.str(v.version, nil, MAX_AVATAR_NAME), + style = avatarStyle(v.style), + gen = Wire.num(v.gen, nil, 1, 3), + canLinkNationally = Wire.bool(v.canLinkNationally, nil), + } +end + +local function capsEntry(v) + if type(v) ~= "table" then return nil end + local version = Wire.str(v.version, nil, MAX_AVATAR_NAME) + local fp = type(v.fp) == "string" and v.fp:lower() or nil + if not version or not version:match("^[a-z]+$") or not fp + or #fp > MAX_CAPS_FP or not fp:match("^[0-9a-f]+$") then + return nil + end + return { version = version, fp = fp } +end + +function Wire.caps(v) + if type(v) ~= "table" then return nil end + local gens, total = {}, 0 + local src = type(v.gens) == "table" and v.gens or {} + for gen = 1, 3 do + local list = src[tostring(gen)] + if list == nil then list = src[gen] end + if type(list) == "table" then + local out = {} + for i = 1, math.min(#list, MAX_CAPS_ENTRIES) do + local entry = capsEntry(list[i]) + if entry and total < MAX_CAPS_ENTRIES then + out[#out + 1] = entry + total = total + 1 + end + end + gens[tostring(gen)] = out + end + end + return { + proto = Wire.num(v.proto, 1, 1, 1000), + policy = Wire.num(v.policy, 0, 0, 1000000), + gens = gens, } end @@ -501,6 +553,7 @@ function Wire.member(v) recruiting = recruiting(v.recruiting), board = board(v.board), group = memberGroup(v.group), + caps = Wire.caps(v.caps), } end @@ -635,9 +688,85 @@ local function playerEntry(v) seat = Wire.num(v.seat, nil, 0, MAX_SEAT), party = Wire.list(v.party, MAX_TEAM, mon), partyDigest = Wire.str(v.partyDigest, nil, MAX_STRING), + gen = Wire.num(v.gen, nil, 1, 3), + avatar = Wire.avatar(v.avatar), } end +local XG_MODES = { battle = true, trade = true } + +local function xgMode(v) + return XG_MODES[v] and v or nil +end + +local function xgGens(v) + if type(v) ~= "table" then return nil end + local a, b = Wire.num(v[1], nil, 1, 3), Wire.num(v[2], nil, 1, 3) + if not a or not b then return nil end + return { a, b } +end + +local function xgRules(v) + if type(v) ~= "table" then return nil end + return { + mode = xgMode(v.mode), + ruleset = Wire.str(v.ruleset, nil, MAX_NAME), + gen = Wire.num(v.gen, nil, 1, 3), + dexMax = Wire.num(v.dexMax, nil, 1, 65535), + moveMax = Wire.num(v.moveMax, nil, 1, 65535), + moveGen = Wire.num(v.moveGen, nil, 1, 3), + policy = Wire.num(v.policy, nil, 0, 1000000), + gens = xgGens(v.gens), + } +end + +Wire.xgRules = xgRules + +local function xgPair(v, fn) + local out = {} + if type(v) ~= "table" then return out end + for i = 1, 2 do + if v[i] ~= nil then out[i] = fn(v[i]) end + end + return out +end + +local function xgRoster(v) + if type(v) ~= "table" then return nil end + local size, digest = Wire.num(v.size, nil, 1, MAX_TEAM), Wire.digest(v.digest16) + if not size or not digest then return nil end + return { size = size, digest16 = digest } +end + +local function xgOfferMeta(v) + if type(v) ~= "table" then return nil end + local offerRev, digest = Wire.num(v.offerRev, nil, 0, MAX_INT), Wire.digest(v.digest16) + if not offerRev or not digest then return nil end + return { offerRev = offerRev, digest16 = digest } +end + +local function xgSnapshot(v) + if type(v) ~= "table" then return nil end + local mode = xgMode(v.mode) + local rev = Wire.num(v.rev, nil, 0, MAX_INT) + if not mode or not rev then return nil end + return { + mode = mode, + rev = rev, + gens = xgGens(v.gens), + rules = xgRules(v.rules), + blocked = Wire.str(v.blocked, nil, MAX_NAME), + size = Wire.num(v.size, nil, 1, MAX_TEAM), + caps = xgPair(v.caps, function(x) return x == true end), + rosters = xgPair(v.rosters, xgRoster), + sizeReq = xgPair(v.sizeReq, function(x) return Wire.num(x, nil, 1, MAX_TEAM) end), + offers = xgPair(v.offers, xgOfferMeta), + ready = xgPair(v.ready, function(x) return x == true end), + } +end + +Wire.xgSnapshot = xgSnapshot + local function spectatorEntry(v) if type(v) ~= "table" then return nil end return { @@ -704,6 +833,7 @@ SCHEMAS.lobby_hello = function(m) platform = Wire.str(m.platform, nil, MAX_NAME), profiles = Wire.list(m.profiles, MAX_PROFILES, profile), presence = presenceFields(m.presence), + xgen = Wire.num(m.xgen, nil, 0, 1), } end @@ -875,6 +1005,8 @@ SCHEMAS.room_state = function(m) maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS), leader = Wire.num(m.leader, nil, 0, MAX_SEAT), deadlines = Wire.list(m.deadlines, MAX_DEADLINES, deadlineEntry), + mode = xgMode(m.mode), + xg = xgSnapshot(m.xg), } end @@ -991,9 +1123,17 @@ SCHEMAS.plaza_join = function(m) cap = Wire.num(m.cap, nil, 1, MAX_PLAZA), profile = profile(m.profile), avatar = Wire.avatar(m.avatar), + xgen = Wire.num(m.xgen, nil, 0, 1), + caps = Wire.caps(m.caps), } end +SCHEMAS.set_caps = function(m) + local caps = Wire.caps(m.caps) + if not caps then return nil end + return { caps = caps } +end + SCHEMAS.plaza_leave = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME) } end @@ -1756,6 +1896,114 @@ SCHEMAS.game3_mg_leader = inner3(function(m) } end) +local XG_PAYLOAD_DEPTH = 8 +local XG_PAYLOAD_STRING = 256 +local XG_WHY = 40 + +local function xgPayload(v, depth) + if type(v) ~= "table" or depth > XG_PAYLOAD_DEPTH then return nil end + local out = {} + for k, val in pairs(v) do + local kt, vt = type(k), type(val) + if (kt == "string" and #k <= MAX_STRING) or (kt == "number" and k == k) then + if vt == "string" then out[k] = Wire.str(val, nil, XG_PAYLOAD_STRING) + elseif vt == "number" then + if val == val then out[k] = val end + elseif vt == "boolean" then out[k] = val + elseif vt == "table" then out[k] = xgPayload(val, depth + 1) end + end + end + return out +end + +local function xgInner(fn) + return function(m) + local out = fn(m) + if not out then return nil end + out.seat = seatNum(m.seat) + out.relay = Wire.bool(m.relay, nil) + return out + end +end + +local function xgRev(v) + return Wire.num(v, nil, 0, MAX_INT) +end + +SCHEMAS.xg_caps = xgInner(function(m) + return { caps = Wire.caps(m.caps) } +end) + +SCHEMAS.xg_rules = xgInner(function(m) + local out = xgRules(m) + out.rev = xgRev(m.rev) + return out +end) + +SCHEMAS.xg_blocked = xgInner(function(m) + return { rev = xgRev(m.rev), why = Wire.str(m.why, nil, MAX_NAME) } +end) + +SCHEMAS.xg_counter = xgInner(function(m) + return { rev = xgRev(m.rev), gen = Wire.num(m.gen, nil, 1, 3) } +end) + +SCHEMAS.xg_roster = xgInner(function(m) + return { rev = xgRev(m.rev), size = Wire.num(m.size, nil, 1, MAX_TEAM), + digest16 = Wire.digest(m.digest16) } +end) + +SCHEMAS.xg_size = xgInner(function(m) + return { rev = xgRev(m.rev), size = Wire.num(m.size, nil, 1, MAX_TEAM) } +end) + +SCHEMAS.xg_size_req = SCHEMAS.xg_size + +SCHEMAS.xg_offer = xgInner(function(m) + return { rev = xgRev(m.rev), offerRev = Wire.num(m.offerRev, nil, 0, MAX_INT), + payload = xgPayload(m.payload, 0), digest16 = Wire.digest(m.digest16) } +end) + +SCHEMAS.xg_rev = xgInner(function(m) + return { rev = xgRev(m.rev), cause = Wire.str(m.cause, nil, MAX_NAME) } +end) + +SCHEMAS.xg_ready = xgInner(function(m) + return { rev = xgRev(m.rev), digest16 = Wire.digest(m.digest16) } +end) + +SCHEMAS.xg_go = xgInner(function(m) + return { + rev = xgRev(m.rev), + seed = Wire.num(m.seed, nil, 0, MAX_INT), + match = Wire.str(m.match, nil, MAX_NAME), + mode = xgMode(m.mode), + ruleset = Wire.str(m.ruleset, nil, MAX_NAME), + size = Wire.num(m.size, nil, 1, MAX_TEAM), + gen = Wire.num(m.gen, nil, 1, 3), + dexMax = Wire.num(m.dexMax, nil, 1, 65535), + moveMax = Wire.num(m.moveMax, nil, 1, 65535), + moveGen = Wire.num(m.moveGen, nil, 1, 3), + } +end) + +SCHEMAS.xg_cancel = xgInner(function(m) + return { why = Wire.str(m.why, nil, XG_WHY) } +end) + +SCHEMAS.xg_closed = xgInner(function(m) + return { why = Wire.str(m.why, nil, MAX_NAME), detail = Wire.str(m.detail, nil, XG_WHY) } +end) + +SCHEMAS.xg_nack = xgInner(function(m) + return { + of = Wire.str(m.of, nil, MAX_NAME), + why = Wire.str(m.why, nil, MAX_NAME), + rev = xgRev(m.rev), + current = xgRev(m.current), + } +end) + Wire.SCHEMAS = SCHEMAS local function passthrough(m) diff --git a/src/online/Client.lua b/src/online/Client.lua index baa46491..7ebca5b3 100644 --- a/src/online/Client.lua +++ b/src/online/Client.lua @@ -9,7 +9,7 @@ local BACKOFF = { 1, 2, 4, 8, 15 } local MAX_ATTEMPTS = 12 local MATCH_STAGES = { battling = true } local ROOM_STAGES = { waiting = true, ready = true, battling = true, - ended = true } + ended = true, prep = true, trading = true } local UNACKED_MAX = 512 local function now() @@ -326,6 +326,21 @@ function RoomSession:take(messageType, predicate) return nil end +function RoomSession:takeWhere(predicate) + local rest = leftover(self) + local inbox = rest or S.roomInbox + for index = 1, #inbox do + local entry = inbox[index] + local msg = shaped(entry) + if predicate(msg) == true then + table.remove(inbox, index) + if rest then return msg end + return deliver({ entry })[1] + end + end + return nil +end + function RoomSession:unread(messages) if type(messages) ~= "table" then return end local inbox = leftover(self) or S.roomInbox @@ -532,6 +547,8 @@ local function applyRoomState(msg) maxSpectators = msg.maxSpectators, leader = msg.leader, deadlines = deadlines, + mode = msg.mode, + xg = msg.xg, } S.match = msg.match or S.match S.role, S.seat = myRole(S.room) @@ -565,7 +582,8 @@ clearRoom = function() for i = 1, #inbox do local entry = inbox[i] local kind = entry.msg and entry.msg.type - if entry.relay and (kind == "trade_commit" or kind == "trade_abort") then + if entry.relay and (kind == "trade_commit" or kind == "trade_abort" + or Protocol2.XG_RELAY_TYPES[kind]) then keep[#keep + 1] = entry end end @@ -837,7 +855,7 @@ local function resendState() if S.presence then sendRaw(Protocol2.presence(S.presence)) end for _, kind in ipairs({ "union", "wireless" }) do local j = S.plazaJoins[kind] - if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap)) end + if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap, j.opts)) end end local board = S.presence and S.presence.board if S.plazaJoins.union and type(board) == "table" then @@ -1126,6 +1144,7 @@ helloMessage = function() platform = S.platform, profiles = opts.profiles, presence = S.presence, + xgen = Protocol2.XGEN, }) end @@ -1404,11 +1423,25 @@ function Client.replyInvite(id, accept) return sendRaw(Protocol2.inviteReply(id, accept == true)) end -function Client.joinPlaza(kind, profile, avatar, cap) +function Client.joinPlaza(kind, profile, avatar, cap, opts) profile = profile or defaultProfile(3) if cap == nil and kind == "union" then cap = Protocol2.PLAZA_CAP end - S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap } - return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap)) + local xopts = nil + if type(opts) == "table" and opts.xgen then + xopts = { xgen = Protocol2.XGEN, caps = opts.caps } + end + S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap, opts = xopts } + return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap, xopts)) +end + +function Client.setCaps(caps) + local j = S.plazaJoins.union + if j and j.opts then j.opts.caps = caps end + return sendRaw(Protocol2.setCaps(caps)) +end + +function Client.plazaJoinInfo(kind) + return S.plazaJoins[kind or "union"] end function Client.leavePlaza(kind) diff --git a/src/online/Protocol2.lua b/src/online/Protocol2.lua index 457f44a0..6a973cbf 100644 --- a/src/online/Protocol2.lua +++ b/src/online/Protocol2.lua @@ -87,6 +87,7 @@ function Protocol2.lobbyHello(opts) platform = opts.platform, profiles = opts.profiles or {}, presence = opts.presence, + xgen = opts.xgen, }) end @@ -116,9 +117,57 @@ end Protocol2.PLAZA_CAP = 40 -function Protocol2.plazaJoin(kind, profile, avatar, cap) +Protocol2.XGEN = 1 + +function Protocol2.plazaJoin(kind, profile, avatar, cap, opts) + opts = opts or {} return build({ type = "plaza_join", kind = kind, profile = profile, - avatar = avatar, cap = cap }) + avatar = avatar, cap = cap, xgen = opts.xgen, caps = opts.caps }) +end + +function Protocol2.setCaps(caps) + return build({ type = "set_caps", caps = caps }) +end + +Protocol2.XG_ACTIVITIES = { xg_battle = "battle", xg_trade = "trade" } +Protocol2.XG_STAGES = { prep = true, trading = true, battling = true, ended = true } +Protocol2.XG_RELAY_TYPES = { xg_rules = true, xg_blocked = true, xg_size = true, xg_rev = true, + xg_go = true, xg_closed = true, xg_nack = true } +Protocol2.XG_SEAT_TYPES = { xg_caps = true, xg_counter = true, xg_roster = true, + xg_size_req = true, xg_offer = true, xg_ready = true, xg_cancel = true } + +function Protocol2.isXg(msg) + local t = type(msg) == "table" and msg.type or nil + return Protocol2.XG_RELAY_TYPES[t] == true or Protocol2.XG_SEAT_TYPES[t] == true +end + +function Protocol2.xgCaps(caps) + return build({ type = "xg_caps", caps = caps }) +end + +function Protocol2.xgCounter(rev, gen) + return build({ type = "xg_counter", rev = rev, gen = gen }) +end + +function Protocol2.xgRoster(rev, size, digest16) + return build({ type = "xg_roster", rev = rev, size = size, digest16 = digest16 }) +end + +function Protocol2.xgSizeReq(rev, size) + return build({ type = "xg_size_req", rev = rev, size = size }) +end + +function Protocol2.xgOffer(rev, offerRev, payload, digest16) + return build({ type = "xg_offer", rev = rev, offerRev = offerRev, payload = payload, + digest16 = digest16 }) +end + +function Protocol2.xgReady(rev, digest16) + return build({ type = "xg_ready", rev = rev, digest16 = digest16 }) +end + +function Protocol2.xgCancel(why) + return build({ type = "xg_cancel", why = why }) end function Protocol2.plazaLeave(kind) @@ -323,7 +372,7 @@ Protocol2.CLIENT_TYPES = { invite_reply = true, plaza_join = true, plaza_leave = true, group_open = true, group_list = true, group_join = true, group_accept = true, group_leave = true, group_start = true, direct_queue = true, - direct_list = true, direct_leave = true, + direct_list = true, direct_leave = true, set_caps = true, } Protocol2.SERVER_TYPES = { @@ -344,6 +393,7 @@ Protocol2.SERVER_TYPES = { Protocol2.RELAY_INNER = { trade_commit = true, trade_abort = true, game3_mg_leader = true } +for t in pairs(Protocol2.XG_RELAY_TYPES) do Protocol2.RELAY_INNER[t] = true end Protocol2.RESULTS = { win = true, lose = true, draw = true } @@ -618,6 +668,8 @@ local REASONS = { bad_activity = Strings.source("That activity isn't available."), already_queued = Strings.source("You're already waiting for a partner."), rate_limited = Strings.source("Too many requests. Please wait a moment."), + bad_avatar = Strings.source("The relay couldn't read your trainer."), + bad_caps = Strings.source("The relay couldn't read your game list."), } local CLOSED_REASONS = { @@ -649,6 +701,7 @@ local INVITE_CLOSED = { no_room = Strings.source("That room is gone."), no_group = Strings.source("That group is gone."), group_full = Strings.source("That group is full."), + not_plaza = Strings.source("That trainer isn't in this Union Room."), } Protocol2.UPGRADE_TEXT = Strings.source("This build is too old for online play. Please update.") diff --git a/src/online/Trade.lua b/src/online/Trade.lua index 90c55c52..0fbd5dae 100644 --- a/src/online/Trade.lua +++ b/src/online/Trade.lua @@ -974,6 +974,13 @@ function Trade.pendingSentAt(savePath) for _, e in ipairs(readJournal(Trade.journalPath(savePath))) do if type(e) == "table" and type(e.sent) == "table" then out[#out + 1] = e.sent end end + local xpath = type(savePath) == "string" and savePath ~= "" + and (savePath:gsub("%.lua$", "")) .. "_xtrade.lua" or nil + for _, e in ipairs(readJournal(xpath)) do + if type(e) == "table" and type(e.out) == "table" and type(e.out.record) == "table" then + out[#out + 1] = e.out.record + end + end return out end diff --git a/src/online/union/Avatars.lua b/src/online/union/Avatars.lua new file mode 100644 index 00000000..6d8f60dc --- /dev/null +++ b/src/online/union/Avatars.lua @@ -0,0 +1,507 @@ +local GameVersion = require("src.core.GameVersion") + +local Avatars = {} + +local readerOverride = nil +local resolved = {} +local tables = {} +local hostBases = {} +local stats = { reads = 0, resolves = 0, images = 0 } + +Avatars.GB_FRAME = 16 +-- pokered/data/sprites/facings.asm:1 +Avatars.GB_STAND = { down = 0, up = 1, left = 2, right = 2 } +Avatars.GB_WALK = { down = 3, up = 4, left = 5, right = 5 } +-- pokeemerald/src/data/object_events/object_event_anims.h:825 +Avatars.GBA_STAND = { down = 0, up = 1, left = 2, right = 2 } +Avatars.GBA_WALK_A = { down = 3, up = 5, left = 7, right = 7 } +Avatars.GBA_WALK_B = { down = 4, up = 6, left = 8, right = 8 } + +local GB1 = { "red", "blue", "yellow" } +local GB2 = { "gold", "silver", "crystal" } +local GB2_FEMALE = { "crystal" } +local FRLG = { "firered", "leafgreen" } +local RSE = { "ruby", "sapphire", "emerald" } +local RSE_CLASSES = { "emerald" } + +local SPRITE_GB1 = "assets/generated/sprites/red.png" +-- pokecrystal/data/sprites/player_sprites.asm:1 +local SPRITE_GB2 = { [0] = { png = "assets/generated/sprites/chris.png", id = "SPRITE_CHRIS" }, + [1] = { png = "assets/generated/sprites/kris.png", id = "SPRITE_KRIS" } } +local GB2_SPRITES = "data/generated/sprites.lua" +local GB2_PALETTES = "data/generated/palettes.lua" +local GB2_DAYTIME = "DAY" +local OW_ROOT = "data/generated/gba/ow/" +local UNION_AVATARS = "data/generated/gba/union_room/avatars.lua" + +local function defaultReader(version, rel) + local prefix = GameVersion.cachePrefix(version) + return require("src.import.CacheFs").readAt(prefix .. rel) +end + +function Avatars.directoryReader(roots) + local CacheBlob = require("src.import.CacheBlob") + return function(version, rel) + local root = type(roots) == "table" and roots[version] or roots + if type(root) ~= "string" then return nil end + local path = root .. "/" .. GameVersion.cachePrefix(version) .. rel + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return CacheBlob.decode(path, body) + end +end + +function Avatars.setReader(fn) + readerOverride = fn + Avatars.reset() +end + +function Avatars.reset() + resolved = {} + tables = {} + hostBases = {} + stats = { reads = 0, resolves = 0, images = 0 } +end + +function Avatars.refresh() + resolved = {} + tables = {} + hostBases = {} +end + +function Avatars.stats() + return { reads = stats.reads, resolves = stats.resolves, images = stats.images } +end + +local function read(version, rel) + stats.reads = stats.reads + 1 + local fn = readerOverride or defaultReader + local ok, bytes = pcall(fn, version, rel) + if ok and type(bytes) == "string" and #bytes > 0 then return bytes end + return nil +end + +local function evaluate(body, label) + if type(body) ~= "string" then return nil end + local chunk = (loadstring or load)(body, "@" .. label) + if not chunk then return nil end + if setfenv then setfenv(chunk, {}) end + local ok, value = pcall(chunk) + return ok and type(value) == "table" and value or nil +end + +local function tableAt(version, rel) + local key = version .. "|" .. rel + local hit = tables[key] + if hit ~= nil then return hit or nil end + local t = evaluate(read(version, rel), version .. "/" .. rel) + tables[key] = t or false + return t +end + +function Avatars.pngSize(bytes) + if type(bytes) ~= "string" or #bytes < 24 or bytes:sub(1, 8) ~= "\137PNG\r\n\26\n" then + return nil + end + local function u32(i) + local a, b, c, d = bytes:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return u32(17), u32(21) +end + +local function ordered(first, family) + local out, seen = {}, {} + if first then + for _, v in ipairs(family) do + if v == first then out[1], seen[v] = v, true end + end + end + for _, v in ipairs(family) do + if not seen[v] then out[#out + 1] = v end + end + return out +end + +local function classIndex(style) + local n = type(style) == "string" and tonumber(style:match("^g3:(%d+)$")) or nil + if n and n >= 0 and n <= 7 then return math.floor(n) end + return nil +end + +function Avatars.family(p) + local version = p and p.game + local gen = GameVersion.VERSIONS[version or ""] and GameVersion.generation(version) or tonumber(p and p.gen) + local female = p and p.gender == 1 + if gen == 1 then return "gb1", ordered(version, GB1) end + if gen == 2 then + if female and version == "crystal" then return "gb2f", ordered(version, GB2_FEMALE) end + return "gb2m", ordered(version, GB2) + end + if gen == 3 then + local cls = classIndex(p.style) + local frlg = version == "firered" or version == "leafgreen" + if frlg then return cls and "frlg:class" or "frlg:player", ordered(version, FRLG) end + if cls then return "rse:class", ordered(version, RSE_CLASSES) end + return "rse:player", ordered(version, RSE) + end + return nil, {} +end + +local function rects(w, h, count) + local out = {} + for i = 0, count - 1 do out[i] = { x = 0, y = i * h, w = w, h = h } end + return out +end + +local function gb2PaletteFor(version, spriteId) + local sprites = tableAt(version, GB2_SPRITES) + local palettes = tableAt(version, GB2_PALETTES) + local def = sprites and sprites[spriteId] + local id = def and tonumber(def.paletteId) + local objects = palettes and palettes.objects + local day = type(objects) == "table" and objects[GB2_DAYTIME] or nil + local colors = day and id and day[id + 1] + if type(colors) ~= "table" or #colors ~= 4 then return nil end + return { mode = "gbc", colors = colors, name = def.palette } +end + +local function gb2Palette(version, gender) + return gb2PaletteFor(version, SPRITE_GB2[gender].id) +end + +local function gbEntry(version, gen, path, palette) + local bytes = read(version, path) + local w, h = Avatars.pngSize(bytes) + if not w or w ~= Avatars.GB_FRAME or h < Avatars.GB_FRAME * 6 then return nil end + local frames = math.floor(h / Avatars.GB_FRAME) + return { + version = version, gen = gen, layout = "gb", + source = { kind = "png", rel = path, bytes = bytes }, + w = w, h = Avatars.GB_FRAME, frames = frames, sheetW = w, sheetH = h, + rects = rects(w, Avatars.GB_FRAME, frames), + anchor = { x = w / 2, y = Avatars.GB_FRAME }, + palette = palette, + } +end + +local function frlgPlayer(gender) + local V = require("src.import.gba.versions_frlg") + return gender == 1 and V.OW_PLAYER_FEMALE or V.OW_PLAYER_MALE +end + +local function gbaGid(version, kind, p) + local gender = p.gender == 1 and 1 or 0 + if kind == "frlg:player" then return frlgPlayer(gender) end + if kind == "rse:player" then + local manifest = tableAt(version, OW_ROOT .. "manifest.lua") + local rows = manifest and manifest.avatars and manifest.avatars.player + for _, row in ipairs(type(rows) == "table" and rows or {}) do + if row.state == "NORMAL" then return gender == 1 and row.female or row.male end + end + return nil + end + local av = tableAt(version, UNION_AVATARS) + local ids = av and av.gfx_ids + local row = ids and (gender == 1 and ids.female or ids.male) + local n = classIndex(p.style) + return row and n and row[n + 1] or nil +end + +local function gbaEntry(version, kind, p) + local gid = kind == "host" and p.gid or gbaGid(version, kind, p) + if not gid then return nil end + local manifest = tableAt(version, OW_ROOT .. "manifest.lua") + local info = manifest and manifest.sprites and manifest.sprites[gid] + if type(info) ~= "table" then return nil end + local w, h, n = tonumber(info.width), tonumber(info.height), tonumber(info.frameCount) + if not (w and h and n) or n < 9 then return nil end + local rel = OW_ROOT .. gid .. ".rgba" + local bytes = read(version, rel) + if not bytes or #bytes ~= w * h * n * 4 then return nil end + return { + version = version, gen = 3, layout = "gba", gid = gid, + source = { kind = "rgba", rel = rel, bytes = bytes }, + w = w, h = h, frames = n, sheetW = w, sheetH = h * n, + rects = rects(w, h, n), + anchor = { x = w / 2, y = h }, + palette = { mode = "rgba" }, + } +end + +local function candidate(kind, version, p) + if kind == "gb1" then + return gbEntry(version, 1, SPRITE_GB1, { mode = "dmg" }) + end + if kind == "gb2m" or kind == "gb2f" then + local gender = kind == "gb2f" and 1 or 0 + local palette = gb2Palette(version, gender) + if not palette then return nil end + return gbEntry(version, 2, SPRITE_GB2[gender].png, palette) + end + return gbaEntry(version, kind, p) +end + +function Avatars.key(p) + if type(p) ~= "table" then return "?" end + return table.concat({ tostring(p.game), tostring(p.style or "player"), + tostring(p.gender == 1 and 1 or 0) }, "|") +end + +-- pokered/constants/sprite_constants.asm:4 +Avatars.HOST_GB1 = { + [0] = { "SPRITE_YOUNGSTER", "SPRITE_COOLTRAINER_M", "SPRITE_HIKER", "SPRITE_BIKER", + "SPRITE_SUPER_NERD", "SPRITE_GAMBLER", "SPRITE_GENTLEMAN", "SPRITE_FISHER", + "SPRITE_SAILOR", "SPRITE_SWIMMER", "SPRITE_ROCKER", "SPRITE_SCIENTIST", + "SPRITE_MIDDLE_AGED_MAN" }, + [1] = { "SPRITE_BRUNETTE_GIRL", "SPRITE_LITTLE_GIRL", "SPRITE_GIRL", "SPRITE_COOLTRAINER_F", + "SPRITE_BEAUTY", "SPRITE_CHANNELER", "SPRITE_MIDDLE_AGED_WOMAN" }, +} +-- pokecrystal/constants/sprite_constants.asm:4 +Avatars.HOST_GB2 = { + [0] = { "SPRITE_YOUNGSTER", "SPRITE_BUG_CATCHER", "SPRITE_COOLTRAINER_M", "SPRITE_BIKER", + "SPRITE_BLACK_BELT", "SPRITE_FISHER", "SPRITE_GENTLEMAN", "SPRITE_POKEFAN_M", + "SPRITE_ROCKER", "SPRITE_SAILOR", "SPRITE_SCIENTIST", "SPRITE_SUPER_NERD", + "SPRITE_SWIMMER_GUY", "SPRITE_SAGE" }, + [1] = { "SPRITE_LASS", "SPRITE_COOLTRAINER_F", "SPRITE_BEAUTY", "SPRITE_POKEFAN_F", + "SPRITE_SWIMMER_GIRL", "SPRITE_TEACHER", "SPRITE_TWIN" }, +} +-- pokefirered/include/constants/event_objects.h:22 +Avatars.HOST_FRLG = { + [0] = { 16, 18, 19, 20, 25, 26, 27, 30, 32, 39, 41, 52, 53, 54, 55, 56, 57, 61, 62 }, + [1] = { 17, 22, 23, 24, 28, 29, 31, 35, 40, 42, 58 }, +} +-- pokeemerald/include/constants/event_objects.h:14 +Avatars.HOST_RSE = { + [0] = { 7, 9, 13, 19, 23, 31, 35, 36, 37, 38, 39, 44, 46, 48, 49, 50, 55 }, + [1] = { 8, 10, 14, 16, 32, 40, 45, 47 }, +} + +local function hashOf(text) + local h = 5381 + for i = 1, #text do h = (h * 33 + text:byte(i)) % 4294967296 end + return h +end + +function Avatars.pickKey(p) + if type(p) ~= "table" then return "?" end + return table.concat({ tostring(p.game), tostring(math.floor(tonumber(p.trainerId) or 0)), + tostring(p.name or ""), tostring(p.gender == 1 and 1 or 0) }, "|") +end + +function Avatars.hostOf(host) + local version = type(host) == "table" and host.version or host or GameVersion.get() + if not GameVersion.VERSIONS[version or ""] then return nil end + return version, GameVersion.generation(version) +end + +function Avatars.hostList(version, gender) + local gen = GameVersion.generation(version) + local lists + if gen == 1 then lists = Avatars.HOST_GB1 + elseif gen == 2 then lists = Avatars.HOST_GB2 + elseif version == "firered" or version == "leafgreen" then lists = Avatars.HOST_FRLG + else lists = Avatars.HOST_RSE end + return lists[gender == 1 and 1 or 0] +end + +local function hostBase(version, ref) + local key = version .. "|" .. tostring(ref) + local hit = hostBases[key] + if hit ~= nil then return hit or nil end + local gen = GameVersion.generation(version) + local entry + if gen == 3 then + entry = gbaEntry(version, "host", { gid = ref }) + else + local sprites = tableAt(version, GB2_SPRITES) + local def = sprites and sprites[ref] + if type(def) == "table" and def.walker and type(def.image) == "string" then + local palette = { mode = "dmg" } + if gen == 2 then palette = gb2PaletteFor(version, ref) end + if palette then entry = gbEntry(version, gen, def.image, palette) end + end + end + if entry then entry.hostRef = ref end + hostBases[key] = entry or false + return entry +end + +Avatars.hostEntry = hostBase + +local function hostStandin(p, host, need, family) + local version = Avatars.hostOf(host) + if not version then return nil end + local list = Avatars.hostList(version, p.gender) + if not list or #list == 0 then return nil end + local start = hashOf(Avatars.pickKey(p)) % #list + for i = 0, #list - 1 do + local base = hostBase(version, list[(start + i) % #list + 1]) + if base then + local out = {} + for k, v in pairs(base) do out[k] = v end + out.hostStandin = true + out.hostVersion = version + out.hostGen = base.gen + out.gen = GameVersion.VERSIONS[p.game or ""] and GameVersion.generation(p.game) or tonumber(p.gen) + out.need = need + out.family = family + return out + end + end + return nil +end + +function Avatars.resolve(p, host) + local key = Avatars.key(p) + local hit = resolved[key] + if hit and not hit.hostStandin and not hit.standin then return hit end + local hostVersion = Avatars.hostOf(host) + local pickKey = "host|" .. tostring(hostVersion) .. "|" .. Avatars.pickKey(p) + local picked = resolved[pickKey] + if hit and picked then return picked end + stats.resolves = stats.resolves + 1 + local kind, versions = Avatars.family(p) + local entry + if hit then + kind, versions = hit.family, hit.need + elseif kind then + for _, version in ipairs(versions) do + entry = candidate(kind, version, p) + if entry then break end + end + end + if entry then + entry.key = key + entry.family = kind + resolved[key] = entry + return entry + end + if not hit then resolved[key] = { key = key, standin = true, family = kind, need = versions } end + local gen = GameVersion.VERSIONS[p and p.game or ""] and GameVersion.generation(p.game) or nil + entry = type(p) == "table" and hostStandin(p, host, versions, kind) or nil + if entry then + entry.key = pickKey + else + entry = { key = pickKey, standin = true, family = kind, gen = gen, need = versions } + end + resolved[pickKey] = entry + return entry +end + +function Avatars.pose(entry, facing, walkPhase, stepFlip) + facing = facing or "down" + if type(entry) ~= "table" or entry.standin then return 0, facing == "right" end + local flip = facing == "right" + local frame + if entry.layout == "gba" then + if walkPhase == 1 then + frame = stepFlip and Avatars.GBA_WALK_A[facing] or Avatars.GBA_WALK_B[facing] + end + frame = frame or Avatars.GBA_STAND[facing] or 0 + else + if walkPhase == 1 then + frame = Avatars.GB_WALK[facing] + if (facing == "down" or facing == "up") and stepFlip then flip = true end + end + frame = frame or Avatars.GB_STAND[facing] or 0 + end + if frame >= entry.frames then frame = 0 end + return frame, flip +end + +local function shadeIndex(r) + if r > 0.83 then return 0 end + if r > 0.5 then return 1 end + if r > 0.17 then return 2 end + return 3 +end + +local function bakeShades(id, colors) + id:mapPixel(function(_, _, r, g, b, a) + local s = shadeIndex(r) + if s == 0 then return r, g, b, 0 end + local c = colors and colors[s + 1] + if not c then return r, g, b, a end + return c[1] / 255, c[2] / 255, c[3] / 255, a + end) +end + +function Avatars.imageData(entry) + if type(entry) ~= "table" or entry.standin then return nil end + if entry._imageData then return entry._imageData end + if not (love and love.image and love.image.newImageData) then return nil end + local src = entry.source + local id + if src.kind == "rgba" then + id = love.image.newImageData(entry.sheetW, entry.sheetH, "rgba8", src.bytes) + else + local data = love.filesystem.newFileData(src.bytes, "avatar.png") + id = love.image.newImageData(data) + end + entry._imageData = id + return id +end + +function Avatars.image(entry, colors) + if type(entry) ~= "table" or entry.standin then return nil end + local paletteKey = colors and table.concat({ tostring(colors[2] and colors[2][1]), + tostring(colors[3] and colors[3][1]), tostring(colors[4] and colors[4][1]), + tostring(colors[2] and colors[2][2]), tostring(colors[3] and colors[3][2]), + tostring(colors[4] and colors[4][3]) }, ",") or "own" + entry._images = entry._images or {} + local img = entry._images[paletteKey] + if img then return img end + if not (love and love.graphics and love.graphics.newImage) then return nil end + local base = Avatars.imageData(entry) + if not base then return nil end + local id = base + if entry.palette.mode ~= "rgba" then + id = base.clone and base:clone() or base + bakeShades(id, colors or entry.palette.colors) + end + img = love.graphics.newImage(id) + if img.setFilter then img:setFilter("nearest", "nearest") end + stats.images = stats.images + 1 + entry._images[paletteKey] = img + return img +end + +function Avatars.quads(entry) + if type(entry) ~= "table" or entry.standin then return nil end + if entry._quads then return entry._quads end + if not (love and love.graphics and love.graphics.newQuad) then return nil end + local q = {} + for i = 0, entry.frames - 1 do + local r = entry.rects[i] + q[i] = love.graphics.newQuad(r.x, r.y, r.w, r.h, entry.sheetW, entry.sheetH) + end + entry._quads = q + return q +end + +function Avatars.draw(entry, footX, footY, facing, walkPhase, stepFlip, scale, colors) + scale = scale or 1 + if type(entry) ~= "table" or entry.standin then return false end + local img, quads = Avatars.image(entry, colors), Avatars.quads(entry) + if not (img and quads) then return false end + local frame, flip = Avatars.pose(entry, facing, walkPhase, stepFlip) + local x = footX - entry.anchor.x * scale + local y = footY - entry.anchor.y * scale + if flip then + love.graphics.draw(img, quads[frame], x + entry.w * scale, y, 0, -scale, scale) + else + love.graphics.draw(img, quads[frame], x, y, 0, scale, scale) + end + return true +end + +Avatars.STANDIN_H = 16 + +function Avatars.drawStandin() + return false +end + +return Avatars diff --git a/src/online/union/Badge.lua b/src/online/union/Badge.lua new file mode 100644 index 00000000..b7cd764d --- /dev/null +++ b/src/online/union/Badge.lua @@ -0,0 +1,135 @@ +local Badge = {} + +Badge.SIZE = 9 + +Badge.MASK = { + "..#####..", + ".#.....#.", + "#.......#", + "#.......#", + "#.......#", + "#.......#", + "#.......#", + ".#.....#.", + "..#####..", +} + +Badge.DIGITS = { + [1] = { ".#.", "##.", ".#.", ".#.", "###" }, + [2] = { "##.", "..#", ".#.", "#..", "###" }, + [3] = { "###", "..#", ".##", "..#", "###" }, +} + +Badge.DIGIT_X = 3 +Badge.DIGIT_Y = 2 + +Badge.STYLES = { + [1] = { ring = { 8, 24, 32 }, fill = { 224, 248, 208 }, digit = { 8, 24, 32 } }, + [2] = { ring = { 24, 56, 160 }, fill = { 248, 248, 248 }, digit = { 24, 56, 160 } }, + [3] = { ring = { 56, 56, 64 }, fill = { 255, 255, 255 }, digit = { 208, 48, 48 } }, +} + +local images = {} +local builds = 0 + +local function inside(row, col) + local line = Badge.MASK[row] + local first = line:find("#", 1, true) + local last = #line - (line:reverse():find("#", 1, true) or 1) + 1 + return first ~= nil and col > first and col < last +end + +function Badge.styleOf(style) + if type(style) == "table" and style.ring and style.fill and style.digit then return style end + return Badge.STYLES[tonumber(style) or 3] or Badge.STYLES[3] +end + +function Badge.size(scale) + local s = math.max(1, math.floor(tonumber(scale) or 1)) + return Badge.SIZE * s, Badge.SIZE * s +end + +function Badge.pixels(digit, style) + local st = Badge.styleOf(style) + local glyph = Badge.DIGITS[tonumber(digit) or 0] + local out = {} + for row = 1, Badge.SIZE do + local line = Badge.MASK[row] + for col = 1, Badge.SIZE do + local c = nil + if line:sub(col, col) == "#" then + c = st.ring + elseif inside(row, col) then + c = st.fill + local gy, gx = row - Badge.DIGIT_Y, col - Badge.DIGIT_X + if glyph and gy >= 1 and gy <= 5 and gx >= 1 and gx <= 3 and glyph[gy]:sub(gx, gx) == "#" then + c = st.digit + end + end + if c then out[#out + 1] = { x = col - 1, y = row - 1, c = c } end + end + end + return out +end + +local function styleKey(st) + return table.concat({ st.ring[1], st.ring[2], st.ring[3], st.fill[1], st.fill[2], st.fill[3], + st.digit[1], st.digit[2], st.digit[3] }, ",") +end + +local function imageFor(digit, st) + if not (love and love.image and love.image.newImageData and love.graphics.newImage) then return nil end + local key = tostring(digit) .. "|" .. styleKey(st) + local hit = images[key] + if hit then return hit end + local id = love.image.newImageData(Badge.SIZE, Badge.SIZE) + for _, p in ipairs(Badge.pixels(digit, st)) do + id:setPixel(p.x, p.y, p.c[1] / 255, p.c[2] / 255, p.c[3] / 255, 1) + end + local img = love.graphics.newImage(id) + if img.setFilter then img:setFilter("nearest", "nearest") end + images[key] = img + builds = builds + 1 + return img +end + +function Badge.builds() + return builds +end + +function Badge.reset() + images = {} + builds = 0 +end + +function Badge.draw(x, y, digit, style, scale) + if not (love and love.graphics) then return false end + if not Badge.DIGITS[tonumber(digit) or 0] then return false end + local s = math.max(1, math.floor(tonumber(scale) or 1)) + local st = Badge.styleOf(style) + local img = imageFor(tonumber(digit), st) + local g = love.graphics + local r0, g0, b0, a0 = 1, 1, 1, 1 + if g.getColor then r0, g0, b0, a0 = g.getColor() end + if img then + g.setColor(1, 1, 1, 1) + g.draw(img, math.floor(x), math.floor(y), 0, s, s) + else + for _, p in ipairs(Badge.pixels(digit, st)) do + g.setColor(p.c[1] / 255, p.c[2] / 255, p.c[3] / 255, 1) + g.rectangle("fill", math.floor(x) + p.x * s, math.floor(y) + p.y * s, s, s) + end + end + g.setColor(r0, g0, b0, a0) + return true +end + +function Badge.drawBeside(nameRight, nameTop, nameHeight, digit, style, scale, gap) + local w, h = Badge.size(scale) + local x = nameRight + (gap or 1) * math.max(1, math.floor(tonumber(scale) or 1)) + local y = nameTop + math.floor(((nameHeight or h) - h) / 2) + Badge.draw(x, y, digit, style, scale) + return x + w, y, w, h +end + +return Badge diff --git a/src/online/union/BattlePrepModel.lua b/src/online/union/BattlePrepModel.lua new file mode 100644 index 00000000..f1362f6b --- /dev/null +++ b/src/online/union/BattlePrepModel.lua @@ -0,0 +1,1312 @@ +local Compat = require("src.online.xgen.Compat") +local Datasets = require("src.online.xgen.Datasets") +local Messages = require("src.online.xgen.Messages") +local Policy = require("src.online.xgen.Policy") +local Project = require("src.online.xgen.Project") +local Rentals = require("src.online.xgen.Rentals") +local TradeConvert = require("src.online.xgen.TradeConvert") +local Identity = require("src.online.xgen.Identity") + +local Model = {} +Model.__index = Model + +Model.STEPS = { "rules", "problems", "substitute", "moves", "size", "confirm", "waiting", "go", "closed" } + +local copy = Project.copy + +local TEXT = { + title_rules = "Union Battle", + title_problems = "Team Check", + title_substitute = "Substitutes", + title_moves = "Moves", + title_size = "Team Size", + title_confirm = "Final Check", + title_waiting = "Ready", + title_closed = "Union Battle", + title_changes = "Battle Rules", + title_rentals = "Rental Pokémon", + title_mon = "Battle Data", + + vs = "{name} wants to battle.", + vs_self = "Battle with {name}.", + plays = "{name} is playing {game}.", + rules_g3u = "Union rules: Pokémon No. 1 to {dexMax} with Gen {moveGen} moves and types.", + rules_native = "Link battle with the rules of Gen {gen}.", + waiting_rules = "Waiting for the rules…", + temporary = "Nothing is saved. Your Pokémon, items and Exp. stay as they are.", + + blocked_mods = "Gameplay mods are on. Union battles need the original game data. Turn the mods off and try again.", + blocked_data = "This game's data can't be read. Import it again from the launcher.", + blocked_proto = "The other game has a different Union Room version. Both players need to update.", + blocked_policy_mismatch = "The other game has a different Union Room version. Both players need to update.", + blocked_fingerprint = "Your games' data don't match. Both players need the same game version with no gameplay mods.", + blocked_missing_import = "A game needs to be imported first. Import it from the launcher.", + blocked_other = "This battle can't be set up.", + blocked_no_team = "You have no Pokémon that can battle.", + + problems_none = "Every Pokémon can battle.", + problems_some = "Some Pokémon need changes.", + egg_skipped = "Eggs stay out.", + + sub_intro = "Choose who takes its place.", + sub_owned = "Your {name}, {place}.", + sub_shares = "Shares a type with {want}.", + sub_primary = "Same main type as {want}.", + sub_other = "A different type.", + sub_level = "Level {level}.", + rental_tag = "RENTAL", + rental_info = "A rental Pokémon. It is only for this battle.", + leave_out = "It stays out of this battle.", + + move_pick = "Choose a move for its place.", + move_same_type = "Same type.", + move_same_band = "Similar power.", + move_empty = "The move slot is left empty.", + move_needs_one = "It needs at least one move.", + move_type = "Type {type}", + move_row = "Power {power} PP {pp}", + + size_mine = "You bring {n}.", + size_theirs = "{name} brings {n}.", + size_wait = "Waiting for {name}…", + size_agreed = "Each side uses {n}.", + size_sit_out = "Choose {n} to sit out.", + size_they_sit = "{name} chooses who sits out.", + size_asked = "You asked for {n} each.", + size_peer_asked = "{name} asks for {n} each.", + size_pick = "Ask for another team size. Both players must agree.", + size_out = "{name} sits out.", + size_in = "{name} battles.", + + confirm_intro = "Your team for this battle.", + confirm_note = "Check each Pokémon, then confirm.", + waiting = "Waiting for {name} to confirm…", + changed = "Something changed. Check your team and confirm again.", + rules_changed = "The rules changed. Your team is checked again.", + nack = "That didn't go through. Try again.", + + closed_cancel = "{name} cancelled the battle.", + closed_self = "The battle was cancelled.", + closed_timeout = "Nobody did anything for a while, so the battle was cancelled.", + closed_gone = "The link to {name} was lost.", + closed_other = "The battle was cancelled.", + + chg_engine = "Everyone battles with Gen 3 rules.", + chg_types = "Types and moves are the Gen {moveGen} ones.", + chg_items = "Held items are off.", + chg_abilities = "Abilities are off.", + chg_natures = "Natures are neutral.", + chg_special = "Special counts as both Sp. Atk and Sp. Def.", + chg_dv = "DVs become IVs: twice the DV, plus one.", + chg_statexp = "Stat Exp. becomes EVs: its square root, up to 255 each and 510 in all.", + chg_iv = "IVs and EVs stay as they are.", + chg_full = "HP and PP are full and status is cleared.", + chg_native = "The usual link battle rules of your games.", + + mon_stats = "HP {hp} Atk {atk}", + mon_stats2 = "Def {def} SpA {spa}", + mon_stats3 = "SpD {spd} Spe {spe}", + mon_move = "{name} {pp}/{max}", + mon_types = "Lv{level} {types}", + mon_level = "{name} Lv{level}", + mon_ivs = "IV {hp}/{atk}/{def}/{spa}/{spd}/{spe}", + mon_evs = "EV {hp}/{atk}/{def}/{spa}/{spd}/{spe}", + + item_continue = "Continue", + item_cancel = "Cancel", + item_back = "Back", + item_changes = "Rules", + item_rentals = "Rentals", + item_leave = "Leave out", + item_empty = "Empty", + item_done = "Done", + item_size = "Team size", + item_ready = "Confirm", + item_ok = "OK", + item_agree = "Agree to {n}", + item_size_n = "{n} each", +} +Model.TEXT = TEXT + +local function fill(template, args) + args = args or {} + return (template:gsub("{(%w+)}", function(key) + local v = args[key] + if v == nil then return "" end + return tostring(v) + end)) +end + +local function say(key, args) + return fill(TEXT[key] or key, args) +end +Model.say = say + +local function gameName(version) + local GameVersion = require("src.core.GameVersion") + local info = GameVersion.VERSIONS[version or ""] + return "Pokémon " .. (info and info.label or tostring(version or "?")) +end + +function Model.new(opts) + opts = opts or {} + local self = setmetatable({}, Model) + self.version = opts.version + self.gen = tonumber(opts.gen) or (self.version and require("src.core.GameVersion").generation(self.version)) or 1 + self.data = opts.data + if self.data == nil and self.version then self.data = Datasets.get(self.version) end + self.prep = opts.prep + self.opponent = copy(opts.opponent or {}) + self.opponent.name = self.opponent.name or "?" + self.gameplayMods = opts.gameplayMods == true + self.rulesOverride = opts.rules + self.ownedSource = opts.owned + self.step = "rules" + self.cursor = 1 + self.view = nil + self.notice = nil + self.done = false + self.outcome = nil + self.events = {} + self.final = nil + self.goInfo = nil + self.sent = { roster = nil, sizeReq = nil, ready = nil } + self:reset() + return self +end + +function Model:emit(kind, fields) + local e = { kind = kind } + for k, v in pairs(fields or {}) do e[k] = v end + self.events[#self.events + 1] = e +end + +function Model:rules() + if self.prep and self.prep.rules then return self.prep.rules end + return self.rulesOverride +end + +local function rulesSig(r) + if type(r) ~= "table" then return "" end + return TradeConvert.canonical({ r.ruleset, r.gen, r.dexMax, r.moveMax, r.moveGen }) +end + +local function hasLegacy(r) + for _, g in ipairs(r.gens or {}) do + if tonumber(g) and tonumber(g) < 3 then return true end + end + return r.ruleset == "g3u" +end + +function Model:target() + local r = self:rules() + if type(r) ~= "table" then return nil end + if r.ruleset == "native" then + return { ruleset = "native", gen = r.gen } + end + local moveGen = tonumber(r.moveGen) or 1 + return { ruleset = "g3u-gen" .. moveGen, gen = moveGen, dexMax = r.dexMax, moveMax = r.moveMax, + legacyPresent = hasLegacy(r) } +end + +function Model:isNative() + local r = self:rules() + return type(r) == "table" and r.ruleset == "native" +end + +local function ownedFrom(source) + local TeamPick = require("src.online.TeamPick") + local out = {} + for _, c in ipairs(TeamPick.candidates(source or {})) do + out[#out + 1] = { rec = c.mon, ref = { where = c.where, box = c.box, index = c.index }, place = c.source } + end + return out +end +Model.ownedFrom = ownedFrom + +function Model:reset() + self.owned = self.ownedSource and (self.ownedSource.list or ownedFrom(self.ownedSource)) or {} + self.team = {} + self.eggs = 0 + local data = self.data + for i, o in ipairs(self.owned) do + if o.ref.where == "party" then + local view = type(data) == "table" and Project.read(o.rec, data) or nil + if view and view.isEgg then + self.eggs = self.eggs + 1 + else + self.team[#self.team + 1] = { base = i, moves = {} } + end + end + end + self.sizeChoice = nil + self.final = nil + self.sent = { roster = nil, sizeReq = nil, ready = nil } + self.sig = rulesSig(self:rules()) + self.memo, self.memoCount, self.pageMemo = nil, nil, nil + self:rebuildRules() +end + +function Model:rebuildRules() + self.unsupported = {} + self.rentalSet = { rentals = {}, excluded = {} } + local target = self:target() + if not target or target.ruleset == "native" or type(self.data) ~= "table" then return end + if self.data.generation == target.gen and self.data.generation <= 2 then + local Table = require("src.battle.g3u.Table") + for _, row in ipairs(Table.unsupportedMoves(self.data)) do self.unsupported[#self.unsupported + 1] = row.id end + end + self.rentalSet = Rentals.build(target.ruleset, self.data, { unsupported = self.unsupported, + legacyPresent = target.legacyPresent }) +end + +function Model:entries() + local out = {} + for _, e in ipairs(self.team) do + if not e.left then out[#out + 1] = e end + end + return out +end + +function Model:peerSize() + local p = self.prep + return p and p.peer and p.peer.roster and p.peer.roster.size or nil +end + +function Model:agreedSize() + return self.prep and self.prep.size or nil +end + +function Model:args(withSize) + local list = self:entries() + local mons, replace, moves, used, sitOut = {}, {}, {}, {}, {} + for i, e in ipairs(list) do + mons[i] = self.owned[e.base].rec + if e.swap then replace[i] = copy(e.swap) end + moves[i] = copy(e.moves) + used[#used + 1] = e.base + if e.swap and e.swap.owned then used[#used + 1] = e.swap.owned end + if withSize and e.sitOut then sitOut[#sitOut + 1] = i end + end + local adj = { replace = replace, moves = moves } + local opp = nil + if withSize then + adj.sitOut = sitOut + adj.size = self:agreedSize() + opp = self:peerSize() or #mons + end + return { + op = "battle", source = { game = self.version, gen = self.gen, data = self.data }, + target = self:target(), mons = mons, owned = self.owned, ownedInTeam = used, + rentals = self.rentalSet.rentals, adjustments = adj, opponentSize = opp, + unsupported = self.unsupported, versions = { policy = Policy.VERSION, proto = Policy.PROTO }, + }, list +end + +function Model:prepKey() + local p = self.prep + if not p then return "" end + return TradeConvert.canonical({ p.rev, p.size, p.peer.roster and p.peer.roster.size, p.mine.sizeReq, + p.peer.sizeReq, p.mine.ready and true, p.peer.ready and true, p.state, p.blocked, rulesSig(p.rules), + p.canReady and p:canReady() }) +end + +function Model:teamKey() + local t = {} + for i, e in ipairs(self.team) do + t[i] = { e.base, e.swap and e.swap.owned, e.swap and e.swap.rental, e.left, e.sitOut, e.moves } + end + return TradeConvert.canonical(t) +end + +function Model:report(withSize) + local key = (withSize and "1" or "0") .. self:teamKey() .. self:prepKey() + self.memo = self.memo or {} + local hit = self.memo[key] + if hit then return hit.r, hit.list end + local args, list = self:args(withSize) + local r = Compat.report(args) + if self.memoCount and self.memoCount > 32 then self.memo, self.memoCount = {}, 0 end + self.memo[key] = { r = r, list = list } + self.memoCount = (self.memoCount or 0) + 1 + return r, list +end + +function Model:blockReason() + if self.gameplayMods then return "blocked_mods" end + if type(self.data) ~= "table" then return "blocked_data" end + local p = self.prep + if p and p.blocked then + local key = "blocked_" .. tostring(p.blocked) + return TEXT[key] and key or "blocked_other" + end + if #self.team == 0 then return "blocked_no_team" end + return nil +end + +function Model:names() + local data = self.data + return { + species = function(n) + local sp = type(data) == "table" and data.species[tonumber(n) or -1] + return sp and sp.name or ("No. " .. tostring(n)) + end, + move = function(id) + local mv = type(data) == "table" and data.moves[tonumber(id) or -1] + return mv and mv.name or ("No. " .. tostring(id)) + end, + } +end + +function Model:monName(rec) + local view = type(self.data) == "table" and Project.read(rec, self.data) or nil + if not view then return "?" end + return view.nickname or view.speciesName or "?" +end + +function Model:entryRec(e) + if e.swap and e.swap.owned then return self.owned[e.swap.owned].rec end + return self.owned[e.base].rec +end + +function Model:entryRental(e) + if e.swap and e.swap.rental then return self.rentalSet.rentals[e.swap.rental] end + return nil +end + +function Model:entryName(e) + local rental = self:entryRental(e) + if rental then return rental.name end + return self:monName(self:entryRec(e)) +end + +function Model:speciesBlock(rec) + local target = self:target() + if not target or target.ruleset == "native" then return nil end + local view, code, detail = Project.read(rec, self.data) + if not view then return { code = code, detail = detail } end + if view.isEgg then return { code = "egg" } end + local ok, why, info = Compat.speciesEligible(view.national, Compat.resolveRuleset(target)) + if not ok then return { code = why, detail = info, view = view } end + return nil +end + +function Model:subQueue() + local out = {} + for _, e in ipairs(self.team) do + if self:speciesBlock(self.owned[e.base].rec) then out[#out + 1] = e end + end + return out +end + +function Model:moveQueue() + local out = {} + local target = self:target() + if not target or target.ruleset == "native" then return out end + local ruleset = Compat.resolveRuleset(target) + local unsupported = Compat.asSet(self.unsupported) + for _, e in ipairs(self:entries()) do + if not self:entryRental(e) then + local rec = self:entryRec(e) + if not self:speciesBlock(rec) then + local view = Project.read(rec, self.data) + for j, m in ipairs(view.moves) do + local status = Compat.moveStatus(self.data, view.national, view.level, m.move, ruleset, unsupported) + if status then + out[#out + 1] = { entry = e, index = j, move = m.move, code = status, view = view } + end + end + end + end + end + return out +end + +function Model:problemLines() + local lines = {} + local names = self:names() + for _, e in ipairs(self.team) do + local rec = self.owned[e.base].rec + local sb = self:speciesBlock(rec) + if sb then + local detail = copy(sb.detail or {}) + detail.national = detail.national or (sb.view and sb.view.national) + lines[#lines + 1] = Messages.text(sb.code, detail, names) + end + end + for _, q in ipairs(self:moveQueueForBase()) do + lines[#lines + 1] = Messages.about(q.name, q.code, { move = q.move, national = q.view.national }, names) + end + return lines +end + +function Model:moveQueueForBase() + local out = {} + local target = self:target() + if not target or target.ruleset == "native" then return out end + local ruleset = Compat.resolveRuleset(target) + local unsupported = Compat.asSet(self.unsupported) + for _, e in ipairs(self.team) do + local rec = self.owned[e.base].rec + if not self:speciesBlock(rec) then + local view = Project.read(rec, self.data) + for j, m in ipairs(view.moves) do + local status = Compat.moveStatus(self.data, view.national, view.level, m.move, ruleset, unsupported) + if status then + out[#out + 1] = { entry = e, index = j, move = m.move, code = status, view = view, + name = view.nickname or view.speciesName } + end + end + end + end + return out +end + +function Model:replacementOptions(e) + local target = self:target() + local ruleset = Compat.resolveRuleset(target) + local base = self.owned[e.base].rec + local view = Project.read(base, self.data) or { national = -1, level = 50 } + local exclude = {} + for _, other in ipairs(self.team) do + exclude[other.base] = true + if other ~= e and not other.left and other.swap and other.swap.owned then exclude[other.swap.owned] = true end + end + local owned = Compat.replacements(self.data, view, self.owned, ruleset, exclude, self.unsupported) + local usedRental = {} + for _, other in ipairs(self.team) do + if other ~= e and not other.left and other.swap and other.swap.rental then usedRental[other.swap.rental] = true end + end + local rentals = {} + for _, row in ipairs(Compat.rentalCandidates(self.data, view, self.rentalSet.rentals)) do + if not usedRental[row.index] then rentals[#rentals + 1] = row end + end + return owned, rentals, view +end + +function Model:moveOptions(q) + local target = self:target() + local ruleset = Compat.resolveRuleset(target) + local exclude = {} + for _, m in ipairs(q.view.moves) do exclude[m.move] = true end + for j, v in pairs(q.entry.moves) do + if j ~= q.index and tonumber(v) and v ~= 0 then exclude[v] = true end + end + return Compat.moveSuggestions(self.data, q.view.national, q.view.level, ruleset, q.move, exclude, + Compat.asSet(self.unsupported)) +end + +function Model:canEmpty(q) + local queue = self:moveQueue() + local illegal = {} + for _, other in ipairs(queue) do + if other.entry == q.entry then illegal[other.index] = other end + end + local count = 0 + for j in ipairs(q.view.moves) do + if not illegal[j] then + count = count + 1 + elseif j ~= q.index then + local v = q.entry.moves[j] + if v == nil or (tonumber(v) and v ~= 0) then count = count + 1 end + end + end + return count > 0 +end + +function Model:go(step, notice) + self.step = step + self.cursor = 1 + self.view = nil + self.notice = notice + self.subIndex = self.subIndex or 1 + self.moveIndex = self.moveIndex or 1 + self:emit("step", { step = step }) +end + +function Model:send(kind, ...) + local p = self.prep + if not p then return false end + if kind == "roster" then return p:roster(...) end + if kind == "sizeReq" then return p:sizeRequest(...) end + if kind == "ready" then return p:ready(...) end + return false +end + +function Model:rosterRecords() + local r = self:report(false) + if not r.ok then return nil, r end + return r.result.team, r +end + +function Model:syncRoster() + local team = self:rosterRecords() + if not team then return false end + local digest = TradeConvert.digest(team) + local s = self.sent.roster + if s and s.digest == digest and s.size == #team then return true end + self.sent.roster = { digest = digest, size = #team } + self:send("roster", #team, digest) + return true +end + +function Model:finalReport() + return self:report(true) +end + +function Model:needSitOut() + local size = self:agreedSize() + if not size then return 0 end + local n = #self:entries() - size + return n > 0 and n or 0 +end + +function Model:sitOutCount() + local n = 0 + for _, e in ipairs(self:entries()) do + if e.sitOut then n = n + 1 end + end + return n +end + +function Model:sizeSettled() + if not self:agreedSize() then return false end + local r = self:finalReport() + return r.ok +end + +function Model:enterSubstitute(fromEnd) + local q = self:subQueue() + if #q == 0 then return self:enterMoves(fromEnd) end + self.subIndex = fromEnd and #q or 1 + self:go("substitute") +end + +function Model:enterMoves(fromEnd) + local q = self:moveQueue() + if #q == 0 then + if fromEnd then return self:enterSubstituteBack() end + return self:enterSize() + end + self.moveIndex = fromEnd and #q or 1 + self:go("moves") +end + +function Model:enterSubstituteBack() + local q = self:subQueue() + if #q == 0 then return self:go("problems") end + self.subIndex = #q + self:go("substitute") +end + +function Model:enterSize() + local r = self:report(false) + if not r.ok then + self:go("problems", say("changed")) + return + end + self:syncRoster() + self:go("size") +end + +function Model:enterConfirm(notice) + local r = self:finalReport() + if not r.ok then + self:go("size", notice) + return + end + self:go("confirm", notice) +end + +function Model:discard() + self.team = {} + self.owned = {} + self.final = nil + self.rentalSet = { rentals = {}, excluded = {} } + self.view = nil + self.memo, self.memoCount, self.pageMemo = nil, nil, nil +end + +function Model:close(outcome, notice) + if self.step == "closed" then return end + self.outcome = outcome + self:discard() + self:go("closed", notice) +end + +function Model:cancel(why) + if self.step == "go" or self.step == "closed" then return end + if self.prep then self.prep:cancel(why or "cancel") end + self:close("cancel", say("closed_self")) +end + +local CLOSED_TEXT = { cancel = "closed_cancel", timeout = "closed_timeout", gone = "closed_gone" } + +function Model:handlePrepEvent(e) + if e.kind == "rules" then + local sig = rulesSig(self:rules()) + if sig ~= self.sig then + local had = self.sig ~= "" + self:reset() + if had then self:go("rules", say("rules_changed")) end + end + elseif e.kind == "closed" then + local mine = e.seat ~= nil and self.prep and e.seat == self.prep:seat() + local key = mine and "closed_self" or CLOSED_TEXT[e.why or ""] or "closed_other" + self:close("closed", say(key, { name = self.opponent.name })) + elseif e.kind == "nack" then + if e.of == "xg_roster" then self.sent.roster = nil end + if e.of == "xg_size_req" then self.sent.sizeReq = nil end + if e.of == "xg_ready" then + self.sent.ready = nil + if self.step == "waiting" then self:enterConfirm(say("nack")) end + end + elseif e.kind == "go" then + if self.step == "waiting" and self.final then + self.goInfo = copy(e.go) + self:go("go") + self.outcome = "go" + self.done = true + end + end +end + +function Model:poll() + local p = self.prep + if p then + for _, e in ipairs(p:poll()) do self:handlePrepEvent(e) end + if self.step ~= "closed" and self.step ~= "go" then + if p.state == "closed" then + self:close("closed", say(CLOSED_TEXT[p.closed and p.closed.why or ""] or "closed_other", { name = self.opponent.name })) + elseif self.step == "waiting" and not p.mine.ready then + self.sent.ready = nil + self.final = nil + self:enterConfirm(say("changed")) + elseif self.step == "confirm" and not self:sizeSettled() then + self:go("size", say("changed")) + elseif (self.step == "size" or self.step == "confirm") and not self.sent.roster then + self:syncRoster() + end + end + end + local out = self.events + self.events = {} + return out +end + +function Model:style() + return Messages.style(self.gen) +end + +function Model:levelLabel(level) + return "Lv" .. tostring(level) +end + +function Model:changeLines() + local lines = {} + local r = self:rules() or {} + if r.ruleset == "native" then + lines[#lines + 1] = say("chg_native") + else + lines[#lines + 1] = say("chg_engine") + lines[#lines + 1] = say("chg_types", { moveGen = r.moveGen }) + if hasLegacy(r) then + lines[#lines + 1] = say("chg_items") + lines[#lines + 1] = say("chg_abilities") + lines[#lines + 1] = say("chg_natures") + end + if self.gen == 1 then lines[#lines + 1] = say("chg_special") end + if self.gen <= 2 then + lines[#lines + 1] = say("chg_dv") + lines[#lines + 1] = say("chg_statexp") + else + lines[#lines + 1] = say("chg_iv") + end + lines[#lines + 1] = say("chg_full") + end + lines[#lines + 1] = say("temporary") + return lines +end + +function Model:rentalCoverage() + local target = self:target() + local out = {} + if not target or target.ruleset == "native" then return out end + local types = target.gen == 1 and Identity.GEN1_TYPES or Identity.TYPES + local byType = {} + for _, rental in ipairs(self.rentalSet.rentals) do byType[rental.type] = byType[rental.type] or rental end + for _, t in ipairs(types) do + out[#out + 1] = { type = t, rental = byType[t] } + end + return out +end + +function Model:rentalLines(rental) + local lines = {} + lines[#lines + 1] = TEXT.rental_tag .. " " .. rental.name + lines[#lines + 1] = say("mon_types", { level = rental.level, types = table.concat(rental.types, "/") }) + local s = rental.stats + lines[#lines + 1] = say("mon_stats", { hp = s.hp, atk = s.atk }) + lines[#lines + 1] = say("mon_stats2", { def = s.def, spa = s.spAtk }) + lines[#lines + 1] = say("mon_stats3", { spd = s.spDef, spe = s.speed }) + for _, mv in ipairs(rental.moves) do + lines[#lines + 1] = say("mon_move", { name = mv.name, pp = mv.pp, max = mv.maxPp }) + end + lines[#lines + 1] = say("rental_info") + return lines +end + +function Model:displayRec(rec) + if type(rec) ~= "table" or rec.national ~= nil or type(self.data) ~= "table" then return rec end + local view = Project.read(rec, self.data) + if not view then return rec end + local moves = {} + for i, m in ipairs(view.moves) do moves[i] = { id = m.move, pp = m.pp } end + local st = type(rec.stats) == "table" and rec.stats or {} + return { + national = view.national, level = view.level, nickname = view.nickname, moves = moves, + maxHp = rec.maxHp or rec.maxHP or st.hp, atk = rec.atk or rec.attack or st.attack, + def = rec.def or rec.defense or st.defense, spAtk = rec.spAtk or rec.special or st.spAtk or st.special, + spDef = rec.spDef or rec.special or st.spDef or st.special, speed = rec.speed or st.speed, + } +end + +function Model:recordLines(rec, changes, short) + rec = self:displayRec(rec) + local lines = {} + local names = self:names() + local tag = rec.rental and (TEXT.rental_tag .. " ") or "" + lines[#lines + 1] = tag .. say("mon_level", { name = rec.nickname or names.species(rec.national), level = rec.level }) + lines[#lines + 1] = say("mon_stats", { hp = rec.maxHp, atk = rec.atk }) + lines[#lines + 1] = say("mon_stats2", { def = rec.def, spa = rec.spAtk }) + lines[#lines + 1] = say("mon_stats3", { spd = rec.spDef, spe = rec.speed }) + for _, m in ipairs(rec.moves or {}) do + lines[#lines + 1] = say("mon_move", { name = names.move(m.id), pp = m.pp, max = m.pp }) + end + if short then return lines end + if rec.rental then lines[#lines + 1] = say("rental_info") end + if type(rec.ivs) == "table" then lines[#lines + 1] = say("mon_ivs", rec.ivs) end + if type(rec.evs) == "table" then lines[#lines + 1] = say("mon_evs", rec.evs) end + for _, c in ipairs(changes or {}) do + if c.field ~= "species" and c.field ~= "moves" then + lines[#lines + 1] = Messages.change(c, 3)[1] + end + end + return lines +end + +local function item(id, label, detail, extra) + local out = { id = id, label = label, detail = detail } + for k, v in pairs(extra or {}) do out[k] = v end + return out +end + +function Model:pageRules() + local lines = {} + local r = self:rules() + lines[#lines + 1] = say("vs", { name = self.opponent.name }) + if self.opponent.version then + lines[#lines + 1] = say("plays", { name = self.opponent.name, game = gameName(self.opponent.version) }) + end + local why = self:blockReason() + local items = {} + if why then + lines[#lines + 1] = say(why) + items[#items + 1] = item("cancel", TEXT.item_cancel) + return { title = TEXT.title_rules, lines = lines, items = items } + end + if not r then + lines[#lines + 1] = say("waiting_rules") + items[#items + 1] = item("cancel", TEXT.item_cancel) + return { title = TEXT.title_rules, lines = lines, items = items } + end + if r.ruleset == "native" then + lines[#lines + 1] = say("rules_native", { gen = r.gen }) + else + lines[#lines + 1] = say("rules_g3u", { dexMax = r.dexMax, moveMax = r.moveMax, moveGen = r.moveGen }) + end + items[#items + 1] = item("continue", TEXT.item_continue) + items[#items + 1] = item("changes", TEXT.item_changes) + if r.ruleset ~= "native" then items[#items + 1] = item("rentals", TEXT.item_rentals) end + items[#items + 1] = item("cancel", TEXT.item_cancel) + return { title = TEXT.title_rules, lines = lines, items = items } +end + +function Model:pageProblems() + local lines = self:problemLines() + if #lines == 0 then + lines[1] = say("problems_none") + else + table.insert(lines, 1, say("problems_some")) + end + if self.eggs > 0 then lines[#lines + 1] = say("egg_skipped") end + return { title = TEXT.title_problems, lines = lines, items = { + item("continue", TEXT.item_continue), item("back", TEXT.item_back), item("cancel", TEXT.item_cancel) } } +end + +function Model:pageSubstitute() + local q = self:subQueue() + local e = q[self.subIndex] + if not e then return self:pageProblems() end + local base = self.owned[e.base].rec + local wantName = self:monName(base) + local names = self:names() + local sb = self:speciesBlock(base) or {} + local detail = copy(sb.detail or {}) + detail.national = detail.national or (sb.view and sb.view.national) + local lines = { Messages.text(sb.code or "bad_record", detail, names), say("sub_intro", { name = wantName }) } + local owned, rentals = self:replacementOptions(e) + local items = {} + for _, row in ipairs(owned) do + local o = self.owned[row.index] + local why = row.sharesType and say("sub_shares", { want = wantName }) + or row.samePrimary and say("sub_primary", { want = wantName }) or say("sub_other") + items[#items + 1] = item("swap_owned", row.name .. " " .. self:levelLabel(row.level), + { say("sub_owned", { name = row.name, place = o.place }), say("sub_level", { level = row.level }), why }, + { arg = row.index, chosen = e.swap and e.swap.owned == row.index }) + end + for _, row in ipairs(rentals) do + local rental = self.rentalSet.rentals[row.index] + local lines2 = self:rentalLines(rental) + if row.sharesType then table.insert(lines2, 2, say("sub_shares", { want = wantName })) end + items[#items + 1] = item("swap_rental", TEXT.rental_tag .. " " .. rental.name, lines2, + { arg = row.index, chosen = e.swap and e.swap.rental == row.index }) + end + if #self:entries() > 1 or e.left then + items[#items + 1] = item("leave", TEXT.item_leave, { say("leave_out") }, { chosen = e.left == true }) + end + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_substitute, lines = lines, items = items, + progress = { self.subIndex, #q } } +end + +function Model:pageMoves() + local q = self:moveQueue() + local cur = q[self.moveIndex] + if not cur then return self:pageProblems() end + local names = self:names() + local name = self:entryName(cur.entry) + local lines = { + Messages.about(name, cur.code, { move = cur.move, national = cur.view.national }, names), + say("move_pick"), + } + local items = {} + local chosen = cur.entry.moves[cur.index] + for _, row in ipairs(self:moveOptions(cur)) do + local mv = self.data.moves[row.move] + local d = { say("move_type", { type = mv.type or "?" }), say("move_row", { power = mv.power, pp = Policy.maxPp(mv.pp, 0) }) } + if row.sameType then d[#d + 1] = say("move_same_type") end + if row.sameBand then d[#d + 1] = say("move_same_band") end + items[#items + 1] = item("move", row.name, d, { arg = row.move, chosen = chosen == row.move }) + end + items[#items + 1] = item("empty", TEXT.item_empty, { say(self:canEmpty(cur) and "move_empty" or "move_needs_one") }, + { disabled = not self:canEmpty(cur), why = say("move_needs_one"), chosen = chosen == 0 }) + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_moves, lines = lines, items = items, progress = { self.moveIndex, #q } } +end + +function Model:pageSize() + local lines = {} + local list = self:entries() + lines[#lines + 1] = say("size_mine", { n = #list }) + local peer = self:peerSize() + local size = self:agreedSize() + local items = {} + if not peer or not size then + lines[#lines + 1] = say("size_wait", { name = self.opponent.name }) + else + lines[#lines + 1] = say("size_theirs", { name = self.opponent.name, n = peer }) + lines[#lines + 1] = say("size_agreed", { n = size }) + end + local p = self.prep + if p and p.mine.sizeReq then lines[#lines + 1] = say("size_asked", { n = p.mine.sizeReq }) end + local peerReq = p and p.peer.sizeReq + if peerReq then lines[#lines + 1] = say("size_peer_asked", { name = self.opponent.name, n = peerReq }) end + local need = self:needSitOut() + if size and need > 0 then + lines[#lines + 1] = say("size_sit_out", { n = need }) + for i, e in ipairs(list) do + local tag = self:entryRental(e) and (TEXT.rental_tag .. " ") or "" + items[#items + 1] = item("toggle", (e.sitOut and "- " or "") .. tag .. self:entryName(e), + { say(e.sitOut and "size_out" or "size_in", { name = self:entryName(e) }) }, { arg = i, chosen = e.sitOut == true }) + end + elseif size and peer and peer > size then + lines[#lines + 1] = say("size_they_sit", { name = self.opponent.name }) + end + if size then + local settled = self:sizeSettled() + items[#items + 1] = item("continue", need > 0 and TEXT.item_done or TEXT.item_continue, nil, + { disabled = not settled, why = need > 0 and say("size_sit_out", { n = need }) or nil }) + if peerReq and peerReq ~= size and peerReq <= #list and p.mine.sizeReq ~= peerReq then + items[#items + 1] = item("agree", say("item_agree", { n = peerReq }), nil, { arg = peerReq }) + end + if math.min(#list, peer or #list) > 1 then items[#items + 1] = item("size", TEXT.item_size) end + end + items[#items + 1] = item("back", TEXT.item_back) + items[#items + 1] = item("cancel", TEXT.item_cancel) + return { title = TEXT.title_size, lines = lines, items = items } +end + +function Model:pageSizePick() + local list = self:entries() + local max = math.min(#list, self:peerSize() or #list) + local items = {} + for n = 1, max do items[#items + 1] = item("size_n", say("item_size_n", { n = n }), nil, { arg = n }) end + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_size, lines = { say("size_pick") }, items = items } +end + +function Model:pageConfirm() + local r = self:finalReport() + local lines = { say("confirm_intro"), say("confirm_note") } + local items = {} + local team = r.ok and r.result.team or {} + local slots = r.ok and r.result.slots or {} + local changesBy = {} + for _, c in ipairs(r.changes or {}) do + changesBy[c.slot] = changesBy[c.slot] or {} + table.insert(changesBy[c.slot], c) + end + local names = self:names() + for i, raw in ipairs(team) do + local rec = self:displayRec(raw) + local label = (rec.rental and (TEXT.rental_tag .. " ") or "") .. (rec.nickname or names.species(rec.national)) + items[#items + 1] = item("mon", label, self:recordLines(rec, changesBy[slots[i]], true), + { arg = i, full = self:recordLines(rec, changesBy[slots[i]]) }) + end + local canReady = r.ok and (not self.prep or self.prep:canReady()) + items[#items + 1] = item("ready", TEXT.item_ready, nil, { disabled = not canReady }) + items[#items + 1] = item("changes", TEXT.item_changes) + items[#items + 1] = item("back", TEXT.item_back) + items[#items + 1] = item("cancel", TEXT.item_cancel) + return { title = TEXT.title_confirm, lines = lines, items = items, keepBody = false } +end + +function Model:pageWaiting() + return { title = TEXT.title_waiting, lines = { say("waiting", { name = self.opponent.name }) }, + items = { item("unready", TEXT.item_back), item("cancel", TEXT.item_cancel) } } +end + +function Model:pageClosed() + return { title = TEXT.title_closed, lines = {}, items = { item("ok", TEXT.item_ok) } } +end + +function Model:pageView() + local v = self.view + if v.kind == "changes" then + return { title = TEXT.title_changes, lines = self:changeLines(), items = {}, pager = true } + elseif v.kind == "rentals" then + local items = {} + for _, row in ipairs(self:rentalCoverage()) do + if row.rental then + items[#items + 1] = item("info", row.type .. " " .. row.rental.name, self:rentalLines(row.rental)) + end + end + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_rentals, lines = {}, items = items } + elseif v.kind == "mon" then + return { title = TEXT.title_mon, lines = v.lines, items = {}, pager = true } + elseif v.kind == "size" then + return self:pageSizePick() + end + return { title = "", lines = {}, items = {} } +end + +local PAGES = { + rules = "pageRules", problems = "pageProblems", substitute = "pageSubstitute", moves = "pageMoves", + size = "pageSize", confirm = "pageConfirm", waiting = "pageWaiting", closed = "pageClosed", +} + +function Model:rawPage() + if self.view then return self:pageView() end + local fn = PAGES[self.step] + if not fn then return { title = "", lines = {}, items = {} } end + return self[fn](self) +end + +function Model:page() + local v = self.view + local key = TradeConvert.canonical({ self.step, v and v.kind, v and v.lines and #v.lines, self.subIndex, + self.moveIndex, self.outcome }) .. self:teamKey() .. self:prepKey() + local raw = self.pageMemo and self.pageMemo.key == key and self.pageMemo.page + if not raw then + raw = self:rawPage() + self.pageMemo = { key = key, page = raw } + end + local pg = {} + for k, x in pairs(raw) do pg[k] = x end + pg.lines = {} + if self.notice and not self.view then + pg.lines[1] = self.notice + pg.notice = true + end + for _, line in ipairs(raw.lines or {}) do pg.lines[#pg.lines + 1] = line end + pg.step = self.view and self.view.kind or self.step + if #pg.items > 0 then + if self.cursor > #pg.items then self.cursor = #pg.items end + if self.cursor < 1 then self.cursor = 1 end + end + pg.cursor = self.cursor + pg.scroll = self.view and self.view.scroll or 0 + local sel = pg.items[self.cursor] + pg.info = sel and sel.detail or nil + return pg +end + +function Model:openView(kind, extra) + self.view = { kind = kind, scroll = 0, parentCursor = self.cursor } + for k, v in pairs(extra or {}) do self.view[k] = v end + self.cursor = 1 +end + +function Model:closeView() + local v = self.view + self.view = nil + self.cursor = v and v.parentCursor or 1 +end + +function Model:choose(it) + if not it then return end + if it.disabled then + if it.why then self.notice = it.why end + return + end + local id = it.id + if id == "cancel" then return self:cancel("cancel") end + if self.view then + if id == "back" then return self:closeView() end + if self.view.kind == "size" and id == "size_n" then + self:closeView() + self.sent.sizeReq = it.arg + self:send("sizeReq", it.arg) + end + return + end + local step = self.step + if step == "closed" then + self.done = true + return + end + if id == "changes" then return self:openView("changes") end + if id == "rentals" then return self:openView("rentals") end + if step == "rules" then + if id == "continue" then self:go("problems") end + elseif step == "problems" then + if id == "continue" then self:enterSubstitute() elseif id == "back" then self:go("rules") end + elseif step == "substitute" then + local q = self:subQueue() + local e = q[self.subIndex] + if id == "back" then + if self.subIndex > 1 then + self.subIndex = self.subIndex - 1 + self.cursor = 1 + else + self:go("problems") + end + return + end + if not e then return end + if id == "swap_owned" then + e.swap, e.left, e.moves = { owned = it.arg }, nil, {} + elseif id == "swap_rental" then + e.swap, e.left, e.moves = { rental = it.arg }, nil, {} + elseif id == "leave" then + e.swap, e.left, e.moves = nil, true, {} + end + self:clearSent() + if self.subIndex < #q then + self.subIndex = self.subIndex + 1 + self.cursor = 1 + else + self:enterMoves() + end + elseif step == "moves" then + local q = self:moveQueue() + local cur = q[self.moveIndex] + if id == "back" then + if self.moveIndex > 1 then + self.moveIndex = self.moveIndex - 1 + self.cursor = 1 + else + self:enterSubstituteBack() + end + return + end + if not cur then return end + if id == "move" then + cur.entry.moves[cur.index] = it.arg + elseif id == "empty" then + if not self:canEmpty(cur) then + self.notice = say("move_needs_one") + return + end + cur.entry.moves[cur.index] = 0 + end + self:clearSent() + if self.moveIndex < #q then + self.moveIndex = self.moveIndex + 1 + self.cursor = 1 + else + self:enterSize() + end + elseif step == "size" then + if id == "toggle" then + local e = self:entries()[it.arg] + if e then e.sitOut = not e.sitOut or nil end + elseif id == "continue" then + self:enterConfirm() + elseif id == "size" then + self:openView("size") + elseif id == "agree" then + self.sent.sizeReq = it.arg + self:send("sizeReq", it.arg) + elseif id == "back" then + self:enterMoves(true) + end + elseif step == "confirm" then + if id == "mon" then + return self:openView("mon", { lines = it.full or it.detail }) + elseif id == "ready" then + return self:ready() + elseif id == "back" then + self:go("size") + end + elseif step == "waiting" then + if id == "unready" then + self.final = nil + self.sent.ready = nil + self.sent.roster = nil + self:syncRoster() + self:go("confirm") + end + end +end + +function Model:clearSent() + self.sent.roster = nil + self.final = nil +end + +function Model:partyIndices(slots, list) + local out = {} + for _, slot in ipairs(slots or {}) do + local e = list and list[slot] + local o = e and self.owned[(e.swap and e.swap.owned) or e.base] + if o and o.ref and o.ref.where == "party" then out[#out + 1] = o.ref.index end + end + return out +end + +function Model:ready() + local r, list = self:finalReport() + if not r.ok then return false end + if self.prep and not self.prep:canReady() then return false end + self.final = { records = copy(r.result.team), size = r.result.size, slots = copy(r.result.slots), + team = self:partyIndices(r.result.slots, list), digest = TradeConvert.digest(r.result.team) } + self.sent.ready = self.final.digest + self:send("ready", self.final.digest) + self:go("waiting") + return true +end + +function Model:input(key) + if self.done then return end + local pg = self:page() + local n = #pg.items + if pg.pager then + if key == "up" then + self.view.scroll = math.max(0, (self.view.scroll or 0) - 1) + elseif key == "down" then + self.view.scroll = math.min(math.max(0, #pg.lines - 1), (self.view.scroll or 0) + 1) + elseif key == "a" or key == "b" then + self:closeView() + end + return + end + if key == "up" and n > 0 then + self.cursor = self.cursor > 1 and self.cursor - 1 or n + elseif key == "down" and n > 0 then + self.cursor = self.cursor < n and self.cursor + 1 or 1 + elseif key == "a" then + self.notice = nil + self:choose(pg.items[self.cursor]) + elseif key == "b" then + self.notice = nil + if self.view then return self:closeView() end + if self.step == "closed" then self.done = true return end + for _, it in ipairs(pg.items) do + if it.id == "back" or it.id == "unready" then return self:choose(it) end + end + end +end + +function Model:result() + if self.outcome ~= "go" or not self.final then return nil end + local r = self:rules() or {} + local go = self.goInfo or {} + return { + records = copy(self.final.records), + size = self.final.size, + digest = self.final.digest, + ruleset = { id = r.ruleset, rulesetId = (self:target() or {}).ruleset, gen = r.gen, dexMax = r.dexMax, + moveMax = r.moveMax, moveGen = r.moveGen, gens = copy(r.gens) }, + seed = go.seed, match = go.match, rev = go.rev, go = copy(go), team = copy(self.final.team or {}), + version = self.version, gen = self.gen, + } +end + +function Model:formatGroups(lines) + local out = {} + for _, line in ipairs(lines or {}) do out[#out + 1] = Messages.format(line, self.gen) end + return out +end + +function Model.fitGroups(body, info, room, opts) + opts = opts or {} + local used, showBody, showInfo = 0, {}, {} + local function take(list, into, from) + for i = from or 1, #list do + local g = list[i] + if used + #g > room then break end + into[#into + 1] = g + used = used + #g + end + end + local first = 1 + if opts.notice and body[1] then + showBody[1] = body[1] + used = #body[1] + first = 2 + end + if opts.keepBody == false then + take(info, showInfo) + take(body, showBody, first) + else + take(body, showBody, first) + take(info, showInfo) + end + local lines = {} + for _, g in ipairs(showBody) do for _, l in ipairs(g) do lines[#lines + 1] = l end end + for _, g in ipairs(showInfo) do for _, l in ipairs(g) do lines[#lines + 1] = l end end + if #lines == 0 then + local g = body[1] or info[1] or {} + for i = 1, math.min(#g, room) do lines[#lines + 1] = g[i] end + end + while #lines > room do table.remove(lines) end + return lines +end + +function Model:formatLines(lines) + local out = {} + for _, line in ipairs(lines or {}) do + for _, l in ipairs(Messages.format(line, self.gen)) do out[#out + 1] = l end + end + return out +end + +function Model:label(text) + if self:style() == "gb" then return (text or ""):upper() end + return text or "" +end + +return Model diff --git a/src/online/union/BattleSession.lua b/src/online/union/BattleSession.lua new file mode 100644 index 00000000..0a45d80e --- /dev/null +++ b/src/online/union/BattleSession.lua @@ -0,0 +1,598 @@ +local Table = require("src.battle.g3u.Table") +local Match = require("src.battle.g3u.Match") +local Wire = require("src.battle.g3u.Wire") +local Policy = require("src.online.xgen.Policy") + +local BattleSession = {} +BattleSession.__index = BattleSession + +BattleSession.SETUP_TIMEOUT = 60 +BattleSession.RESUME_WINDOW = 120 +BattleSession.PEER_GONE = 45 +BattleSession.FINAL_HASH_WAIT = 5 + +local OWN = {} +for _, t in ipairs(Wire.TYPES) do OWN[t] = true end +OWN.xg_closed = true + +local REPORT = { win = "win", lose = "lose", draw = "draw" } + +local function clock() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return os.clock() +end + +local function copyAct(a) + return { kind = a.kind, slot = a.slot, index = a.index } +end + +local function msgBytes(m) + local ok, n = pcall(Wire.size, m) + return ok and n or math.huge +end + +function BattleSession.lowerSeat(gens) + local g0, g1 = tonumber(gens and gens[0]), tonumber(gens and gens[1]) + if g0 and g1 and g1 < g0 then return 1 end + return 0 +end + +-- pokefirered/src/pokemon.c:2845 +local function hpRange(base, level) + local lo = math.floor((2 * base) * level / 100) + level + 10 + local hi = math.floor((2 * base + 31 + 63) * level / 100) + level + 10 + return lo, hi +end + +-- pokefirered/src/pokemon.c:2814 +local function statRange(base, level) + local lo = math.floor((2 * base) * level / 100) + 5 + local hi = math.floor((2 * base + 31 + 63) * level / 100) + 5 + return lo, hi +end + +local STAT_KEYS = { { "atk", "atk" }, { "def", "def" }, { "speed", "spe" }, { "spAtk", "spa" }, { "spDef", "spd" } } + +function BattleSession.checkParty(records, t, opts) + opts = opts or {} + local msg = Wire.party(records) + local ok, why = Wire.validate(msg, { table = t, bytes = msgBytes(msg) }) + if not ok then return nil, why end + if opts.size and #records > opts.size then return nil, "party_size" end + local loose = opts.senderGen ~= nil and t.gen == 1 and opts.senderGen > 1 + for i, r in ipairs(records) do + local base = Table.baseStats(t, r.species) + if r.hp ~= r.maxHp then return nil, "record_" .. i .. ":hp_not_full" end + local lo, hi = hpRange(base.hp, r.level) + if r.maxHp < lo or r.maxHp > hi then return nil, "record_" .. i .. ":hp_range" end + for _, pair in ipairs(STAT_KEYS) do + local key, b = pair[1], pair[2] + if not (loose and (b == "spa" or b == "spd")) then + local slo, shi = statRange(base[b], r.level) + if r[key] < slo or r[key] > shi then return nil, "record_" .. i .. ":" .. key .. "_range" end + end + end + for j, mv in ipairs(r.moves) do + local full = math.min(64, Policy.maxPp(t.moves[mv.id][4], mv.ppUps or 0)) + if mv.pp > full then return nil, "record_" .. i .. ":pp_" .. j end + end + end + return true +end + +local GENDER_CODE = { male = 0, M = 0, female = 1, F = 1, unknown = 2, U = 2, genderless = 2 } +local IV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" } + +function BattleSession.wireRecord(mon) + if type(mon) ~= "table" then return nil end + local moves = {} + for _, m in ipairs(mon.moves or {}) do + local id = type(m) == "table" and (m.id or m.move) or m + if tonumber(id) then + moves[#moves + 1] = { id = tonumber(id), pp = tonumber(type(m) == "table" and m.pp) or 0, + ppUps = tonumber(type(m) == "table" and m.ppUps) or 0 } + end + end + local r = { + species = tonumber(mon.species) or tonumber(mon.national), level = mon.level, hp = mon.hp, maxHp = mon.maxHp, + atk = mon.atk, def = mon.def, spAtk = mon.spAtk, spDef = mon.spDef, speed = mon.speed, moves = moves, + } + if type(mon.nickname) == "string" and mon.nickname ~= "" then r.nickname = mon.nickname end + local g = mon.gender + if type(g) == "string" then g = GENDER_CODE[g] end + if type(g) == "number" then r.gender = g end + if tonumber(mon.friendship) then r.friendship = tonumber(mon.friendship) end + if type(mon.ivs) == "table" then + r.ivs = {} + for _, k in ipairs(IV_KEYS) do r.ivs[k] = tonumber(mon.ivs[k]) or 0 end + end + return r +end + +function BattleSession.wireRecords(list) + local out = {} + for i, mon in ipairs(list or {}) do out[i] = BattleSession.wireRecord(mon) end + return out +end + +function BattleSession.new(opts) + assert(type(opts) == "table", "BattleSession.new needs opts") + assert(type(opts.net) == "table", "BattleSession.new needs a net") + local seat = tonumber(opts.seat) + assert(seat == 0 or seat == 1, "BattleSession.new needs seat 0 or 1") + local go = opts.go or {} + local gens = opts.gens or {} + local self = setmetatable({ + net = opts.net, + seat = seat, + peer = 1 - seat, + go = go, + seed = tonumber(go.seed) or 0, + size = tonumber(go.size), + gens = { [0] = tonumber(gens[0]), [1] = tonumber(gens[1]) }, + data = opts.data, + records = type(opts.records) == "table" and BattleSession.wireRecords(opts.records) or nil, + names = opts.names or {}, + client = opts.client, + linkState = opts.linkState, + now = opts.now or clock, + timeouts = { + setup = (opts.timeouts and opts.timeouts.setup) or BattleSession.SETUP_TIMEOUT, + resume = (opts.timeouts and opts.timeouts.resume) or BattleSession.RESUME_WINDOW, + peer = (opts.timeouts and opts.timeouts.peer) or BattleSession.PEER_GONE, + final = (opts.timeouts and opts.timeouts.final) or BattleSession.FINAL_HASH_WAIT, + }, + phase = "setup", + queue = {}, + table = opts.table, + peerRecords = nil, + match = nil, + mine = {}, + theirs = {}, + myRep = nil, + theirReps = {}, + hashSent = {}, + myHashes = {}, + peerHashes = {}, + result = nil, + reported = false, + log = {}, + }, BattleSession) + self.lower = BattleSession.lowerSeat(self.gens) + self.tableGen = math.min(self.gens[0] or 3, self.gens[1] or 3) + self.startedAt = self.now() + self:_begin() + return self +end + +function BattleSession:isLower() + return self.seat == self.lower +end + +function BattleSession:_note(fmt, ...) + local line = select("#", ...) > 0 and fmt:format(...) or fmt + self.log[#self.log + 1] = line +end + +function BattleSession:_push(ev) + self.queue[#self.queue + 1] = ev +end + +function BattleSession:events() + local out = self.queue + self.queue = {} + return out +end + +function BattleSession:_send(msg) + local net = self.net + if net.closed then return false end + net:send(msg) + return true +end + +function BattleSession:_begin() + if self:isLower() then + local t, why = self.table, nil + if not t then + if type(self.data) ~= "table" then return self:_fail("bad_table", "no_data") end + t, why = Table.build(self.data, self.tableGen) + if not t then return self:_fail("bad_table", why) end + end + local ok, vwhy = Table.validate(t, self.tableGen) + if not ok then return self:_fail("bad_table", vwhy) end + self.table = t + end + if type(self.records) ~= "table" or #self.records == 0 then return self:_fail("bad_party", "no_records") end + if self.table then + local ok, why = BattleSession.checkParty(self.records, self.table, { size = self.size, senderGen = self.gens[self.seat] }) + if not ok then return self:_fail("bad_party", why) end + self:_send(Wire.table(self.table)) + end + self:_send(Wire.party(self.records)) +end + +function BattleSession:_finish(outcome, why, opts) + if self.result then return end + opts = opts or {} + self.result = { outcome = outcome, why = why, detail = opts.detail } + self.phase = "over" + if opts.bye then self:_send(Wire.bye(opts.bye)) end + self:_note("over %s %s %s", tostring(outcome), tostring(why), tostring(opts.detail)) + self:_push({ kind = "over", outcome = outcome, why = why, detail = opts.detail }) + self:_report() +end + +function BattleSession:_fail(why, detail) + local bye = Wire.BYE[why] and why or "error" + self:_finish("draw", why, { bye = bye, detail = detail }) +end + +function BattleSession:_report() + if self.reported then return end + local r = self.result + if not r or r.why == "disconnect" then return end + self.reported = true + local client = self.client + if type(client) ~= "table" then return end + local word = REPORT[r.outcome] or "draw" + local fn = client.report + if type(fn) == "function" then pcall(fn, word) end +end + +function BattleSession:_mapEnd(result) + if result.draw then return "draw" end + return result.winner == self.seat and "win" or "lose" +end + +function BattleSession:_absorb(events) + for _, ev in ipairs(events or {}) do + self:_push(ev) + end + local m = self.match + local h = m:hash(m.turn) + if h and not self.hashSent[m.turn] then + self.hashSent[m.turn] = true + self.myHashes[m.turn] = h + local out = h + if self.tamperHash then out = self.tamperHash(m.turn, h) or h end + self:_send(Wire.hash(m.turn, out)) + end + self:_checkHashes() + if self.result then return end + if m.phase == "over" then + self.overAt = self.overAt or self.now() + self.matchResult = m.result + self.phase = "ending" + self:_tryEnd() + return + end + if m.phase == "choose" then + self.phase = "choose" + self:_push({ kind = "prompt", what = "move", turn = m.turn + 1 }) + elseif m.phase == "replace" then + if m:needs(self.seat) then + self.phase = "replace" + self:_push({ kind = "prompt", what = "replace", turn = m.turn, reason = m.need[self.seat].kind }) + else + self.phase = "replace_wait" + self:_push({ kind = "waiting", what = "replace" }) + end + self:_tryReplace() + end +end + +function BattleSession:_tryEnd() + if self.phase ~= "ending" or self.result then return end + local m = self.match + local turn = m.turn + local theirs = self.peerHashes[turn] + if theirs == nil and self.now() - self.overAt < self.timeouts.final then return end + local r = self.matchResult + self:_finish(self:_mapEnd(r), r.why, { winner = r.winner }) +end + +function BattleSession:_checkHashes() + for turn, mine in pairs(self.myHashes) do + local theirs = self.peerHashes[turn] + if theirs ~= nil and theirs ~= mine then + self:_note("desync turn %d mine %s theirs %s", turn, mine, theirs) + self:_finish("draw", "desync", { bye = "desync", detail = turn }) + return + end + end +end + +function BattleSession:_tryStart() + if self.match or self.result then return end + if not (self.table and self.peerRecords) then return end + local parties = { [self.seat] = self.records, [self.peer] = self.peerRecords } + local ok, m = pcall(Match.new, { table = self.table, parties = parties, seed = self.seed, + names = self.names }) + if not ok then return self:_fail("error", tostring(m)) end + self.match = m + self:_push({ kind = "ready", seat = self.seat, table = self.table, parties = parties, + names = self.names, tableGen = self.table.gen }) + local okS, events = pcall(m.start, m) + if not okS then return self:_fail("error", tostring(events)) end + self:_absorb(events) +end + +function BattleSession:_onTable(msg) + if self:isLower() then return self:_fail("bad_table", "from_higher_seat") end + if self.table then return self:_fail("bad_table", "duplicate") end + local ok, why = Wire.validate(msg, { gen = self.tableGen, bytes = msgBytes(msg) }) + if not ok then return self:_fail("bad_table", why) end + self.table = msg.table + local okP, pwhy = BattleSession.checkParty(self.records, self.table, { size = self.size, senderGen = self.gens[self.seat] }) + if not okP then return self:_fail("bad_party", pwhy) end + if self.peerRecords then + local okQ, qwhy = BattleSession.checkParty(self.peerRecords, self.table, + { size = self.size, senderGen = self.gens[self.peer] }) + if not okQ then return self:_fail("bad_party", qwhy) end + end + self:_tryStart() +end + +function BattleSession:_onParty(msg) + if self.peerRecords then return self:_fail("bad_party", "duplicate") end + if type(msg.records) ~= "table" then return self:_fail("bad_party", "shape") end + if msgBytes(msg) > Wire.MAX_BYTES.g3u_party then return self:_fail("bad_party", "too_big") end + if self.table then + local ok, why = BattleSession.checkParty(msg.records, self.table, + { size = self.size, senderGen = self.gens[self.peer] }) + if not ok then return self:_fail("bad_party", why) end + end + self.peerRecords = msg.records + self:_tryStart() +end + +function BattleSession:_tryResolve() + local m = self.match + if not m or m.phase ~= "choose" or self.result then return end + local turn = m.turn + 1 + local mine, theirs = self.mine[turn], self.theirs[turn] + if not (mine and theirs) then return end + if not m:isLegal(self.peer, theirs) then + return self:_finish("draw", "illegal", { bye = "illegal", detail = turn }) + end + local acts = { [self.seat] = copyAct(mine), [self.peer] = copyAct(theirs) } + local ok, events = pcall(m.submit, m, acts) + if not ok then return self:_fail("error", tostring(events)) end + self:_absorb(events) +end + +function BattleSession:_tryReplace() + local m = self.match + if not m or m.phase ~= "replace" or self.result then return end + local picks = {} + for s = 0, 1 do + if m:needs(s) then + if s == self.seat then + if not self.myRep then return end + picks[s] = self.myRep + else + local rep = self.theirReps[1] + if not rep then return end + if not m:isLegal(s, { kind = "switch", index = rep.index }) then + return self:_finish("draw", "illegal", { bye = "illegal", detail = m.turn }) + end + picks[s] = rep.index + end + end + end + if picks[self.seat] then self.myRep = nil end + if picks[self.peer] then table.remove(self.theirReps, 1) end + local ok, events = pcall(m.replace, m, picks) + if not ok then return self:_fail("error", tostring(events)) end + self:_absorb(events) +end + +function BattleSession:_onBye(msg) + local why = msg.why + if why == "forfeit" or why == "quit" or why == "timeout" then + return self:_finish("win", "forfeit") + end + if why == "desync" then return self:_finish("draw", "desync") end + if why == "illegal" then return self:_finish("draw", "illegal") end + if why == "bad_table" or why == "bad_party" then return self:_finish("draw", why, { detail = "peer" }) end + return self:_finish("draw", "error", { detail = why }) +end + +function BattleSession:_handle(msg) + local t = msg.type + if t == "xg_closed" then + return self:_finish("draw", "disconnect", { detail = msg.why }) + end + if msg.seat ~= nil and msg.seat ~= -1 and msg.seat ~= self.peer then return end + local bytes = msgBytes(msg) + local clean = {} + for k, v in pairs(msg) do + if k ~= "seat" and k ~= "relay" then clean[k] = v end + end + if t == "g3u_table" then return self:_onTable(clean) end + if t == "g3u_party" then return self:_onParty(clean) end + local ok, why = Wire.validate(clean, { bytes = bytes }) + if not ok then + self:_note("drop %s %s", tostring(t), tostring(why)) + return self:_finish("draw", "illegal", { bye = "illegal", detail = t .. ":" .. tostring(why) }) + end + if t == "g3u_bye" then return self:_onBye(clean) end + if t == "g3u_hash" then + if self.peerHashes[clean.turn] == nil then self.peerHashes[clean.turn] = clean.hash end + self:_checkHashes() + self:_tryEnd() + return + end + if not self.match then return self:_fail("error", "early_" .. t) end + if t == "g3u_action" then + local m = self.match + if clean.turn ~= m.turn + 1 or self.theirs[clean.turn] then + return self:_finish("draw", "illegal", { bye = "illegal", detail = "turn" }) + end + local act = Wire.toAction(clean) + if m.phase == "choose" and not m:isLegal(self.peer, act) then + return self:_finish("draw", "illegal", { bye = "illegal", detail = clean.turn }) + end + self.theirs[clean.turn] = act + self:_tryResolve() + elseif t == "g3u_replace" then + if clean.turn ~= self.match.turn then + return self:_finish("draw", "illegal", { bye = "illegal", detail = "replace_turn" }) + end + self.theirReps[#self.theirReps + 1] = { index = clean.index, turn = clean.turn } + self:_tryReplace() + end +end + +local function wanted(msg) + return type(msg) == "table" and OWN[msg.type] == true +end + +function BattleSession:_drain() + local net = self.net + if type(net.takeWhere) == "function" then + for _ = 1, 256 do + if self.result then return end + local msg = net:takeWhere(wanted) + if not msg then return end + self:_handle(msg) + end + return + end + if type(net.poll) == "function" then + for _, msg in ipairs(net:poll() or {}) do + if self.result then return end + if wanted(msg) then self:_handle(msg) end + end + end +end + +function BattleSession:_link() + if self.linkState then return self.linkState() end + if self.net.closed then return "gone" end + return "ok" +end + +function BattleSession:_watchLink() + local state = self:_link() + local now = self.now() + if state == "gone" then + if self.phase == "ending" then + local r = self.matchResult + return self:_finish(self:_mapEnd(r), r.why) + end + return self:_finish("draw", "disconnect") + end + if state == "resuming" then + self.resumingSince = self.resumingSince or now + if now - self.resumingSince > self.timeouts.resume then return self:_finish("draw", "disconnect") end + else + self.resumingSince = nil + end + local online = true + if type(self.net.peerOnline) == "function" then + local ok, v = pcall(self.net.peerOnline, self.net, self.peer) + online = not ok or v ~= false + end + if online then + self.peerGoneSince = nil + else + self.peerGoneSince = self.peerGoneSince or now + if now - self.peerGoneSince > self.timeouts.peer then return self:_finish("draw", "disconnect") end + end + if not self.match and now - self.startedAt > self.timeouts.setup then + return self:_finish("draw", "disconnect", { bye = "timeout", detail = "setup" }) + end +end + +function BattleSession:update() + if self.result then return end + if type(self.net.update) == "function" then pcall(self.net.update, self.net) end + self:_drain() + if self.result then return end + self:_tryStart() + self:_tryEnd() + if self.result then return end + self:_watchLink() +end + +function BattleSession:legal() + local m = self.match + if not m or self.result then return {} end + if self.phase == "choose" or self.phase == "replace" then return m:legalActions(self.seat) end + return {} +end + +function BattleSession:choose(act) + local m = self.match + if self.phase ~= "choose" or not m then return false, "not_choosing" end + if not m:isLegal(self.seat, act) then return false, "illegal" end + local turn = m.turn + 1 + self.mine[turn] = copyAct(act) + self:_send(Wire.action(turn, act)) + self.phase = "wait" + self:_push({ kind = "waiting", what = "move" }) + self:_tryResolve() + return true +end + +function BattleSession:pickReplacement(index) + local m = self.match + if self.phase ~= "replace" or not m then return false, "not_replacing" end + local act = { kind = "switch", index = index } + if not m:isLegal(self.seat, act) then return false, "illegal" end + self.myRep = index + self:_send(Wire.replace(m.turn, index)) + self.phase = "replace_wait" + self:_push({ kind = "waiting", what = "replace" }) + self:_tryReplace() + return true +end + +function BattleSession:forfeit() + if self.result then return false end + if self.phase == "choose" then return self:choose({ kind = "forfeit" }) end + self:_finish("lose", "forfeit", { bye = "forfeit" }) + return true +end + +function BattleSession:quit() + if self.result then return false end + self:_finish("lose", "forfeit", { bye = "quit" }) + return true +end + +function BattleSession:side(seat) + if seat == nil then return nil end + return seat == self.seat and "me" or "foe" +end + +function BattleSession:myParty() + return self.match and self.match:party(self.seat) or nil +end + +function BattleSession:foeParty() + return self.match and self.match:party(self.peer) or nil +end + +function BattleSession:activeMon(seat) + local st = self.match and self.match.st + if not st then return nil end + local b = require("src.core.game3.battle.state").battler(st, seat) + return b and b.mon, b +end + +function BattleSession:active(seat) + return self.match and self.match:active(seat) or nil +end + +function BattleSession:over() + return self.result ~= nil +end + +function BattleSession:close() + if not self.result then self:quit() end +end + +return BattleSession diff --git a/src/online/union/Caps.lua b/src/online/union/Caps.lua new file mode 100644 index 00000000..29c618fc --- /dev/null +++ b/src/online/union/Caps.lua @@ -0,0 +1,59 @@ +local GameVersion = require("src.core.GameVersion") +local Policy = require("src.online.xgen.Policy") + +local Caps = {} + +local NO_MODS = {} + +function Caps.gameplayMods(game) + if type(game) ~= "table" or not game.mods then return false end + local ok, ArenaData = pcall(require, "src.online.ArenaData") + if not ok then return true end + local okB, blockers = pcall(ArenaData.onlineBlockers3, game) + if not okB or type(blockers) ~= "table" then return true end + return #blockers > 0 +end + +function Caps.fingerprint(data, gen) + if type(data) ~= "table" then return nil end + local Fingerprint = require("src.link.Fingerprint") + local ok, fp = pcall(Fingerprint.compute, data, NO_MODS, gen) + if not ok or type(fp) ~= "string" then return nil end + return fp:lower() +end + +function Caps.compute(ctx) + ctx = type(ctx) == "table" and ctx or {} + local version = ctx.version + local gen = GameVersion.VERSIONS[version or ""] and GameVersion.generation(version) or nil + local out = { proto = Policy.PROTO, policy = Policy.VERSION, gens = {} } + if not gen or (ctx.gen ~= nil and ctx.gen ~= gen) then return out end + local blocked = ctx.gameplayMods + if blocked == nil then blocked = Caps.gameplayMods(ctx.game) end + if blocked then return out end + local fp = ctx.vanillaFingerprint or Caps.fingerprint(ctx.data or (ctx.game and ctx.game.data), gen) + if type(fp) ~= "string" or not fp:match("^[0-9a-f]+$") or #fp > 64 then return out end + out.gens[tostring(gen)] = { { version = version, fp = fp } } + return out +end + +function Caps.list(caps, gen) + if type(caps) ~= "table" or type(caps.gens) ~= "table" then return {} end + return caps.gens[tostring(gen)] or caps.gens[gen] or {} +end + +function Caps.has(caps, gen, version, fp) + for _, e in ipairs(Caps.list(caps, gen)) do + if e.version == version and (fp == nil or e.fp == fp) then return true end + end + return false +end + +function Caps.empty(caps) + for gen = 1, 3 do + if #Caps.list(caps, gen) > 0 then return false end + end + return true +end + +return Caps diff --git a/src/online/union/Origin.lua b/src/online/union/Origin.lua new file mode 100644 index 00000000..bf0b887d --- /dev/null +++ b/src/online/union/Origin.lua @@ -0,0 +1,36 @@ +local Origin = {} + +Origin.KEY = "unionOrigin" + +local FIELDS = { map = "string", x = "number", y = "number" } + +local function valid(o) + if type(o) ~= "table" then return false end + for k, t in pairs(FIELDS) do + if type(o[k]) ~= t then return false end + end + return o.gen == 1 or o.gen == 2 or o.gen == 3 +end + +function Origin.record(save, o) + if type(save) ~= "table" then return nil end + local rec = { + gen = o.gen, version = o.version, map = o.map, warp = o.warp, + x = o.x, y = o.y, facing = o.facing, at = o.at or os.time(), + } + if not valid(rec) then return nil end + save[Origin.KEY] = rec + return rec +end + +function Origin.get(save) + local o = type(save) == "table" and save[Origin.KEY] or nil + if valid(o) then return o end + return nil +end + +function Origin.clear(save) + if type(save) == "table" then save[Origin.KEY] = nil end +end + +return Origin diff --git a/src/online/union/Participant.lua b/src/online/union/Participant.lua new file mode 100644 index 00000000..0a3e6114 --- /dev/null +++ b/src/online/union/Participant.lua @@ -0,0 +1,121 @@ +local GameVersion = require("src.core.GameVersion") +local Wire = require("src.link.Wire") + +local Participant = {} + +Participant.NAME_MAX = 10 +Participant.SLOT_MAX = 40 +Participant.DEFAULT_STYLE = "player" + +local function knownVersion(version) + return type(version) == "string" and GameVersion.VERSIONS[version] ~= nil +end + +function Participant.genOf(version) + if not knownVersion(version) then return nil end + return GameVersion.generation(version) +end + +local function caps(v) + if type(v) ~= "table" then return nil end + return Wire.caps(v) +end + +local function groupActivity(row) + local g = type(row.group) == "table" and row.group or nil + return g and g.activity or nil +end + +function Participant.fromMember(row) + if type(row) ~= "table" then return nil, "not a row" end + local id = Wire.playerId(row.id) + if not id then return nil, "no id" end + local slot = tonumber(row.slot) + if not slot or slot ~= math.floor(slot) or slot < 1 or slot > Participant.SLOT_MAX then + return nil, "no slot" + end + local av = type(row.avatar) == "table" and row.avatar or nil + local version = av and av.version or nil + if not knownVersion(version) then return nil, "no version" end + local family = GameVersion.generation(version) + local gen = av.gen + local legacy = false + if gen == nil then + legacy = true + gen = family + elseif gen ~= family then + return nil, "gen mismatch" + end + local name = av.name + if type(name) ~= "string" or name == "" then name = row.name end + return { + slot = slot, + id = id, + name = type(name) == "string" and Wire.chars(name, "", Participant.NAME_MAX) or "", + verified = row.verified == true, + game = version, + gen = gen, + trainerId = math.floor(tonumber(av.trainerId) or 0) % 65536, + gender = av.gender == 1 and 1 or 0, + style = Wire.avatarStyle(av.style) or Participant.DEFAULT_STYLE, + status = type(row.status) == "string" and row.status or "idle", + activity = groupActivity(row), + group = row.group, + online = row.online ~= false, + caps = caps(row.caps), + board = row.board, + legacy = legacy, + } +end + +function Participant.badgeDigit(p) + if type(p) ~= "table" then return nil end + local gen = tonumber(p.gen) + if gen == 1 or gen == 2 or gen == 3 then return gen end + return nil +end + +function Participant.busy(p) + if type(p) ~= "table" then return true end + if p.online == false then return true end + return p.status ~= "idle" and p.status ~= "recruiting" and p.status ~= "waiting" +end + +function Participant.same(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + for _, k in ipairs({ "id", "slot", "name", "game", "gen", "trainerId", "gender", "style", + "status", "activity", "online", "legacy", "verified" }) do + if a[k] ~= b[k] then return false end + end + local ca, cb = a.caps, b.caps + if (ca == nil) ~= (cb == nil) then return false end + if ca and (ca.proto ~= cb.proto or ca.policy ~= cb.policy) then return false end + if ca then + for gen = 1, 3 do + local la, lb = ca.gens[tostring(gen)], cb.gens[tostring(gen)] + if (la == nil) ~= (lb == nil) then return false end + if la then + if #la ~= #lb then return false end + for i = 1, #la do + if la[i].version ~= lb[i].version or la[i].fp ~= lb[i].fp then return false end + end + end + end + end + return true +end + +function Participant.wireAvatar(fields) + fields = type(fields) == "table" and fields or {} + local style = Wire.avatarStyle(fields.style) or Participant.DEFAULT_STYLE + return { + name = Wire.chars(tostring(fields.name or ""), "", Participant.NAME_MAX), + trainerId = math.floor(tonumber(fields.trainerId) or 0) % 65536, + gender = (fields.gender == 1 or fields.gender == "female") and 1 or 0, + version = fields.version, + style = style, + canLinkNationally = type(fields.canLinkNationally) == "boolean" and fields.canLinkNationally or nil, + } +end + +return Participant diff --git a/src/online/union/Prep.lua b/src/online/union/Prep.lua new file mode 100644 index 00000000..d9b427d6 --- /dev/null +++ b/src/online/union/Prep.lua @@ -0,0 +1,363 @@ +local Protocol2 = require("src.online.Protocol2") +local Wire = require("src.link.Wire") + +local Prep = {} +Prep.__index = Prep + +Prep.STATES = { rules_wait = true, blocked = true, prep = true, go = true, closed = true } + +local BUMPING = { xg_roster = true, xg_size_req = true, xg_offer = true, xg_counter = true } +local TRADE_CAUSES = { trade_commit = true, trade_abort = true } + +local function isXg(msg) + return Protocol2.isXg(msg) +end + +function Prep.new(session, opts) + opts = opts or {} + local self = setmetatable({ + session = session, + mode = opts.mode, + rev = 0, + rules = nil, + blocked = nil, + size = nil, + state = "rules_wait", + go = nil, + closed = nil, + round = 0, + offerRev = 0, + invalidated = false, + mine = { pending = {} }, + peer = {}, + events = {}, + snapshotSeen = nil, + sent = {}, + }, Prep) + self:syncSnapshot() + return self +end + +function Prep:seat() + local seat = self.session.seat and self.session.seat() or nil + if seat ~= nil then self.mySeat = seat end + return self.mySeat +end + +function Prep:emit(kind, fields) + local e = { kind = kind } + for k, v in pairs(fields or {}) do e[k] = v end + self.events[#self.events + 1] = e + return e +end + +function Prep:clearReady(reason) + if self.mine.ready then + self.invalidated = true + self:emit("invalidated", { reason = reason }) + end + self.mine.ready = nil + self.peer.ready = nil +end + +function Prep:setRev(rev, cause) + rev = tonumber(rev) + if not rev or rev <= self.rev then return false end + self.rev = rev + self:clearReady(cause or "rev") + self:emit("rev", { rev = rev, cause = cause }) + return true +end + +function Prep:enterPrepState() + if self.state == "go" or self.state == "closed" then return end + if self.blocked then + self.state = "blocked" + elseif self.rules then + self.state = "prep" + else + self.state = "rules_wait" + end +end + +local function pairAt(list, seat) + if type(list) ~= "table" or seat == nil then return nil end + return list[seat + 1] +end + +function Prep:syncSnapshot() + if not self.session.snapshot then return end + local x, room = self.session.snapshot() + if type(x) ~= "table" or x == self.snapshotSeen then return end + self.snapshotSeen = x + self.mode = x.mode or self.mode + local seat = self:seat() + if seat == nil then return end + local other = 1 - seat + if x.rev < self.rev then return end + if x.rev > self.rev then + self.rev = x.rev + self:clearReady("resume") + end + self.rules = x.rules + self.blocked = x.blocked + self.size = x.size + self.mine.roster = pairAt(x.rosters, seat) + self.peer.roster = pairAt(x.rosters, other) + self.mine.sizeReq = pairAt(x.sizeReq, seat) + self.peer.sizeReq = pairAt(x.sizeReq, other) + local myOffer, peerOffer = pairAt(x.offers, seat), pairAt(x.offers, other) + if myOffer then + self.offerRev = math.max(self.offerRev, myOffer.offerRev) + if not (self.mine.offer and self.mine.offer.offerRev == myOffer.offerRev) then + self.mine.offer = { offerRev = myOffer.offerRev, digest16 = myOffer.digest16 } + end + else + self.mine.offer = nil + end + if peerOffer then + if not (self.peer.offer and self.peer.offer.offerRev == peerOffer.offerRev) then + self.peer.offer = { offerRev = peerOffer.offerRev, digest16 = peerOffer.digest16 } + end + else + self.peer.offer = nil + end + if pairAt(x.ready, seat) then + self.mine.ready = self.mine.ready or { rev = x.rev } + else + self.mine.ready = nil + end + self.peer.ready = pairAt(x.ready, other) and { rev = x.rev } or nil + self.peer.capsSent = pairAt(x.caps, other) == true + self.mine.capsSent = pairAt(x.caps, seat) == true + local stage = type(room) == "table" and room.stage or nil + if stage == "prep" then + if self.state == "go" then self.state = "rules_wait" end + self.go = nil + self:enterPrepState() + elseif stage == "trading" or stage == "battling" or stage == "ended" then + if self.state ~= "closed" then self.state = "go" end + else + self:enterPrepState() + end + self:emit("snapshot", { rev = self.rev }) +end + +function Prep:confirm(cause) + local pending = self.mine.pending[cause] + if not pending then return end + self.mine.pending[cause] = nil + if cause == "xg_roster" then + self.mine.roster = { size = pending.size, digest16 = pending.digest16 } + elseif cause == "xg_size_req" then + self.mine.sizeReq = pending.size + elseif cause == "xg_offer" then + self.mine.offer = pending + elseif cause == "xg_counter" then + self.mine.counter = pending.gen + end +end + +function Prep:handleRelay(m) + local kind = m.type + if kind == "xg_rules" then + self:setRev(m.rev, "rules") + self.rules = Wire.xgRules(m) + self.blocked = nil + self:enterPrepState() + self:emit("rules", { rules = self.rules }) + elseif kind == "xg_blocked" then + self:setRev(m.rev, "blocked") + self.rules = nil + self.blocked = m.why + self:enterPrepState() + self:emit("blocked", { why = m.why }) + elseif kind == "xg_rev" then + self:setRev(m.rev, m.cause) + if m.seat ~= nil and m.seat == self:seat() then self:confirm(m.cause) end + if m.seat == -1 and TRADE_CAUSES[m.cause] then + self.mine.offer, self.peer.offer = nil, nil + self.mine.pending.xg_offer = nil + self.go = nil + if self.state == "go" then self.state = "rules_wait" end + self.round = self.round + 1 + self:enterPrepState() + self:emit("trade_round", { round = self.round, cause = m.cause }) + end + elseif kind == "xg_size" then + self:setRev(m.rev, "size") + self.size = m.size + self:emit("size", { size = m.size }) + elseif kind == "xg_nack" then + self.mine.pending[m.of or ""] = nil + if m.of == "xg_ready" then + if self.mine.ready then self.invalidated = true end + self.mine.ready = nil + end + if m.why == "stale_rev" then + self:setRev(m.current, "stale_rev") + self.invalidated = true + elseif m.why == "digest" then + self.invalidated = true + self.mine.ready, self.peer.ready = nil, nil + end + self:emit("nack", { of = m.of, why = m.why, rev = m.rev, current = m.current }) + elseif kind == "xg_go" then + if self.state == "closed" then return end + self.go = { + rev = m.rev, seed = m.seed, match = m.match, mode = m.mode or self.mode, + ruleset = m.ruleset, size = m.size, gen = m.gen, dexMax = m.dexMax, + moveMax = m.moveMax, moveGen = m.moveGen, + } + self.state = "go" + self:emit("go", { go = self.go }) + elseif kind == "xg_closed" then + self.state = "closed" + self.closed = { why = m.why, seat = m.seat ~= -1 and m.seat or nil, detail = m.detail } + self:emit("closed", self.closed) + end +end + +function Prep:handlePeer(m) + local kind = m.type + if kind == "xg_roster" then + self.peer.roster = { size = m.size, digest16 = m.digest16 } + self:emit("peer_roster", { size = m.size }) + elseif kind == "xg_size_req" then + self.peer.sizeReq = m.size + self:emit("peer_size_req", { size = m.size }) + elseif kind == "xg_offer" then + self.peer.offer = { offerRev = m.offerRev, payload = m.payload, digest16 = m.digest16 } + self:emit("peer_offer", { offerRev = m.offerRev }) + elseif kind == "xg_ready" then + if m.rev == self.rev then + self.peer.ready = { rev = m.rev, digest16 = m.digest16 } + self:emit("peer_ready", { rev = m.rev }) + end + elseif kind == "xg_caps" then + self.peer.caps = m.caps + self.peer.capsSent = true + self:emit("peer_caps", {}) + elseif kind == "xg_counter" then + self.peer.counter = m.gen + self:emit("peer_counter", { gen = m.gen }) + elseif kind == "xg_cancel" then + self:emit("peer_cancel", { why = m.why }) + end +end + +function Prep:handle(m) + if type(m) ~= "table" then return end + if Protocol2.XG_RELAY_TYPES[m.type] then + self:handleRelay(m) + else + self:handlePeer(m) + end +end + +function Prep:poll() + self:syncSnapshot() + local take = self.session.take + if take then + for _ = 1, 256 do + local m = take(isXg) + if not m then break end + self:handle(m) + self:syncSnapshot() + end + end + if self.state ~= "closed" and self.session.open and not self.session.open() then + self.state = "closed" + self.closed = { why = "gone" } + self:emit("closed", self.closed) + end + local out = self.events + self.events = {} + return out +end + +function Prep:open() + return self.state ~= "closed" +end + +function Prep:send(msg) + if not msg or self.state == "closed" then return false end + self.sent[#self.sent + 1] = msg.type + return self.session.send(msg) and true or false +end + +function Prep:localChange(kind) + if BUMPING[kind] and self.mine.ready then + self.mine.ready = nil + self.invalidated = true + self:emit("invalidated", { reason = kind }) + end +end + +function Prep:roster(size, digest16) + if self.mode ~= "battle" or self.state == "go" then return false end + self:localChange("xg_roster") + self.mine.pending.xg_roster = { size = size, digest16 = digest16 } + return self:send(Protocol2.xgRoster(self.rev, size, digest16)) +end + +function Prep:sizeRequest(size) + if self.mode ~= "battle" or self.state == "go" then return false end + self:localChange("xg_size_req") + self.mine.pending.xg_size_req = { size = size } + return self:send(Protocol2.xgSizeReq(self.rev, size)) +end + +function Prep:counter(gen) + if self.mode ~= "battle" or self.state == "go" then return false end + self:localChange("xg_counter") + self.mine.pending.xg_counter = { gen = gen } + return self:send(Protocol2.xgCounter(self.rev, gen)) +end + +function Prep:offer(payload, digest16) + if self.mode ~= "trade" or self.state == "go" then return false end + self:localChange("xg_offer") + self.offerRev = self.offerRev + 1 + self.mine.pending.xg_offer = { offerRev = self.offerRev, payload = payload, digest16 = digest16 } + return self:send(Protocol2.xgOffer(self.rev, self.offerRev, payload, digest16)) +end + +function Prep:sendCaps(caps) + return self:send(Protocol2.xgCaps(caps)) +end + +function Prep:canReady() + if self.state ~= "prep" or not self.rules then return false end + if self.mode == "battle" then return self.size ~= nil end + if self.mode == "trade" then return self.mine.offer ~= nil and self.peer.offer ~= nil end + return false +end + +function Prep:ready(digest16) + if self.state ~= "prep" or self.mine.ready then return false end + self.invalidated = false + self.mine.ready = { rev = self.rev, digest16 = digest16 } + return self:send(Protocol2.xgReady(self.rev, digest16)) +end + +function Prep:cancel(why) + if self.state == "closed" then return false end + return self:send(Protocol2.xgCancel(why or "cancel")) +end + +function Prep:leave() + if self.session.leave then self.session.leave() end +end + +function Prep:ackInvalidated() + local was = self.invalidated + self.invalidated = false + return was +end + +function Prep:bothReady() + return self.mine.ready ~= nil and self.peer.ready ~= nil +end + +return Prep diff --git a/src/online/union/Room.lua b/src/online/union/Room.lua new file mode 100644 index 00000000..bf23d139 --- /dev/null +++ b/src/online/union/Room.lua @@ -0,0 +1,401 @@ +local Caps = require("src.online.union.Caps") +local GameVersion = require("src.core.GameVersion") +local Participant = require("src.online.union.Participant") +local Prep = require("src.online.union.Prep") +local Protocol2 = require("src.online.Protocol2") +local Version = require("src.core.Version") +local Wire = require("src.link.Wire") + +local Room = {} +Room.__index = Room + +Room.CAP = Protocol2.PLAZA_CAP +Room.KIND = "union" +Room.RULESET_GB = "union" +Room.RULESET_G3 = "g3_single" + +Room.ERRORS = { + server_outdated = true, client_outdated = true, bad_profile = true, bad_avatar = true, + bad_caps = true, profile = true, offline = true, +} + +local PLAZA_REASONS = { bad_profile = true, bad_avatar = true, bad_caps = true } + +local function defaultClient() + return require("src.online.Client") +end + +function Room.new(opts) + opts = opts or {} + return setmetatable({ + client = opts.client or defaultClient(), + state = "idle", + err = nil, + errSent = false, + bySlot = {}, + byId = {}, + me = nil, + profile = nil, + avatar = nil, + caps = nil, + ctx = nil, + seen = nil, + rejected = 0, + builds = 0, + handlers = nil, + prepFor = nil, + prepObj = nil, + }, Room) +end + +local function genOf(ctx) + local v = ctx.version + if not GameVersion.VERSIONS[v or ""] then return nil end + return GameVersion.generation(v) +end + +local function apiVersion() + local ok, Handshake = pcall(require, "src.link.Handshake") + return (ok and Handshake.apiVersion) or Version.modApi +end + +local function gbFingerprint(ctx, gen) + if ctx.fingerprint then return ctx.fingerprint end + local game = ctx.game + local data = ctx.data or (game and game.data) + if type(data) ~= "table" then return nil end + local Fingerprint = require("src.link.Fingerprint") + local Handshake = require("src.link.Handshake") + local ok, fp = pcall(Fingerprint.compute, data, Handshake.mods(game), gen) + if ok then return fp end + return nil +end + +function Room.buildProfile(ctx) + ctx = type(ctx) == "table" and ctx or {} + if type(ctx.profile) == "table" then return ctx.profile end + local gen = genOf(ctx) + if not gen then return nil, "unknown game" end + if gen == 3 then + local ok, ArenaData = pcall(require, "src.online.ArenaData") + if not ok then return nil, "unavailable" end + local okP, profile, why = pcall(ArenaData.liveProfile3, ctx.game, ctx.rulesetId or Room.RULESET_G3) + if not okP then return nil, tostring(profile) end + if not profile then return nil, why or "profile" end + return profile + end + local fp = gbFingerprint(ctx, gen) + if not fp then return nil, "fingerprint" end + return { + engine = gen, + version = ctx.version, + engineVersion = Version.engine, + apiVersion = apiVersion(), + fingerprint = fp, + rulesetId = Room.RULESET_GB, + kind = "vanilla", + } +end + +function Room.buildAvatar(ctx) + return Participant.wireAvatar({ + name = ctx.name, trainerId = ctx.trainerId, gender = ctx.gender, + version = ctx.version, style = ctx.style, canLinkNationally = ctx.canLinkNationally, + }) +end + +function Room:fail(code, detail) + if self.err then return end + self.err = { error = code, detail = detail } + self.errSent = false + self.state = "error" +end + +function Room:error() + return self.err +end + +function Room:hook() + if self.handlers then return end + local onError = function(e) + if type(e) ~= "table" or e.scope ~= "join" or not PLAZA_REASONS[e.reason] then return end + if self.state ~= "joining" and self.state ~= "joined" then return end + if e.reason == "bad_profile" and self.gen ~= 3 then + self:fail("server_outdated", e.reason) + else + self:fail(e.reason) + end + end + local onUpgrade = function(msg) + if self.state == "joining" or self.state == "joined" then + self:fail("client_outdated", msg and msg.minBuild) + end + end + self.handlers = { error = onError, upgrade_required = onUpgrade } + for event, fn in pairs(self.handlers) do self.client.on(event, fn) end +end + +function Room:unhook() + if not self.handlers then return end + for event, fn in pairs(self.handlers) do self.client.off(event, fn) end + self.handlers = nil +end + +function Room:join(ctx) + ctx = type(ctx) == "table" and ctx or {} + self.err, self.errSent = nil, false + local gen = genOf(ctx) + if not gen then + self:fail("profile", "unknown game") + return false, self.err + end + local profile, why = Room.buildProfile(ctx) + if not profile then + self:fail("profile", why) + return false, self.err + end + if self.client.upgradeRequired and self.client.upgradeRequired() then + self:fail("client_outdated") + return false, self.err + end + self.ctx = ctx + self.gen = gen + self.profile = profile + self.avatar = Room.buildAvatar(ctx) + self.caps = ctx.caps or Caps.compute(ctx) + self.state = "joining" + self.seen = nil + self:hook() + self.client.joinPlaza(Room.KIND, profile, self.avatar, Room.CAP, + { xgen = Protocol2.XGEN, caps = self.caps }) + self.client.setStatus("idle") + return true +end + +function Room:setCaps(caps) + self.caps = caps + return self.client.setCaps(caps) +end + +function Room:leave() + if self.state == "idle" or self.state == "left" then return false end + self.client.leavePlaza(Room.KIND) + self:unhook() + self.state = "left" + self.bySlot, self.byId, self.me, self.seen = {}, {}, nil, nil + return true +end + +local function myId(client) + local you = client.you and client.you() + return you and you.id or nil +end + +local function emptyDiff() + return { joined = {}, left = {}, changed = {} } +end + +function Room:rebuild(plaza, diff) + self.builds = self.builds + 1 + local me = myId(self.client) + local mySlot = tonumber(plaza.you) + local fresh, freshIds = {}, {} + local mine = nil + for _, row in ipairs(plaza.members or {}) do + local isMe = (me ~= nil and row.id == me) or (mySlot ~= nil and row.slot == mySlot) + if isMe then + mine = row + else + local p = Participant.fromMember(row) + if p and not fresh[p.slot] and not freshIds[p.id] then + fresh[p.slot] = p + freshIds[p.id] = p + else + self.rejected = self.rejected + 1 + end + end + end + if mine then + local av = type(mine.avatar) == "table" and mine.avatar or nil + if not av or av.gen == nil then + self:fail("server_outdated", "legacy_shard") + self.client.leavePlaza(Room.KIND) + return + end + self.me = Participant.fromMember(mine) + end + for slot, old in pairs(self.bySlot) do + local now = fresh[slot] + if not now or now.id ~= old.id then diff.left[#diff.left + 1] = old end + end + for slot, now in pairs(fresh) do + local old = self.bySlot[slot] + if not old or old.id ~= now.id then + diff.joined[#diff.joined + 1] = now + elseif not Participant.same(old, now) then + diff.changed[#diff.changed + 1] = now + end + end + local bySlot = function(a, b) return a.slot < b.slot end + table.sort(diff.joined, bySlot) + table.sort(diff.left, bySlot) + table.sort(diff.changed, bySlot) + self.bySlot, self.byId = fresh, freshIds + if self.state == "joining" then self.state = "joined" end +end + +function Room:poll() + local diff = emptyDiff() + if self.state == "idle" or self.state == "left" then return diff end + if not self.err and self.client.upgradeRequired and self.client.upgradeRequired() then + self:fail("client_outdated") + end + if not self.err then + local plaza = self.client.plaza() + if type(plaza) == "table" and plaza.kind == Room.KIND then + local seen = self.seen + if not seen or seen.plaza ~= plaza or seen.instance ~= plaza.instance or seen.rev ~= plaza.rev then + self.seen = { plaza = plaza, instance = plaza.instance, rev = plaza.rev } + self:rebuild(plaza, diff) + end + end + end + if self.err and not self.errSent then + self.errSent = true + diff.error = self.err + end + return diff +end + +function Room:members() + local out = {} + for _, p in pairs(self.bySlot) do out[#out + 1] = p end + table.sort(out, function(a, b) return a.slot < b.slot end) + return out +end + +function Room:member(ref) + if type(ref) == "number" then return self.bySlot[ref] end + if type(ref) == "string" then return self.byId[ref] end + if type(ref) == "table" then return self.byId[ref.id] end + return nil +end + +function Room:self() + return self.me +end + +function Room:count() + local n = 0 + for _ in pairs(self.bySlot) do n = n + 1 end + return n + (self.me and 1 or 0) +end + +function Room:invite(to, activity) + activity = activity or "xg_battle" + local id = type(to) == "table" and to.id or to + id = Wire.playerId(id) + if not id or not (Protocol2.XG_ACTIVITIES[activity] or Protocol2.ACTIVITY_SET[activity]) then + return nil, "bad_invite" + end + return self.client.invite(id, activity, {}, self.profile) +end + +function Room:outgoing() + return self.client.outgoing() +end + +local function fromParticipant(self, inv) + local from = type(inv.from) == "table" and inv.from or {} + local known = self.byId[from.id or ""] + if known then return known end + local av = type(from.avatar) == "table" and from.avatar or nil + if not av then return nil end + local slot = 1 + return Participant.fromMember({ id = from.id, name = from.name, verified = from.verified, + slot = slot, avatar = av, status = "idle" }) +end + +function Room:incoming(includeLegacy) + local out = {} + for _, inv in ipairs(self.client.invites() or {}) do + local mode = Protocol2.XG_ACTIVITIES[inv.activity or ""] + if mode or includeLegacy then + out[#out + 1] = { id = inv.id, activity = inv.activity, mode = mode, + from = fromParticipant(self, inv), + fromId = type(inv.from) == "table" and inv.from.id or nil, + expiresAt = inv.expiresAt } + end + end + return out +end + +function Room:reply(id, accept) + return self.client.replyInvite(id, accept == true) +end + +function Room:setStatus(status) + if not Protocol2.STATUSES[status or ""] then return false end + return self.client.setStatus(status) +end + +function Room:busy(p) + return Participant.busy(p or self.me) +end + +function Room:xgRoom() + local r = self.client.room() + if type(r) == "table" and r.intent == "xg" then return r end + return nil +end + +local function adapter(client, roomId) + local rs = client.roomSession() + local a = {} + function a.send(msg) + if rs and not rs.closed then + rs:send(msg) + return true + end + return false + end + function a.take(pred) + if not rs then return nil end + return rs:takeWhere(pred) + end + function a.snapshot() + local r = client.room() + if type(r) == "table" and r.room == roomId then return r.xg, r end + return nil + end + function a.seat() + local r = client.room() + if type(r) == "table" and r.room == roomId then return client.seat() end + return nil + end + function a.open() + local r = client.room() + return type(r) == "table" and r.room == roomId + end + function a.leave() + if rs and not rs.left then rs:close() end + end + return a +end + +Room.adapter = adapter + +function Room:prep() + local r = self:xgRoom() + if r and self.prepFor ~= r.room then + self.prepFor = r.room + self.prepObj = Prep.new(adapter(self.client, r.room), { mode = r.mode or (r.xg and r.xg.mode) }) + end + return self.prepObj +end + +function Room:dropPrep() + self.prepFor, self.prepObj = nil, nil +end + +return Room diff --git a/src/online/union/Setting.lua b/src/online/union/Setting.lua new file mode 100644 index 00000000..861b01e4 --- /dev/null +++ b/src/online/union/Setting.lua @@ -0,0 +1,26 @@ +local Setting = {} + +Setting.KEY = "unionRoom" + +function Setting.enabledIn(opts) + return not (type(opts) == "table" and opts[Setting.KEY] == false) +end + +function Setting.enabled(fs) + local ok, opts = pcall(function() + return require("src.core.SaveData").loadOptions(fs) + end) + return Setting.enabledIn(ok and opts or nil) +end + +function Setting.appliesTo(generation) + return generation == 1 or generation == 2 +end + +function Setting.patchesOn(generation, opts) + if not Setting.appliesTo(generation) then return true end + if opts ~= nil then return Setting.enabledIn(opts) end + return Setting.enabled() +end + +return Setting diff --git a/src/online/union/TradePrepModel.lua b/src/online/union/TradePrepModel.lua new file mode 100644 index 00000000..e8b63035 --- /dev/null +++ b/src/online/union/TradePrepModel.lua @@ -0,0 +1,386 @@ +local Datasets = require("src.online.xgen.Datasets") +local GameVersion = require("src.core.GameVersion") +local Policy = require("src.online.xgen.Policy") +local Project = require("src.online.xgen.Project") +local TradeConvert = require("src.online.xgen.TradeConvert") + +local Model = {} +Model.__index = Model + +Model.PAYLOAD_VERSION = 1 +Model.MAX_STRING = 256 +Model.MAX_KEY = 64 +Model.MAX_DEPTH = 7 +Model.MAX_BYTES = 8192 +-- pokefirered/src/trade_scene.c:1073 +Model.GEN3_TRADED_FRIENDSHIP = 70 + +Model.TRADE_EVOLUTION = { + [1] = { TRADE = true }, + [2] = { EVOLVE_TRADE = true }, + -- include/constants/pokemon.h:270 + [3] = { [5] = true, [6] = true, EVO_TRADE = true, EVO_TRADE_ITEM = true }, +} + +local copy = Project.copy + +local function genOf(version) + if not (type(version) == "string" and GameVersion.VERSIONS[version]) then return nil end + return GameVersion.generation(version) +end +Model.genOf = genOf + +local function isArray(t) + local n = #t + if n == 0 then return false end + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count == n +end + +local function encodeKey(k) + if type(k) == "number" then + if k ~= k or k ~= math.floor(k) then return nil end + return ("#%.0f"):format(k) + end + if type(k) ~= "string" then return nil end + if k:sub(1, 1) == "#" then return "#" .. k end + return k +end + +local function decodeKey(k) + if type(k) ~= "string" then return k end + if k:sub(1, 2) == "##" then return k:sub(2) end + if k:sub(1, 1) == "#" then return tonumber(k:sub(2)) end + return k +end + +function Model.toWire(v, depth) + depth = depth or 0 + local t = type(v) + if t == "string" then + if #v > Model.MAX_STRING then return nil, "too_big" end + return v + end + if t == "number" then + if v ~= v or v == math.huge or v == -math.huge then return nil, "bad_record" end + return v + end + if t == "boolean" then return v end + if t ~= "table" then return nil, "bad_record" end + if depth >= Model.MAX_DEPTH then return nil, "too_big" end + local out = {} + if isArray(v) then + for i = 1, #v do + local item, why = Model.toWire(v[i], depth + 1) + if item == nil then return nil, why end + out[i] = item + end + return out + end + for k, val in pairs(v) do + local key = encodeKey(k) + if not key or #key > Model.MAX_KEY then return nil, "bad_record" end + local item, why = Model.toWire(val, depth + 1) + if item == nil then return nil, why end + out[key] = item + end + return out +end + +function Model.fromWire(v) + if type(v) ~= "table" then return v end + local out = {} + for k, val in pairs(v) do + local key = decodeKey(k) + if key ~= nil then out[key] = Model.fromWire(val) end + end + return out +end + +function Model.plain(v, depth) + depth = depth or 0 + local t = type(v) + if t == "table" then + if depth > 12 then return nil end + local out = {} + for k, val in pairs(v) do + local kt = type(k) + if kt == "string" or kt == "number" then + local item = Model.plain(val, depth + 1) + if item ~= nil then out[k] = item end + end + end + return out + end + if t == "string" or t == "number" or t == "boolean" then return v end + return nil +end + +Model.datasetSource = nil + +function Model.dataset(version) + if type(Model.datasetSource) == "function" then return Model.datasetSource(version) end + local data = Datasets.get(version) + return data +end + +function Model.datasetFor(version) + local gen = genOf(version) + if not gen then return nil end + local exact = Model.dataset(version) + if exact then return exact, version end + for _, v in ipairs(GameVersion.ORDER) do + if v ~= version and GameVersion.generation(v) == gen then + local d = Model.dataset(v) + if d then return d, v end + end + end + return nil +end + +function Model.receiveRules(destGen, result) + local final = copy(result) + local changes = {} + if destGen == 3 and not final.isEgg then + local was = final.friendship + final.friendship = Model.GEN3_TRADED_FRIENDSHIP + if was ~= final.friendship then + changes[#changes + 1] = { field = "friendship", from = was, to = final.friendship, kind = "change", rule = "received" } + end + end + return final, changes +end + +function Model.tradeEvolution(data, national) + local sp = type(data) == "table" and data.species and data.species[tonumber(national) or -1] + if not sp then return nil end + local methods = Model.TRADE_EVOLUTION[data.generation] or {} + for _, evo in ipairs(sp.evolutions or {}) do + if methods[evo.method] and data.species[evo.into] then + return evo.into, data.species[evo.into].name + end + end + return nil +end + +local function refusal(code, detail) + return { ok = false, blocks = { { code = code, detail = detail } }, changes = {}, options = {} } +end + +function Model.convert(srcVersion, dstVersion, rec, adjustments) + local src, srcUsed = Model.datasetFor(srcVersion) + local dst, dstUsed = Model.datasetFor(dstVersion) + if not src then return refusal("missing_import", { need = srcVersion }) end + if not dst then return refusal("missing_import", { need = dstVersion }) end + if type(rec) ~= "table" then return refusal("bad_record") end + if rec.rental == true then return refusal("rental") end + if rec.projected == true or rec.projection ~= nil or rec.sourceGen ~= nil then return refusal("projection") end + local report = TradeConvert.convert({ source = { data = src }, target = { data = dst }, mon = rec, + adjustments = adjustments, versions = { policy = Policy.VERSION } }) + report.datasets = { source = srcUsed, target = dstUsed } + local destGen = dst.generation + if report.ok then + local final, extra = Model.receiveRules(destGen, report.result) + for _, c in ipairs(extra) do report.changes[#report.changes + 1] = c end + report.final = final + report.finalCanonical = TradeConvert.canonical(final) + local view = Project.read(final, dst) + report.national = view and view.national + report.speciesName = view and dst.species[view.national] and dst.species[view.national].name + local into, name = Model.tradeEvolution(dst, report.national) + if into then report.evolves = { national = into, name = name } end + end + local view = Project.read(rec, src) + report.sourceNational = view and view.national + report.sourceName = view and src.species[view.national] and src.species[view.national].name + report.level = view and view.level + return report +end + +function Model.new(opts) + opts = opts or {} + local self = setmetatable({ + version = opts.version, + gen = genOf(opts.version), + peerVersion = opts.peerVersion, + peerGen = genOf(opts.peerVersion), + peerName = opts.peerName, + owned = opts.owned or {}, + mine = nil, + peer = nil, + refusal = nil, + }, Model) + return self +end + +function Model:setPeer(version, name) + if version ~= self.peerVersion then + self.peerVersion, self.peerGen = version, genOf(version) + if self.mine then self:choose(self.mine.index) end + end + if name then self.peerName = name end +end + +function Model:entry(index) + return self.owned[tonumber(index) or -1] +end + +function Model:choose(index) + local entry = self:entry(index) + if not entry then return nil, "not_owned" end + if entry.locked then return refusal(entry.locked) end + local rec = Model.plain(entry.rec) + self.mine = { index = index, ref = copy(entry.ref), rec = rec, adjust = { moves = {} } } + return self:refresh() +end + +function Model:refresh() + local m = self.mine + if not m then return nil end + local entry = self:entry(m.index) + local report + if entry and entry.locked then + report = refusal(entry.locked) + else + report = Model.convert(self.version, self.peerVersion, m.rec, m.adjust) + end + m.report = report + m.offerable = report.ok == true + return report +end + +function Model:stage(slot, move) + local m = self.mine + slot = tonumber(slot) + if not (m and slot and slot >= 1 and slot <= Policy.MAX_MOVES) then return nil, "bad_slot" end + if move == nil then + m.adjust.moves[slot] = nil + else + m.adjust.moves[slot] = move == false and 0 or move + end + return self:refresh() +end + +function Model:moveOptions(slot) + local m = self.mine + local opts = m and m.report and m.report.options and m.report.options.moves + return opts and opts[tonumber(slot) or -1] or nil +end + +function Model:stagedMoves() + local out = {} + if not self.mine then return out end + for slot, move in pairs(self.mine.adjust.moves) do out[#out + 1] = { slot = slot, move = move } end + table.sort(out, function(a, b) return a.slot < b.slot end) + return out +end + +local function half(srcVersion, dstVersion, source, final) + return { src = srcVersion, dst = dstVersion, source = TradeConvert.canonical(source), + result = TradeConvert.canonical(final) } +end + +function Model.offerDigest(h) + return TradeConvert.digest({ v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, src = h.src, dst = h.dst, + source = h.source, result = h.result }) +end + +function Model:payload() + local m = self.mine + if not (m and m.offerable) then return nil, "not_ready" end + local adjust = {} + for slot, move in pairs(m.adjust.moves) do adjust[#adjust + 1] = { slot = slot, move = move } end + table.sort(adjust, function(a, b) return a.slot < b.slot end) + local body = { v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, src = self.version, dst = self.peerVersion, + source = m.rec, adjust = adjust, preview = m.report.final } + local wire, why = Model.toWire(body) + if not wire then return nil, why end + local ok, text = pcall(require("src.link.Json").encode, wire) + if not ok or #text > Model.MAX_BYTES then return nil, "too_big" end + m.half = half(self.version, self.peerVersion, m.rec, m.report.final) + m.digest16 = Model.offerDigest(m.half) + return wire, m.digest16 +end + +function Model:receive(payload, verify) + self.peer = nil + self.refusal = nil + local function refuse(code, detail) + self.refusal = { code = code, detail = detail } + return nil, code, detail + end + if type(payload) ~= "table" then return refuse("bad_payload") end + local body = Model.fromWire(payload) + if body.v ~= Model.PAYLOAD_VERSION then return refuse("bad_payload", { field = "v" }) end + if body.policy ~= Policy.VERSION then return refuse("policy_mismatch", { mine = Policy.VERSION, theirs = body.policy }) end + if not genOf(body.src) then return refuse("unsupported_version", { version = body.src }) end + if body.dst ~= self.version then return refuse("wrong_destination", { claimed = body.dst, mine = self.version }) end + if self.peerVersion and body.src ~= self.peerVersion then + return refuse("wrong_source", { claimed = body.src, peer = self.peerVersion }) + end + if type(body.source) ~= "table" or type(body.preview) ~= "table" then return refuse("bad_payload") end + local adjust = { moves = {} } + for _, row in ipairs(type(body.adjust) == "table" and body.adjust or {}) do + local slot = tonumber(type(row) == "table" and row.slot) + if not slot or slot < 1 or slot > Policy.MAX_MOVES or slot % 1 ~= 0 or adjust.moves[slot] ~= nil then + return refuse("bad_payload", { field = "adjust" }) + end + adjust.moves[slot] = row.move + end + local report = Model.convert(body.src, self.version, body.source, adjust) + if not report.ok then + local b = report.blocks[1] or {} + return refuse(b.code or "bad_record", b.detail) + end + local claimed = TradeConvert.canonical(body.preview) + if claimed ~= report.finalCanonical then + return refuse("preview_mismatch", { used = report.datasets and report.datasets.source }) + end + if type(verify) == "function" then + local ok, code, detail = verify(report.final, report) + if not ok then return refuse(code or "not_valid_here", detail) end + end + self.peer = { source = body.source, adjust = adjust, report = report, final = report.final, + half = half(body.src, self.version, body.source, report.final), version = body.src } + self.peer.digest16 = Model.offerDigest(self.peer.half) + return true +end + +function Model.agreedDigest(seat0, seat1) + local function h(x) + return { offerRev = tonumber(x.offerRev) or 0, src = x.src, dst = x.dst, source = x.source, result = x.result } + end + return TradeConvert.digest({ v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, seats = { h(seat0), h(seat1) } }) +end + +function Model:agreed(mySeat, myOfferRev, peerOfferRev) + local m, p = self.mine, self.peer + if not (m and m.half and p and p.half) then return nil end + local mine = copy(m.half) + mine.offerRev = myOfferRev + local theirs = copy(p.half) + theirs.offerRev = peerOfferRev + if mySeat == 1 then return Model.agreedDigest(theirs, mine) end + return Model.agreedDigest(mine, theirs) +end + +local function side(report, versionFrom, versionTo, name) + if not report then return nil end + return { + from = versionFrom, to = versionTo, trainer = name, + sourceName = report.sourceName, speciesName = report.speciesName, national = report.national, + level = report.level, final = report.final, changes = report.changes or {}, blocks = report.blocks or {}, + evolves = report.evolves, ok = report.ok == true, + } +end + +function Model:summary() + return { + mine = self.mine and side(self.mine.report, self.version, self.peerVersion, nil) or nil, + theirs = self.peer and side(self.peer.report, self.peer.version, self.version, self.peerName) or nil, + refusal = self.refusal, + } +end + +return Model diff --git a/src/online/union/TradeTxn.lua b/src/online/union/TradeTxn.lua new file mode 100644 index 00000000..cd3af67e --- /dev/null +++ b/src/online/union/TradeTxn.lua @@ -0,0 +1,806 @@ +local GameVersion = require("src.core.GameVersion") +local Model = require("src.online.union.TradePrepModel") +local TradeConvert = require("src.online.xgen.TradeConvert") + +local Txn = {} +Txn.__index = Txn + +Txn.JOURNAL_VERSION = 1 +Txn.JOURNAL_TTL = 24 * 60 * 60 +Txn.RETRY_SECONDS = 5 +Txn.SAVE_RETRY_SECONDS = 3 +Txn.MARKERS = 32 +Txn.TICK_KEY = "union_xtrade" + +Txn.fs = nil +Txn.now = nil +Txn._applied = {} +Txn._live = {} +Txn._resolver = nil + +local function now() + if type(Txn.now) == "function" then return Txn.now() end + return os.time() +end + +local function SaveData() + return require("src.core.SaveData") +end + +local function disk() + if Txn.fs then return Txn.fs end + return SaveData().persistenceFs() +end + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end + +local function log(fmt, ...) + print("[union-trade] " .. fmt:format(...)) +end + +local function lt() + return require("src.core.game3.link.trade") +end + + +local function decode(text) + if type(text) ~= "string" or text == "" then return nil end + local ok, data = pcall(SaveData().decode, text) + if ok and type(data) == "table" then return data end + return nil +end + +local function readFile(fs, path) + if not fs.getInfo(path) then return nil end + local ok, text = pcall(fs.read, path) + return ok and text or nil +end + +function Txn.writeAtomic(path, text) + local fs = disk() + if not (fs and path) then return false end + local tmp = path .. ".tmp" + local ok, wrote = pcall(fs.write, tmp, text) + if not (ok and wrote) or readFile(fs, tmp) ~= text then return false end + if fs.getInfo(path) then pcall(fs.remove, path) end + ok, wrote = pcall(fs.write, path, text) + if not (ok and wrote) or readFile(fs, path) ~= text then return false end + pcall(fs.remove, tmp) + return true +end + +function Txn.journalPath(version) + local main = SaveData().saveFilename(version) + if type(main) ~= "string" or main == "" then return nil end + return (main:gsub("%.lua$", "")) .. "_xtrade.lua" +end + +function Txn.readJournal(version) + local fs = disk() + local path = Txn.journalPath(version) + if not (fs and path) then return {} end + local data = decode(readFile(fs, path)) or decode(readFile(fs, path .. ".tmp")) + if not (data and type(data.entries) == "table") then return {} end + local out = {} + for _, e in ipairs(data.entries) do + if type(e) == "table" and type(e.key) == "string" then out[#out + 1] = e end + end + return out +end + +function Txn.writeJournal(version, entries) + local fs = disk() + local path = Txn.journalPath(version) + if not (fs and path) then return false end + if #entries == 0 then + for _, p in ipairs({ path, path .. ".tmp" }) do + if fs.getInfo(p) then + local ok, removed = pcall(fs.remove, p) + if not ok or removed == false then return false end + end + end + return true + end + local ok, text = pcall(SaveData().encode, { v = Txn.JOURNAL_VERSION, entries = entries }) + if not ok then return false end + return Txn.writeAtomic(path, text) +end + +function Txn.journalPut(version, entry) + local kept = {} + for _, e in ipairs(Txn.readJournal(version)) do + if e.key ~= entry.key then kept[#kept + 1] = e end + end + kept[#kept + 1] = entry + return Txn.writeJournal(version, kept) +end + +function Txn.journalDrop(version, key) + local list, kept = Txn.readJournal(version), {} + for _, e in ipairs(list) do + if e.key ~= key then kept[#kept + 1] = e end + end + if #kept == #list then return true end + return Txn.writeJournal(version, kept) +end + +function Txn.pending(version) + return Txn.readJournal(version) +end + +function Txn.entryKey(room, n, digest) + return tostring(room) .. ":" .. tostring(n) .. ":" .. tostring(digest) +end + + +local function protocol() + return require("src.link.Protocol") +end + +local function identityOf(gen, rec) + if type(rec) ~= "table" then return nil end + if gen == 3 then + return ("%s:%s:%s"):format(tostring(tonumber(rec.personality) or 0), tostring((tonumber(rec.otId) or 0) % 65536), + tostring(tonumber(rec.otSecretId) or 0)) + end + local d = type(rec.dvs) == "table" and rec.dvs or {} + return ("%s:%s:%s:%s:%s:%s"):format(tostring(rec.ot or rec.otName), tostring(rec.otId), tostring(d.attack), + tostring(d.defense), tostring(d.speed), tostring(d.special or d.specialAttack)) +end +Txn.identityOf = identityOf + +local Adapter = {} +Adapter.__index = Adapter + +local function newAdapter(game, gen, version) + return setmetatable({ game = game, gen = gen, version = version }, Adapter) +end + +function Adapter:data() + return self.game and self.game.data +end + +function Adapter:save() + if self.gen == 3 then + local g = self.game + if g and g.session then return g.session end + local Runtime = package.loaded["src.core.game3.runtime"] + return Runtime and Runtime.getSession and Runtime.getSession() or nil + end + return self.game and self.game.save +end + +local function game3() + local Runtime = package.loaded["src.core.game3.runtime"] + return Runtime and Runtime._game or nil +end + +function Adapter:ready() + local s = self:save() + if type(s) ~= "table" or type(s.party) ~= "table" then return false end + local g = self.game or (self.gen == 3 and game3() or nil) + if self.gen == 3 and g and g.phase ~= nil and g.phase ~= "field" then return false end + return true +end + +local GEN_BOXES = { [1] = 12, [2] = 14 } + +function Adapter:lists() + local s = self:save() + local out = {} + if type(s) ~= "table" then return out end + out[#out + 1] = { where = "party", list = s.party or {} } + if self.gen ~= 3 then + if self.gen == 1 then require("src.pokemon.Boxes").ensure(s) end + s.boxes = s.boxes or {} + for b = 1, GEN_BOXES[self.gen] do + if type(s.boxes[b]) == "table" then out[#out + 1] = { where = "box", box = b, list = s.boxes[b] } end + end + end + return out +end + +function Adapter:listFor(ref) + local s = self:save() + if type(s) ~= "table" or type(ref) ~= "table" then return nil end + if ref.where == "party" then return s.party end + if ref.where == "box" and self.gen ~= 3 and type(s.boxes) == "table" then return s.boxes[tonumber(ref.box) or -1] end + return nil +end + +function Adapter:at(ref) + local list = self:listFor(ref) + return list and list[tonumber(ref.index) or -1] or nil +end + +function Adapter:pack(mon) + if type(mon) ~= "table" then return nil end + local P = protocol() + local ok, rec = pcall(function() + if self.gen == 3 then return P.packMon3(mon) end + if self.gen == 2 then return P.packMon2(mon) end + local r = P.packMon(mon) + r.catchRate = mon.catchRate + return r + end) + if not ok then return nil end + return Model.plain(rec) +end + +function Adapter:canonicalAt(ref) + local rec = self:pack(self:at(ref)) + return rec and TradeConvert.canonical(rec) or nil +end + +function Adapter:lockOf(ref, mon, pending) + if self.gen == 2 then + local Mail = require("src.core.gen2.Mail") + if Mail.monHoldsMail(mon) then return "mail" end + local s = self:save() + local mail = type(s.mail) == "table" and type(s.mail.party) == "table" and s.mail.party or nil + if ref.where == "party" and mail and mail[ref.index] ~= nil then return "mail" end + end + if type(mon) == "table" and (mon.rental == true or mon.projected == true) then return "rental" end + local id = pending and next(pending) ~= nil and identityOf(self.gen, self:pack(mon)) or nil + if id and pending[id] then return "pending" end + return nil +end + +function Adapter:owned() + local out, pending = {}, {} + for _, e in ipairs(Txn.readJournal(self.version)) do + if type(e.out) == "table" and e.out.identity then pending[e.out.identity] = true end + end + for _, l in ipairs(self:lists()) do + for i, mon in ipairs(l.list) do + local ref = { where = l.where, box = l.box, index = i } + out[#out + 1] = { ref = ref, rec = self:pack(mon), live = mon, locked = self:lockOf(ref, mon, pending) } + end + end + return out +end + +function Adapter:native(final) + if type(final) ~= "table" then return nil, "bad_record" end + local P = protocol() + local data = self:data() + local ok, mon, why = pcall(function() + if self.gen == 3 then return P.unpackMon3(nil, copy(final), { strict = true }) end + if self.gen == 2 then return P.unpackMon2(data, copy(final), { strict = true }) end + return P.unpackMon(data, copy(final), { strict = true }) + end) + if not ok then return nil, "not_valid_here", { error = tostring(mon) } end + if not mon then return nil, "not_valid_here", { error = why } end + if mon.level ~= final.level then return nil, "not_valid_here", { field = "level" } end + if type(final.moves) == "table" and #mon.moves ~= #final.moves then return nil, "not_valid_here", { field = "moves" } end + if self.gen == 1 then + mon.catchRate = final.catchRate + mon.traded = true + elseif self.gen == 2 then + mon.traded = true + end + return mon +end + +function Adapter:validate(final) + local mon, code, detail = self:native(final) + if not mon then return false, code, detail end + return true +end + +local function markDex1(save, species) + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.owned = save.pokedex.owned or {} + save.pokedex.seen[species] = true + save.pokedex.owned[species] = true +end + +function Adapter:evolve1(list, index, mon) + local data = self:data() + local def = data and data.pokemon and data.pokemon[mon.species] + for _, evo in ipairs(def and def.evolutions or {}) do + -- engine/pokemon/evos_moves.asm:70 + if evo.method == "TRADE" and data.pokemon[evo.species] then + require("src.pokemon.Evolution").apply(self.game, mon, evo.species, "TRADE") + return evo.species + end + end + return nil +end + +function Adapter:put(ref, mon) + local list = self:listFor(ref) + local index = tonumber(ref.index) + if not (list and index and list[index]) then return nil, "missing" end + local s = self:save() + local sent = list[index] + local evolved + if self.gen == 1 then + list[index] = mon + markDex1(s, mon.species) + -- engine/link/cable_club.asm:801 + if self.version == "yellow" then + pcall(function() require("src.world.PikachuFollower").modifyHappiness(s, "TRADE", sent) end) + end + evolved = self:evolve1(list, index, mon) + elseif self.gen == 2 then + local Evolution = require("src.core.gen2.Evolution") + list[index] = mon + Evolution.markPokedex(s, mon.species) + local data = self:data() + -- engine/pokemon/evolve.asm:144 + local entry = Evolution.checkMon(data, mon, { link = true }) + if entry then + local next = Evolution.apply(data, mon, entry) + if next then + list[index] = next + Evolution.markPokedex(s, entry.into) + evolved = entry.into + end + end + else + local Trade = require("src.core.game3.scripting.natives_trade") + if not Trade.tradeMons(s, index - 1, mon) then return nil, "swap" end + local okQ, key, args = pcall(Trade.noteLinkTrade, s, sent, mon, self.peerName, true) + if okQ and key then pcall(function() require("src.core.game3.quest_log_recorder").event(s, key, args) end) end + -- pokefirered/src/trade_scene.c:2311 + local Evolution = require("src.core.game3.evolution") + local target = Evolution.tradeTarget(mon, s) + if target then + Evolution.apply(mon, target, s, s.bag, "trade") + evolved = target + end + end + return true, evolved +end + +function Adapter:hasMarker(key) + if self.gen == 3 then return false end + local s = self:save() + for _, k in ipairs(type(s) == "table" and type(s.unionTrades) == "table" and s.unionTrades or {}) do + if k == key then return true end + end + return false +end + +function Adapter:mark(key) + if self.gen == 3 then return end + local s = self:save() + local list = type(s.unionTrades) == "table" and s.unionTrades or {} + list[#list + 1] = key + while #list > Txn.MARKERS do table.remove(list, 1) end + s.unionTrades = list +end + +function Adapter:write() + local g = self.game or (self.gen == 3 and game3() or nil) + if type(self.writer) == "function" then return self.writer(self) == true end + if self.gen == 3 then + local Runtime = package.loaded["src.core.game3.runtime"] + if Runtime and Runtime._mod and g then + local okB, Bridge = pcall(require, "src.core.game3.bridge") + if okB and Bridge.persistSessionOnly then + if not pcall(Bridge.persistSessionOnly, Runtime._mod, g) then return false end + end + end + if not (g and g.saveGame) then return false end + local ok, wrote = pcall(g.saveGame, g) + return ok and wrote ~= false + end + if g and g.writeSave then + local ok, wrote = pcall(g.writeSave, g) + if not ok then log("save failed: %s", tostring(wrote)) end + return ok and wrote ~= false and wrote ~= nil + end + local s = self:save() + if self.gen == 2 then + local ok, wrote = pcall(require("src.core.gen2.Save").save, s) + return ok and wrote == true + end + local ok, wrote = pcall(SaveData().save, s) + return ok and wrote == true +end + +function Adapter:scrubBackup() + local fs = disk() + local main = SaveData().saveFilename(self.version) + if not (fs and main) then return end + local bak = main .. ".bak" + if not fs.getInfo(bak) then return end + local body = readFile(fs, main) + if decode(body) then pcall(fs.write, bak, body) end +end + +function Adapter:find(test) + for _, l in ipairs(self:lists()) do + for i, mon in ipairs(l.list) do + local rec = self:pack(mon) + if rec and test(rec) then return { where = l.where, box = l.box, index = i } end + end + end + return nil +end + +Txn.Adapter = Adapter + +function Txn.adapterFor(game, opts) + opts = opts or {} + if type(opts.adapter) == "table" then return opts.adapter end + if type(game) == "table" and type(game.unionTradeAdapter) == "table" then return game.unionTradeAdapter end + local version = opts.version or (type(game) == "table" and type(game.save) == "table" and game.save.version) + if type(game) == "table" and type(game.session) == "table" and game.session.version then + version = opts.version or game.session.version + end + if not (type(version) == "string" and GameVersion.VERSIONS[version]) then version = GameVersion.get() end + return newAdapter(game, GameVersion.generation(version), version) +end + +function Txn.newAdapter(game, version) + return newAdapter(game, GameVersion.generation(version), version) +end + + +local function locateFor(adapter, entry) + if adapter:hasMarker(entry.key) then return nil, "already" end + local out = entry.out or {} + if out.ref and adapter:canonicalAt(out.ref) == out.canonical then return out.ref end + local inId = entry.incomingIdentity + if inId and adapter:find(function(r) return identityOf(adapter.gen, r) == inId end) then return nil, "already" end + local ref = adapter:find(function(r) return TradeConvert.canonical(r) == out.canonical end) + if ref then return ref end + ref = out.identity and adapter:find(function(r) return identityOf(adapter.gen, r) == out.identity end) + if ref then return ref end + return nil, "missing" +end + +function Txn.applyEntry(adapter, entry) + local ref, why = locateFor(adapter, entry) + if not ref then return why end + local mon, code = adapter:native(entry.incoming) + if not mon then return "invalid", code end + local ok, evolved = adapter:put(ref, mon) + if not ok then return "invalid", evolved end + adapter:mark(entry.key) + entry.evolved = evolved + return "applied", evolved +end + +function Txn.settleCommit(adapter, entry) + if Txn._applied[entry.key] == "saved" then + Txn.journalDrop(adapter.version, entry.key) + return "done" + end + local status = "applied" + if Txn._applied[entry.key] ~= "memory" then + local detail + status, detail = Txn.applyEntry(adapter, entry) + if status == "missing" or status == "invalid" then + log("committed trade %s could not be applied: %s %s", entry.key, status, tostring(detail)) + Txn.journalDrop(adapter.version, entry.key) + return status + end + Txn._applied[entry.key] = "memory" + end + if not adapter:write() then return "save_failed" end + adapter:scrubBackup() + Txn._applied[entry.key] = "saved" + Txn.journalDrop(adapter.version, entry.key) + return status == "already" and "done_already" or "done" +end + +function Txn.settle(adapter, entry, outcome) + if outcome == "abort" then + Txn.journalDrop(adapter.version, entry.key) + return "aborted" + end + if outcome == "commit" then + if entry.state ~= "committed" then + entry.state = "committed" + Txn.journalPut(adapter.version, entry) + end + return Txn.settleCommit(adapter, entry) + end + return nil +end + + +local function liveKeys() + local out = {} + for _, t in pairs(Txn._live) do + if t.entry then out[t.entry.key] = true end + end + return out +end + +function Txn.step(st, dt) + local adapter = st.adapter + if not adapter:ready() then return false end + local live = liveKeys() + if st.job then + local status, outcome = lt().pollOutcome(st.job) + if status == "pending" then return false end + local entry = st.job.entry + st.job = nil + local result = status == "ok" and Txn.settle(adapter, entry, outcome) or nil + local settled = result ~= nil and result ~= "save_failed" + st.fails = settled and 0 or math.min((st.fails or 0) + 1, 6) + st.wait = (result == "save_failed" and Txn.SAVE_RETRY_SECONDS or Txn.RETRY_SECONDS) * (settled and 1 or 2 ^ st.fails) + if settled then st.wait = 0 end + st.last = result or outcome or status + end + if (st.wait or 0) > 0 then + st.wait = st.wait - (tonumber(dt) or 0) + return false + end + local open = {} + for _, e in ipairs(Txn.readJournal(adapter.version)) do + if not live[e.key] then + if e.state == "committed" then + local r = Txn.settleCommit(adapter, e) + st.last = r + if r == "save_failed" then + st.wait = Txn.SAVE_RETRY_SECONDS + return false + end + elseif now() - (tonumber(e.at) or 0) > Txn.JOURNAL_TTL then + Txn.journalDrop(adapter.version, e.key) + else + open[#open + 1] = e + end + end + end + if #open == 0 then return true end + st.index = ((st.index or 0) % #open) + 1 + st.job = lt().fetchOutcome(open[st.index]) + return false +end + +local function tick() + local st = Txn._resolver + if not st then return end + local t = (type(love) == "table" and love.timer and love.timer.getTime) and love.timer.getTime() or os.clock() + local dt = st.lastTick and (t - st.lastTick) or 0 + st.lastTick = t + local ok, done = pcall(Txn.step, st, dt) + if not ok then + log("resolver failed: %s", tostring(done)) + done = true + end + if done then + Txn._resolver = nil + return + end + require("src.core.DeferredWrite").schedule(Txn.TICK_KEY, tick) +end + +function Txn.resumePending(game, opts) + local adapter = Txn.adapterFor(game, opts) + if #Txn.readJournal(adapter.version) == 0 then return nil end + if Txn._resolver and Txn._resolver.adapter.version == adapter.version then + Txn._resolver.adapter = adapter + return Txn._resolver + end + local st = { adapter = adapter, wait = 0, fails = 0, index = 0 } + Txn._resolver = st + if adapter:ready() then + for _, e in ipairs(Txn.readJournal(adapter.version)) do + if e.state == "committed" and not liveKeys()[e.key] then Txn.settleCommit(adapter, e) end + end + end + if not (opts and opts.manual) then + require("src.core.DeferredWrite").schedule(Txn.TICK_KEY, tick) + end + return st +end + + +function Txn.new(opts) + local adapter = opts.adapter or Txn.adapterFor(opts.game, opts) + local self = setmetatable({ + game = opts.game, prep = opts.prep, model = opts.model, adapter = adapter, + roomId = opts.roomId, peerName = opts.peerName, state = "prep", entry = nil, + digest = nil, events = {}, saveWait = 0, + }, Txn) + adapter.peerName = opts.peerName + Txn._live[self] = self + return self +end + +function Txn:emit(kind, fields) + local e = { kind = kind } + for k, v in pairs(fields or {}) do e[k] = v end + self.events[#self.events + 1] = e +end + +function Txn:room() + if self.roomId then return self.roomId end + local session = self.prep and self.prep.session + if session and session.snapshot then + local _, r = session.snapshot() + if type(r) == "table" and type(r.room) == "string" then return r.room end + end + return nil +end + +function Txn:send(msg) + local session = self.prep and self.prep.session + return session and session.send and session.send(msg) and true or false +end + +function Txn:cancel(why) + self.state = "aborted" + self.why = why + if self.prep and self.prep.cancel then self.prep:cancel(why) end + self:emit("aborted", { why = why, roundEnded = true }) +end + +function Txn:recheck() + local m = self.model and self.model.mine + if not (m and m.half) then return false, "no_offer" end + local s = self.adapter:save() + if type(s) ~= "table" then return false, "no_save" end + local list = self.adapter:listFor(m.ref) + if not (list and list[m.ref.index]) then return false, "capacity" end + if self.adapter:canonicalAt(m.ref) ~= m.half.source then return false, "changed" end + local ok, code = self.adapter:validate(self.model.peer and self.model.peer.final) + if not ok then return false, code end + return true +end + +function Txn:readyDigest() + local prep, m = self.prep, self.model + if not (prep and m and prep.mine.offer and prep.peer.offer) then return nil end + local mineRev = prep.mine.offer.offerRev + local peerRev = prep.peer.offer.offerRev + return m:agreed(prep:seat(), mineRev, peerRev) +end + +function Txn:confirm() + if self.state ~= "prep" and self.state ~= "ready" then return false end + local digest = self:readyDigest() + if not digest then + self:cancel("no_offer") + return false + end + local ok, why = self:recheck() + if not ok then + self:cancel("recheck_" .. tostring(why)) + return false + end + local room = self:room() + if not room then + self:cancel("no_room") + return false + end + local m = self.model + local round = (self.prep and self.prep.round or 0) + 1 + local entry = { + key = Txn.entryKey(room, round, digest), room = room, n = round, digest = digest, at = now(), + gen = self.adapter.gen, version = self.adapter.version, state = "confirming", + peer = { name = self.peerName, version = m.peer and m.peer.version }, + out = { ref = copy(m.mine.ref), record = copy(m.mine.rec), canonical = m.mine.half.source, + identity = identityOf(self.adapter.gen, m.mine.rec) }, + incoming = copy(m.peer.final), incomingCanonical = m.peer.half.result, + incomingIdentity = identityOf(self.adapter.gen, m.peer.final), + } + if not Txn.journalPut(self.adapter.version, entry) then + self:cancel("journal") + return false + end + self.entry, self.digest = entry, digest + self.state = "commit_wait" + self:send({ type = "trade_confirm", digest = digest }) + self:emit("confirming", { digest = digest }) + return true +end + +function Txn:onCommit(msg) + if self.state ~= "commit_wait" then return false end + local d = type(msg) == "table" and msg.digests or nil + if type(d) ~= "table" or d[1] ~= self.digest or d[2] ~= self.digest then return false end + local entry = self.entry + entry.state = "committed" + entry.commitN = tonumber(msg.n) + Txn.journalPut(self.adapter.version, entry) + local status, detail = Txn.applyEntry(self.adapter, entry) + if status == "missing" or status == "invalid" then + log("committed trade %s could not be applied: %s %s", entry.key, status, tostring(detail)) + Txn.journalDrop(self.adapter.version, entry.key) + self.state = "failed" + self:emit("failed", { why = status }) + return true + end + Txn._applied[entry.key] = "memory" + self.evolved = entry.evolved + self.state = "saving" + self:trySave() + return true +end + +function Txn:trySave() + if self.state ~= "saving" then return false end + if self.adapter:write() then + self.adapter:scrubBackup() + Txn._applied[self.entry.key] = "saved" + Txn.journalDrop(self.adapter.version, self.entry.key) + self.state = "done" + self:emit("done", { evolved = self.evolved }) + return true + end + self.saveFailed = true + self.saveWait = Txn.SAVE_RETRY_SECONDS + self:emit("save_failed", {}) + return false +end + +function Txn:onAbort(msg) + if self.state ~= "commit_wait" then return false end + Txn.journalDrop(self.adapter.version, self.entry.key) + self.state = "aborted" + self.why = type(msg) == "table" and msg.why or "aborted" + self:emit("aborted", { why = self.why }) + return true +end + +function Txn:lost() + if self.state ~= "commit_wait" then return false end + self.state = "unresolved" + Txn._live[self] = nil + Txn.resumePending(self.game, { adapter = self.adapter }) + self:emit("unresolved", {}) + return true +end + +local function isBarrier(m) + return type(m) == "table" and (m.type == "trade_commit" or m.type == "trade_abort") +end + +function Txn:pump(dt) + local session = self.prep and self.prep.session + if self.state == "commit_wait" and session and session.take then + for _ = 1, 16 do + local m = session.take(isBarrier) + if not m then break end + if m.type == "trade_commit" then self:onCommit(m) else self:onAbort(m) end + if self.state ~= "commit_wait" then break end + end + if self.state == "commit_wait" and session.open and not session.open() then self:lost() end + elseif self.state ~= "commit_wait" and session and session.take then + for _ = 1, 16 do + if not session.take(isBarrier) then break end + end + end + if self.state == "saving" then + self.saveWait = self.saveWait - (tonumber(dt) or 0) + if self.saveWait <= 0 then self:trySave() end + end + local out = self.events + self.events = {} + return out +end + +function Txn:nextRound() + if self.state == "commit_wait" or self.state == "saving" then return false end + self.state, self.entry, self.digest, self.evolved, self.why = "prep", nil, nil, nil, nil + return true +end + +function Txn:close() + if self.state == "commit_wait" then self:lost() end + Txn._live[self] = nil + if self.state == "saving" then Txn.resumePending(self.game, { adapter = self.adapter }) end +end + +function Txn.reset() + Txn._applied = {} + Txn._live = {} + Txn._resolver = nil +end + +return Txn diff --git a/src/online/xgen/Compat.lua b/src/online/xgen/Compat.lua new file mode 100644 index 00000000..b73efb3e --- /dev/null +++ b/src/online/xgen/Compat.lua @@ -0,0 +1,304 @@ +local Datasets = require("src.online.xgen.Datasets") +local Policy = require("src.online.xgen.Policy") +local Project = require("src.online.xgen.Project") +local TradeConvert = require("src.online.xgen.TradeConvert") + +local Compat = {} + +local copy = Project.copy + +local function asSet(list) + local out = {} + if type(list) ~= "table" then return out end + for k, v in pairs(list) do + if v == true then out[tonumber(k) or k] = true + elseif tonumber(v) then out[tonumber(v)] = true end + end + return out +end +Compat.asSet = asSet + +local function resolveRuleset(target) + target = target or {} + local id = target.ruleset or "g3u" + if id == "native" then return { id = "native", engine = "native", gen = target.gen } end + local base = Policy.ruleset(id) or (target.gen and Policy.rulesetForGen(target.gen)) or nil + local r = { id = base and base.id or id, engine = "g3u", gen = base and base.gen or target.gen, + dexMax = tonumber(target.dexMax) or (base and base.dexMax), moveMax = tonumber(target.moveMax) or (base and base.moveMax) } + return r +end +Compat.resolveRuleset = resolveRuleset + +function Compat.speciesEligible(national, ruleset) + national = tonumber(national) + if not national then return false, "species_unknown" end + if national > (ruleset.dexMax or 0) then + return false, "species_not_in_ruleset", { national = national, dexMax = ruleset.dexMax } + end + return true +end + +function Compat.moveStatus(data, national, level, move, ruleset, unsupported) + move = tonumber(move) + if not move or move > (ruleset.moveMax or 0) or not data.moves[move] then + return "move_not_in_ruleset" + end + if unsupported and unsupported[move] then return "move_unsupported" end + if not Datasets.learnable(data, national, move, level) then return "move_not_legal" end + return nil +end + +function Compat.legalMoves(data, national, level, ruleset, unsupported) + local out = {} + for move in pairs(Datasets.learnSources(data, national, level)) do + if not Compat.moveStatus(data, national, level, move, ruleset, unsupported) then out[#out + 1] = move end + end + table.sort(out) + return out +end + +function Compat.moveSuggestions(data, national, level, ruleset, original, exclude, unsupported) + local orig = data.moves[tonumber(original) or -1] + local rows = {} + for _, move in ipairs(Compat.legalMoves(data, national, level, ruleset, unsupported)) do + if not (exclude and exclude[move]) then + local mv = data.moves[move] + rows[#rows + 1] = { move = move, name = mv.name, key = mv.key or "", type = mv.type, + category = mv.category, power = mv.power, + sameType = orig ~= nil and mv.type == orig.type, + sameBand = orig ~= nil and mv.category == orig.category and Policy.powerBand(mv.power) == Policy.powerBand(orig.power) } + end + end + table.sort(rows, function(a, b) + if a.sameType ~= b.sameType then return a.sameType end + if a.sameBand ~= b.sameBand then return a.sameBand end + if a.key ~= b.key then return a.key < b.key end + return a.move < b.move + end) + return rows +end + +local function sharesType(a, b) + for _, x in ipairs(a or {}) do + for _, y in ipairs(b or {}) do + if x == y then return true end + end + end + return false +end + +function Compat.replacements(data, slotView, owned, ruleset, exclude, unsupported) + local want = data.species[slotView.national] + local wantTypes = want and want.types or {} + local rows = {} + for index, entry in ipairs(owned or {}) do + if not (exclude and exclude[index]) then + local rec = entry.rec or entry + local view = Project.read(rec, data) + if view and not view.isEgg and Compat.speciesEligible(view.national, ruleset) + and #Compat.legalMoves(data, view.national, view.level, ruleset, unsupported) > 0 then + local types = data.species[view.national].types + rows[#rows + 1] = { index = index, ref = entry.ref, national = view.national, level = view.level, + name = data.species[view.national].name, + sharesType = sharesType(types, wantTypes), samePrimary = types[1] == wantTypes[1], + distance = math.abs(view.level - slotView.level) } + end + end + end + table.sort(rows, function(a, b) + if a.sharesType ~= b.sharesType then return a.sharesType end + if a.samePrimary ~= b.samePrimary then return a.samePrimary end + if a.distance ~= b.distance then return a.distance < b.distance end + return a.index < b.index + end) + return rows +end + +function Compat.rentalCandidates(data, slotView, rentals) + local want = data.species[slotView.national] + local wantTypes = want and want.types or {} + local rows = {} + for index, r in ipairs(rentals or {}) do + local types = r.types or {} + rows[#rows + 1] = { index = index, rental = true, national = r.national, type = r.type, + sharesType = sharesType(types, wantTypes), samePrimary = types[1] == wantTypes[1] } + end + table.sort(rows, function(a, b) + if a.sharesType ~= b.sharesType then return a.sharesType end + if a.samePrimary ~= b.samePrimary then return a.samePrimary end + return a.index < b.index + end) + return rows +end + +function Compat.teamSize(mine, theirs, requested) + mine, theirs = tonumber(mine) or 0, tonumber(theirs) or mine + local size = math.min(mine, theirs) + requested = tonumber(requested) + if requested and requested >= 1 and requested <= size then size = requested end + return math.max(0, math.min(6, size)) +end + +local function versionBlocks(args, block) + local v = args.versions + if type(v) ~= "table" then return end + if v.policy ~= nil and v.policy ~= Policy.VERSION then block(nil, "policy_mismatch", nil, { mine = Policy.VERSION, theirs = v.policy }) end + if v.proto ~= nil and v.proto ~= Policy.PROTO then block(nil, "proto_mismatch", nil, { mine = Policy.PROTO, theirs = v.proto }) end +end + +local function battleReport(args, report, block) + local source = args.source or {} + local data = source.data + local ruleset = resolveRuleset(args.target) + local adjustments = args.adjustments or {} + local unsupported = asSet(args.unsupported) + local legacy = not (args.target and args.target.legacyPresent == false) + if type(data) ~= "table" then block(nil, "missing_import", nil, { need = "source" }) return end + local mons = args.mons or {} + local replace = adjustments.replace or {} + local sitOut = asSet(adjustments.sitOut) + local active = {} + for slot = 1, #mons do + if not sitOut[slot] then active[#active + 1] = slot end + end + local size = Compat.teamSize(#mons, args.opponentSize, adjustments.size) + if #active > size then + block(nil, "choose_sit_out", "team", { need = #active - size, size = size }) + local choices = {} + for _, slot in ipairs(active) do choices[#choices + 1] = slot end + report.options.sitOut = choices + elseif #active < size or #active == 0 then + block(nil, "team_too_small", "team", { size = size, have = #active }) + end + report.options.replacements, report.options.moves, report.options.rentals = {}, {}, {} + report.options.allowEmpty = true + local team = {} + local usedOwned = {} + for _, slot in ipairs(active) do + local r = replace[slot] + if r and r.owned then usedOwned[r.owned] = true end + end + for _, entry in ipairs(args.ownedInTeam or {}) do usedOwned[entry] = true end + if ruleset.engine == "native" then + if source.gen and ruleset.gen and source.gen ~= ruleset.gen then + block(nil, "native_needs_same_gen", nil, { mine = source.gen, ruleset = ruleset.gen }) + end + for _, slot in ipairs(active) do team[#team + 1] = copy(mons[slot]) end + report.result = { ruleset = ruleset.id, size = size, team = team, slots = copy(active) } + return + end + for _, slot in ipairs(active) do + local r = replace[slot] + local rec = mons[slot] + local rental = nil + if r and r.owned then + local entry = (args.owned or {})[r.owned] + rec = entry and (entry.rec or entry) + report.changes[#report.changes + 1] = { slot = slot, field = "species", from = slot, to = { owned = r.owned }, kind = "change" } + elseif r and r.rental then + rental = (args.rentals or {})[r.rental] + report.changes[#report.changes + 1] = { slot = slot, field = "species", from = slot, to = { rental = r.rental }, kind = "change" } + end + if rental then + team[#team + 1] = copy(rental.record or rental) + else + local view, code, detail = Project.read(rec, data) + if not view then + block(slot, code, nil, detail) + elseif view.isEgg then + block(slot, "egg", "isEgg") + else + local okSpecies, why, info = Compat.speciesEligible(view.national, ruleset) + if not okSpecies then + block(slot, why, "species", info) + report.options.replacements[slot] = Compat.replacements(data, view, args.owned, ruleset, usedOwned, unsupported) + report.options.rentals[slot] = Compat.rentalCandidates(data, view, args.rentals) + else + local moveAdj = (adjustments.moves or {})[slot] or {} + local final, seen, current = {}, {}, {} + for _, m in ipairs(view.moves) do current[m.move] = true end + for j, m in ipairs(view.moves) do + local adj = moveAdj[j] + if adj ~= nil then + if adj == 0 or adj == false then + report.changes[#report.changes + 1] = { slot = slot, field = "moves", from = m.move, to = 0, kind = "change", index = j } + else + local status = Compat.moveStatus(data, view.national, view.level, adj, ruleset, unsupported) + if status or seen[adj] then + block(slot, "replacement_not_legal", "moves", { index = j, move = adj, why = status or "duplicate" }) + else + seen[adj] = true + final[#final + 1] = { move = adj, ppUps = 0 } + report.changes[#report.changes + 1] = { slot = slot, field = "moves", from = m.move, to = adj, kind = "change", index = j } + end + end + else + local status = Compat.moveStatus(data, view.national, view.level, m.move, ruleset, unsupported) + if status then + block(slot, status, "moves", { index = j, move = m.move }) + report.options.moves[slot] = report.options.moves[slot] or {} + report.options.moves[slot][j] = Compat.moveSuggestions(data, view.national, view.level, ruleset, m.move, current, unsupported) + elseif not seen[m.move] then + seen[m.move] = true + final[#final + 1] = { move = m.move, ppUps = m.ppUps } + end + end + end + local pending = report.options.moves[slot] ~= nil + if #final == 0 and not pending then block(slot, "no_legal_moves", "moves") end + local projected = Project.battleMon(view, data, { moves = final, legacyPresent = legacy }) + if view.gen < 3 then + report.changes[#report.changes + 1] = { slot = slot, field = "dvs", from = copy(view.dvs), to = copy(projected.ivs), kind = "change" } + report.changes[#report.changes + 1] = { slot = slot, field = "statExp", from = copy(view.statExp), to = copy(projected.evs), kind = "change" } + elseif legacy then + if view.nature ~= Policy.NATURE_NEUTRAL then + report.changes[#report.changes + 1] = { slot = slot, field = "nature", from = view.nature, to = Policy.NATURE_NEUTRAL, kind = "change" } + end + if view.ability and view.ability ~= 0 then + report.changes[#report.changes + 1] = { slot = slot, field = "ability", from = view.ability, to = 0, kind = "change" } + end + if view.item and view.item ~= 0 then + report.changes[#report.changes + 1] = { slot = slot, field = "item", from = view.item, to = 0, kind = "change" } + end + end + team[#team + 1] = projected + end + end + end + end + report.result = { ruleset = ruleset.id, dexMax = ruleset.dexMax, moveMax = ruleset.moveMax, + size = size, team = team, slots = copy(active) } +end + +function Compat.report(args) + args = args or {} + local report = { ok = false, blocks = {}, options = {}, changes = {}, result = nil } + local function block(slot, code, field, detail) + report.blocks[#report.blocks + 1] = { slot = slot, code = code, field = field, detail = detail } + end + versionBlocks(args, block) + if args.op == "battle" then + battleReport(args, report, block) + elseif args.op == "trade" then + local mon = args.mon or (args.mons or {})[1] + local t = TradeConvert.convert({ source = args.source, target = args.target, mon = mon, + adjustments = args.adjustments, slot = args.slot or 1 }) + for _, b in ipairs(t.blocks) do report.blocks[#report.blocks + 1] = b end + for _, c in ipairs(t.changes) do report.changes[#report.changes + 1] = c end + report.options = t.options + report.result = t.result + report.preview = t.preview + report.canonical = t.canonical + report.accounting = t.accounting + else + block(nil, "bad_op", nil, { op = args.op }) + end + report.ok = #report.blocks == 0 + if not report.ok and args.op == "battle" then + report.preview = report.result + report.result = nil + end + return report +end + +return Compat diff --git a/src/online/xgen/Datasets.lua b/src/online/xgen/Datasets.lua new file mode 100644 index 00000000..4ab190e9 --- /dev/null +++ b/src/online/xgen/Datasets.lua @@ -0,0 +1,408 @@ +local Identity = require("src.online.xgen.Identity") + +local Datasets = {} + +local cached = {} +local readerOverride = nil + +local GB_FILES = { pokemon = "data/generated/pokemon.lua", moves = "data/generated/moves.lua", + items = "data/generated/items.lua" } +local GBA_ROOT = "data/generated/gba/pokemon/" +local GBA_FILES = { + names = GBA_ROOT .. "names.lua", national = GBA_ROOT .. "national.lua", + types = GBA_ROOT .. "types.lua", stats = GBA_ROOT .. "stats.lua", meta = GBA_ROOT .. "meta.lua", + abilities = GBA_ROOT .. "abilities.lua", battleMoves = GBA_ROOT .. "battle_moves.lua", + moveNames = GBA_ROOT .. "move_names.lua", learnsets = GBA_ROOT .. "learnsets.lua", + tmhm = GBA_ROOT .. "tmhm.lua", tutor = GBA_ROOT .. "tutor.lua", eggMoves = GBA_ROOT .. "egg_moves.lua", + evolutions = GBA_ROOT .. "evolutions.lua", items = "data/generated/gba/items/pack.lua", + typeNames = GBA_ROOT .. "type_names.lua", +} +local GBA_REQUIRED = { "names", "national", "types", "stats", "meta", "battleMoves", "moveNames", + "learnsets", "tmhm", "eggMoves", "evolutions", "items" } +local NO_TUTORS = { ruby = true, sapphire = true } + +local function versionInfo(version) + local GameVersion = require("src.core.GameVersion") + return GameVersion.generation(version), GameVersion.cachePrefix(version) +end + +local function defaultReader(version, rel) + local _, prefix = versionInfo(version) + return require("src.import.CacheFs").readAt(prefix .. rel) +end + +function Datasets.directoryReader(root) + local CacheBlob = require("src.import.CacheBlob") + return function(version, rel) + local _, prefix = versionInfo(version) + local path = root .. "/" .. prefix .. rel + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return CacheBlob.decode(path, body) + end +end + +function Datasets.setReader(fn) + readerOverride = fn + cached = {} +end + +function Datasets.reset() + cached = {} +end + +local function evaluate(body, label) + if type(body) ~= "string" then return nil end + local chunk = (loadstring or load)(body, "@" .. label) + if not chunk then return nil end + if setfenv then setfenv(chunk, {}) end + local ok, value = pcall(chunk) + return ok and type(value) == "table" and value or nil +end + +local function readTables(version, reader) + local generation = versionInfo(version) + local files = generation == 3 and GBA_FILES or GB_FILES + local out = {} + for name, rel in pairs(files) do + out[name] = evaluate(reader(version, rel), version .. "/" .. rel) + end + return out +end + +local function placeholder(name) + return type(name) ~= "string" or name == "" or name:match("^%?+$") ~= nil or name == "TERU-SAMA" +end + +local function addLevel(list, level, move) + if move then list[#list + 1] = { level = tonumber(level) or 1, move = move } end +end + +local function sortLevels(list) + for i, row in ipairs(list) do row.order = i end + table.sort(list, function(x, y) + if x.level ~= y.level then return x.level < y.level end + return x.order < y.order + end) + for _, row in ipairs(list) do row.order = nil end +end + +local function newData(version, generation) + return { + version = version, generation = generation, + dexMax = generation == 1 and 151 or generation == 2 and 251 or 386, + moveMax = generation == 1 and 165 or generation == 2 and 251 or 354, + species = {}, moves = {}, + localToNational = {}, nationalToLocal = {}, + localToMove = {}, moveToLocal = {}, + localToType = {}, typeToLocal = {}, + items = { byLocal = {}, byKey = {}, held = generation ~= 1 }, + problems = {}, + } +end + +local function problem(data, code, detail) + data.problems[#data.problems + 1] = { code = code, detail = detail } +end + +local function buildGB(data, raw) + local generation = data.generation + local Growth = generation == 1 and require("src.pokemon.Growth") or nil + local Mon = generation == 2 and require("src.battle.gen2.Mon") or nil + local growthRows = raw.pokemon and raw.pokemon.growthRates + for key, def in pairs(raw.moves or {}) do + if type(def) == "table" and tonumber(def.index) and def.index >= 1 and def.index <= data.moveMax then + local id = def.index + local typeName = Identity.canonType(def.type, generation) + data.localToMove[key], data.moveToLocal[id] = id, key + if def.type ~= nil and typeName then data.localToType[def.type], data.typeToLocal[typeName] = typeName, def.type end + data.moves[id] = { id = id, key = Identity.normalize(def.name or key), name = def.name or tostring(key), + localKey = key, type = typeName, power = tonumber(def.power) or 0, accuracy = tonumber(def.accuracy), + pp = tonumber(def.pp) or 0, effect = def.effect, effectChance = def.effectChance, + category = Identity.category(typeName, def.power) } + end + end + for key, def in pairs(raw.pokemon or {}) do + if type(def) == "table" and tonumber(def.dex) and def.dex >= 1 and def.dex <= data.dexMax then + local n = def.dex + data.localToNational[key], data.nationalToLocal[n] = n, key + local types = {} + for _, t in ipairs(def.types or {}) do + local name = Identity.canonType(t, generation) + if name and types[#types] ~= name then types[#types + 1] = name end + end + local b = def.baseStats or {} + local base = { hp = b.hp, atk = b.attack, def = b.defense, spe = b.speed, + spa = b.specialAttack or b.special, spd = b.specialDefense or b.special, special = b.special } + local curve = def.growthRate + local exp = {} + for level = 1, 100 do + if generation == 1 then + exp[level] = Growth.expForLevel(curve, level) + else + exp[level] = Mon.experienceForLevel(growthRows and growthRows[curve], level) + end + end + local levelMoves, tmhm, tutor, egg = {}, {}, {}, {} + for _, m in ipairs(def.level1Moves or {}) do addLevel(levelMoves, 1, m) end + for _, row in ipairs(def.learnset or {}) do addLevel(levelMoves, row.level, row.move) end + for _, row in ipairs(def.levelMoves or {}) do addLevel(levelMoves, row.level, row.move) end + for _, m in ipairs(def.tmhm or {}) do tmhm[#tmhm + 1] = m end + for _, m in ipairs(def.tutorMoves or {}) do tutor[#tutor + 1] = m end + for _, m in ipairs(def.eggMoves or {}) do egg[#egg + 1] = m end + local evolutions = {} + for _, evo in ipairs(def.evolutions or {}) do + evolutions[#evolutions + 1] = { into = evo.into or evo.species, method = evo.method, + level = evo.level, item = evo.item } + end + data.species[n] = { national = n, key = Identity.normalize(def.name or key), name = def.name or tostring(key), + localKey = key, types = types, base = base, genderRatio = def.genderRatio, + catchRate = def.catchRate, growth = curve, exp = exp, friendship = generation == 2 and 70 or nil, + rawLevel = levelMoves, rawTmhm = tmhm, rawTutor = tutor, rawEgg = egg, rawEvolutions = evolutions } + end + end + local Mail = generation == 2 and require("src.core.gen2.Mail") or nil + for key, def in pairs(generation == 2 and raw.items or {}) do + if type(def) == "table" and not placeholder(def.name) then + local norm = Identity.normalize(def.name) + if norm then + data.items.byLocal[key] = { key = norm, name = def.name, localKey = key, + mail = Mail and Mail.isMail(key) or false, index = def.index } + if data.items.byKey[norm] ~= nil and data.items.byKey[norm] ~= key then + problem(data, "item_name_collision", { key = norm }) + end + data.items.byKey[norm] = key + end + end + end + data.items.timeCapsule = raw.items and raw.items.timeCapsule + data.raw = { items = raw.items, pokemon = raw.pokemon, moves = raw.moves } +end + +local CAPE_BRINK_VERSIONS = { firered = true, leafgreen = true } + +local function bitSet(value, index) + return math.floor((tonumber(value) or 0) / 2 ^ index) % 2 == 1 +end + +local function buildGBA(data, raw) + local generation = 3 + local SummaryData = require("src.core.game3.summary_data") + for id = 1, data.moveMax do + local row = raw.battleMoves and raw.battleMoves.moves and raw.battleMoves.moves[id] + local name = raw.moveNames and raw.moveNames[id] + if type(row) == "table" and not placeholder(name) then + local typeName = Identity.canonType(row.type, generation) + data.localToMove[id], data.moveToLocal[id] = id, id + data.moves[id] = { id = id, key = Identity.normalize(name), name = name, localKey = id, + type = typeName, power = tonumber(row.power) or 0, accuracy = tonumber(row.accuracy), + pp = tonumber(row.pp) or 0, priority = tonumber(row.priority) or 0, effect = row.effect, + effectChance = row.secondaryChance, target = row.target, flags = row.flags, + category = Identity.category(typeName, row.power) } + end + end + for id = 0, 17 do + local name = Identity.GEN3_TYPES[id] + data.localToType[id], data.typeToLocal[name] = name, id + local shown = raw.typeNames and raw.typeNames[id] + if shown and name ~= "MYSTERY" and not Identity.subsequence(Identity.normalize(shown) or "", name) then + problem(data, "type_name_mismatch", { id = id, shown = shown, canonical = name }) + end + end + local toSpecies = raw.national and raw.national.toSpecies or {} + local machines = raw.tmhm and raw.tmhm.machines or {} + local tmLearn = raw.tmhm and raw.tmhm.learnsets or {} + local tutorMoves = raw.tutor and raw.tutor.moves or {} + local tutorLearn = raw.tutor and raw.tutor.learnsets or {} + local tutorCount = 0 + while tutorMoves[tutorCount] ~= nil do tutorCount = tutorCount + 1 end + for n = 1, data.dexMax do + local internal = tonumber(toSpecies[n]) + local name = internal and raw.names and raw.names[internal] + local stats = internal and raw.stats and raw.stats[internal] + local meta = internal and raw.meta and raw.meta[internal] + if internal and not placeholder(name) and type(stats) == "table" and type(meta) == "table" then + data.localToNational[internal], data.nationalToLocal[n] = n, internal + local types = {} + for _, t in ipairs(raw.types and raw.types[internal] or {}) do + local tn = Identity.canonType(t, generation) + if tn and types[#types] ~= tn then types[#types + 1] = tn end + end + local exp = {} + for level = 1, 100 do exp[level] = SummaryData.expForLevel(meta.growthRate, level) end + local levelMoves, tmhm, tutor, egg = {}, {}, {}, {} + for _, row in ipairs(raw.learnsets and raw.learnsets[internal] or {}) do + addLevel(levelMoves, row[1] or row.level, tonumber(row[2] or row.move)) + end + local tm = tmLearn[internal] + if type(tm) == "table" then + for index = 0, 57 do + local bits = index < 32 and tm.lo or tm.hi + if bitSet(bits, index < 32 and index or index - 32) and machines[index] then + tmhm[#tmhm + 1] = tonumber(machines[index]) + end + end + end + local tutorBits = tutorLearn[internal] + for index = 0, tutorCount - 1 do + if bitSet(tutorBits, index) then tutor[#tutor + 1] = tonumber(tutorMoves[index]) end + end + if CAPE_BRINK_VERSIONS[data.version] then + for _, row in pairs(require("src.core.game3.move_learn").CAPE_BRINK) do + if row.species == internal then tutor[#tutor + 1] = row.move end + end + end + for _, m in ipairs(raw.eggMoves and raw.eggMoves[internal] or {}) do egg[#egg + 1] = tonumber(m) end + local evolutions = {} + for _, evo in ipairs(raw.evolutions and raw.evolutions[internal] or {}) do + evolutions[#evolutions + 1] = { into = tonumber(evo.target), method = evo.method, param = evo.param } + end + local pair = raw.abilities and raw.abilities[internal] or {} + data.species[n] = { national = n, key = Identity.normalize(name), name = name, localKey = internal, + types = types, base = { hp = stats.hp, atk = stats.atk, def = stats.def, spe = stats.spe, + spa = stats.spa, spd = stats.spd }, + genderRatio = meta.genderRatio, catchRate = meta.catchRate, growth = meta.growthRate, exp = exp, + friendship = meta.friendship, abilities = { tonumber(pair[1]) or 0, tonumber(pair[2]) or 0 }, + rawLevel = levelMoves, rawTmhm = tmhm, rawTutor = tutor, rawEgg = egg, rawEvolutions = evolutions } + end + end + for id, def in pairs(raw.items and raw.items.items or {}) do + if type(def) == "table" and tonumber(id) and not placeholder(def.name) then + local norm = Identity.normalize(def.name) + if norm and norm ~= "" then + data.items.byLocal[id] = { key = norm, name = def.name, localKey = id, + mail = def.fieldUseName == "ItemUseOutOfBattle_Mail", index = id } + if data.items.byKey[norm] == nil or id < data.items.byKey[norm] then data.items.byKey[norm] = id end + end + end + end + data.raw = { items = raw.items } +end + +local function canonicalizeLearnsets(data) + local function moveOf(m) + if type(m) == "number" and data.generation == 3 then return data.moves[m] and m or nil end + return data.localToMove[m] + end + local function setOf(list) + local out = {} + for _, m in ipairs(list) do + local id = moveOf(m) + if id then out[id] = true end + end + return out + end + for n, sp in pairs(data.species) do + local level = {} + for _, row in ipairs(sp.rawLevel) do + local id = moveOf(row.move) + if id then level[#level + 1] = { level = row.level, move = id } end + end + sortLevels(level) + sp.levelMoves, sp.tmhm, sp.tutor, sp.egg = level, setOf(sp.rawTmhm), setOf(sp.rawTutor), setOf(sp.rawEgg) + local evolutions = {} + for _, evo in ipairs(sp.rawEvolutions) do + local into = evo.into + local target = into ~= nil and data.localToNational[into] or nil + if target then + evolutions[#evolutions + 1] = { into = target, method = evo.method, level = evo.level, param = evo.param } + local child = data.species[target] + if child and child.evolvesFrom == nil then child.evolvesFrom = n end + end + end + sp.evolutions = evolutions + sp.rawLevel, sp.rawTmhm, sp.rawTutor, sp.rawEgg, sp.rawEvolutions = nil, nil, nil, nil, nil + end +end + +function Datasets.build(version, raw) + local generation = versionInfo(version) + if not generation then return nil, "unknown_version" end + local data = newData(version, generation) + if generation == 3 then + for _, name in ipairs(GBA_REQUIRED) do + if type(raw[name]) ~= "table" then return nil, "missing_import", { file = GBA_FILES[name] } end + end + if not NO_TUTORS[version] and type(raw.tutor) ~= "table" then + return nil, "missing_import", { file = GBA_FILES.tutor } + end + buildGBA(data, raw) + else + if type(raw.pokemon) ~= "table" or type(raw.moves) ~= "table" then + return nil, "missing_import", { file = GB_FILES.pokemon } + end + buildGB(data, raw) + end + canonicalizeLearnsets(data) + data.ready = next(data.species) ~= nil and next(data.moves) ~= nil + if not data.ready then return nil, "missing_import" end + return data +end + +function Datasets.get(version) + if cached[version] ~= nil then return cached[version] or nil, cached[version] == false and "missing_import" or nil end + local reader = readerOverride or defaultReader + local data = Datasets.build(version, readTables(version, reader)) + cached[version] = data or false + if not data then return nil, "missing_import" end + return data +end + +function Datasets.put(version, data) + cached[version] = data +end + +function Datasets.chain(data, national) + local out, seen = {}, {} + local n = tonumber(national) + while n and data.species[n] and not seen[n] do + seen[n] = true + out[#out + 1] = n + n = data.species[n].evolvesFrom + end + return out +end + +function Datasets.learnSources(data, national, level) + level = tonumber(level) or 100 + local out = {} + local function add(move, source) + if move and move <= data.moveMax then + out[move] = out[move] or {} + out[move][source] = true + end + end + for depth, n in ipairs(Datasets.chain(data, national)) do + local sp = data.species[n] + local tag = depth == 1 and "" or "_prevo" + for _, row in ipairs(sp.levelMoves) do + if row.level <= level then add(row.move, "level" .. tag) end + end + for move in pairs(sp.tmhm) do add(move, "tmhm" .. tag) end + for move in pairs(sp.tutor) do add(move, "tutor" .. tag) end + for move in pairs(sp.egg) do add(move, "egg" .. tag) end + end + return out +end + +function Datasets.learnable(data, national, move, level) + local sources = Datasets.learnSources(data, national, level)[tonumber(move) or -1] + return sources ~= nil, sources +end + +function Datasets.expAt(data, national, level) + local sp = data.species[tonumber(national) or -1] + return sp and sp.exp[math.max(1, math.min(100, math.floor(tonumber(level) or 1)))] or nil +end + +function Datasets.levelForExp(data, national, exp) + local sp = data.species[tonumber(national) or -1] + if not sp then return nil end + local level = 1 + while level < 100 and (sp.exp[level + 1] or math.huge) <= (tonumber(exp) or 0) do level = level + 1 end + return level +end + +return Datasets diff --git a/src/online/xgen/Identity.lua b/src/online/xgen/Identity.lua new file mode 100644 index 00000000..2c88c0a7 --- /dev/null +++ b/src/online/xgen/Identity.lua @@ -0,0 +1,129 @@ +local Identity = {} + +-- include/constants/pokemon.h:6 +Identity.GEN3_TYPES = { + [0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON", [4] = "GROUND", + [5] = "ROCK", [6] = "BUG", [7] = "GHOST", [8] = "STEEL", [9] = "MYSTERY", + [10] = "FIRE", [11] = "WATER", [12] = "GRASS", [13] = "ELECTRIC", [14] = "PSYCHIC", + [15] = "ICE", [16] = "DRAGON", [17] = "DARK", +} + +Identity.TYPES = { "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", + "STEEL", "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK" } + +Identity.GEN1_TYPES = { "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", + "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON" } + +local GEN3_TYPE_ID = {} +for id, name in pairs(Identity.GEN3_TYPES) do GEN3_TYPE_ID[name] = id end +Identity.GEN3_TYPE_ID = GEN3_TYPE_ID + +local PHYSICAL = {} +-- include/battle.h:466 +for id, name in pairs(Identity.GEN3_TYPES) do PHYSICAL[name] = id < 9 end +Identity.PHYSICAL = PHYSICAL + +function Identity.normalize(name) + if type(name) ~= "string" then return nil end + local s = name:upper() + s = s:gsub("\226\153\128", "F"):gsub("\226\153\130", "M") + s = s:gsub("", "F"):gsub("", "M") + s = s:gsub("\195\169", "E"):gsub("\195\137", "E") + s = s:gsub("[^A-Z0-9]", "") + if s == "" then return nil end + return s +end + +function Identity.canonType(value, generation) + if generation == 3 or type(value) == "number" then + return Identity.GEN3_TYPES[tonumber(value) or -1] + end + if type(value) ~= "string" then return nil end + local s = value:upper():gsub("_TYPE$", "") + if s == "CURSE" or s == "???" then return "MYSTERY" end + if GEN3_TYPE_ID[s] then return s end + return nil +end + +function Identity.subsequence(short, long) + if type(short) ~= "string" or type(long) ~= "string" then return false end + local at = 1 + for i = 1, #short do + local found = long:find(short:sub(i, i), at, true) + if not found then return false end + at = found + 1 + end + return true +end + +function Identity.category(typeName, power) + if (tonumber(power) or 0) == 0 then return "status" end + return PHYSICAL[typeName] and "physical" or "special" +end + +function Identity.speciesOf(data, localKey) + if type(data) ~= "table" or localKey == nil then return nil end + local n = data.localToNational[localKey] + if n == nil and data.generation == 3 then n = data.localToNational[tonumber(localKey)] end + return n +end + +function Identity.localSpecies(data, national) + return data and data.nationalToLocal[tonumber(national) or -1] or nil +end + +function Identity.moveOf(data, localKey) + if type(data) ~= "table" or localKey == nil then return nil end + local n = data.localToMove[localKey] + if n == nil and data.generation == 3 then n = data.localToMove[tonumber(localKey)] end + return n +end + +function Identity.localMove(data, move) + return data and data.moveToLocal[tonumber(move) or -1] or nil +end + +function Identity.itemKey(data, localItem) + if type(data) ~= "table" or localItem == nil or localItem == 0 then return nil end + local row = data.items.byLocal[localItem] + if row == nil and data.generation == 3 then row = data.items.byLocal[tonumber(localItem)] end + return row and row.key or nil +end + +function Identity.localItem(data, key) + return data and key and data.items.byKey[key] or nil +end + +function Identity.maps(data) + return { + species = { toCanonical = data.localToNational, toLocal = data.nationalToLocal }, + moves = { toCanonical = data.localToMove, toLocal = data.moveToLocal }, + types = { toCanonical = data.localToType, toLocal = data.typeToLocal }, + items = { toCanonical = data.items.byLocal, toLocal = data.items.byKey }, + } +end + +local function compareNames(kind, a, b, out, max) + local left = kind == "species" and a.species or a.moves + local right = kind == "species" and b.species or b.moves + local ids = {} + for id in pairs(left) do + if right[id] and id <= max then ids[#ids + 1] = id end + end + table.sort(ids) + for _, id in ipairs(ids) do + local x, y = left[id].key, right[id].key + if x ~= y and not Identity.subsequence(x, y) and not Identity.subsequence(y, x) then + out[#out + 1] = { kind = kind, id = id, a = a.version, b = b.version, left = x, right = y } + end + end +end + +function Identity.agree(a, b) + local out = {} + compareNames("species", a, b, out, math.min(a.dexMax, b.dexMax)) + compareNames("moves", a, b, out, math.min(a.moveMax, b.moveMax)) + return #out == 0, out +end + +return Identity diff --git a/src/online/xgen/Messages.lua b/src/online/xgen/Messages.lua new file mode 100644 index 00000000..4361945e --- /dev/null +++ b/src/online/xgen/Messages.lua @@ -0,0 +1,146 @@ +local Messages = {} + +Messages.GB_WIDTH = 18 + +local TEXT = { + missing_import = "Import {need} first.", + policy_mismatch = "The other player has a different Union Room version.", + proto_mismatch = "The other player has a different Union Room version.", + bad_op = "That can't be done here.", + bad_record = "This Pokemon's data can't be read.", + species_unknown = "This Pokemon's species is unknown.", + move_unknown = "One of its moves is unknown.", + egg = "Eggs can't be used here.", + mail = "Take the Mail off first.", + item_unknown = "Its held item is unknown.", + item_unrepresentable = "The other game has no {item}. Take it off first.", + species_missing = "{species} doesn't exist in the other game.", + species_not_in_ruleset = "{species} can't join this battle. Only No. 1 to {dexMax} can.", + move_not_in_ruleset = "{move} isn't used in this battle.", + move_unsupported = "{move} can't be used in this battle.", + move_not_legal = "{species} can't learn {move}.", + move_missing = "{move} doesn't exist in the other game.", + replacement_not_legal = "That move can't be chosen.", + no_legal_moves = "It needs at least one move.", + no_moves = "It needs at least one move.", + nickname_unencodable = "Its nickname can't be written in the other game.", + ot_unencodable = "Its trainer name can't be written in the other game.", + ot_invalid = "Its trainer ID is invalid.", + personality_unrepresentable = "It can't keep its looks in the other game.", + traits_unrepresentable = "It can't keep its looks in the other game.", + choose_sit_out = "Choose {need} to sit out.", + team_too_small = "Choose {size} Pokemon.", + native_needs_same_gen = "This battle needs the same generation.", + rental_type_mismatch = "A rental Pokemon is unavailable.", + rental_duplicate_move = "A rental Pokemon is unavailable.", + rental_bad_moves = "A rental Pokemon is unavailable.", + unknown_ruleset = "These rules are unknown.", +} + +local CHANGE = { + change = "{field} will change.", + loss = "{field} will be lost.", +} + +local FIELD = { + dvs = "Its strength values", ivs = "Its strength values", statExp = "Its training", evs = "Its training", + exp = "Its Exp. Points", experience = "Its Exp. Points", nickname = "Its nickname", + moves = "A move", pp = "PP", item = "Its held item", heldItem = "Its held item", + catchRate = "Its held item", happiness = "Its friendship", friendship = "Its friendship", + pokerus = "Pokerus", ribbons = "Its ribbons", contest = "Its contest stats", nature = "Its nature", + ability = "Its ability", abilityNum = "Its ability", personality = "Its personality", + origin = "Where it was met", caughtData = "Where it was met", shiny = "Its color", + gender = "Its gender", unownLetter = "Its form", species = "The Pokemon", + hp = "Its HP", status = "Its status", markings = "Its marks", otSecretId = "Its trainer data", + otGender = "Its trainer data", language = "Its language", metLocation = "Where it was met", + metLevel = "Where it was met", metGame = "Where it was met", pokeball = "Its Ball", + caughtLevel = "Where it was met", caughtTime = "When it was met", caughtLocation = "Where it was met", + caughtByGender = "Its trainer data", caughtGender = "Its trainer data", caught = "Where it was met", + fatefulEncounter = "Its obedience mark", modernFatefulEncounter = "Its obedience mark", + championRibbon = "Its ribbons", extra = "Its extra data from mods", + egg = "Its egg data", eggSteps = "Its egg data", eggCycles = "Its egg data", isEgg = "Its egg data", + sleep = "Its status", sleepTurns = "Its status", + ot = "Its trainer data", otId = "Its trainer ID", otName = "Its trainer name", level = "Its level", + speciesId = "The Pokemon", stats = "Its stats", baseStats = "Its stats", types = "Its type", + evolution = "How it evolves", mail = "Its Mail", ppBonuses = "PP", ppBonusesPacked = "PP", +} + +local function fill(template, detail, names) + detail = detail or {} + names = names or {} + return (template:gsub("{(%w+)}", function(key) + local value = detail[key] + if key == "species" then + local n = detail.national or detail.species + value = names.species and names.species(n) or (n and ("No. " .. tostring(n))) or "It" + elseif key == "move" and detail.move ~= nil then + value = names.move and names.move(detail.move) or ("move " .. tostring(detail.move)) + elseif key == "item" and detail.item ~= nil then + value = names.item and names.item(detail.item) or tostring(detail.item) + elseif key == "need" and type(value) == "table" then + value = "the other game" + end + return value ~= nil and tostring(value) or "" + end)) +end + +local function wrap(text, width) + local lines, line = {}, "" + for word in text:gmatch("%S+") do + if line == "" then + line = word + elseif #line + 1 + #word <= width then + line = line .. " " .. word + else + lines[#lines + 1] = line + line = word + end + end + if line ~= "" then lines[#lines + 1] = line end + return lines +end + +function Messages.style(gen) + return (tonumber(gen) or 3) >= 3 and "gba" or "gb" +end + +function Messages.format(text, gen) + if Messages.style(gen) == "gb" then + return wrap(text:upper(), Messages.GB_WIDTH) + end + return { text } +end + +function Messages.text(code, detail, names) + local template = TEXT[code] or "That can't be done." + return fill(template, detail, names) +end + +function Messages.about(name, code, detail, names) + local template = TEXT[code] or "That can't be done." + if not template:find("{species}", 1, true) then return tostring(name) .. ": " .. fill(template, detail, names) end + local own = {} + for k, v in pairs(names or {}) do own[k] = v end + own.species = function() return name end + return fill(template, detail, own) +end + +function Messages.render(code, gen, detail, names) + return Messages.format(Messages.text(code, detail, names), gen) +end + +function Messages.block(row, gen, names) + return Messages.render(row.code, gen, row.detail, names) +end + +function Messages.change(row, gen) + local template = CHANGE[row.kind] or CHANGE.change + local field = FIELD[row.field] or FIELD[(tostring(row.field):match("^(%a+)"))] or "Some data" + return Messages.format(fill(template, { field = field }), gen) +end + +function Messages.known(code) + return TEXT[code] ~= nil +end + +return Messages diff --git a/src/online/xgen/Policy.lua b/src/online/xgen/Policy.lua new file mode 100644 index 00000000..2bd9626e --- /dev/null +++ b/src/online/xgen/Policy.lua @@ -0,0 +1,131 @@ +local Policy = {} + +Policy.VERSION = 1 +Policy.RENTALS = 1 +Policy.PROTO = 1 + +Policy.RULESETS = { + ["g3u-gen1"] = { id = "g3u-gen1", engine = "g3u", gen = 1, dexMax = 151, moveMax = 165 }, + ["g3u-gen2"] = { id = "g3u-gen2", engine = "g3u", gen = 2, dexMax = 251, moveMax = 251 }, + ["g3u-gen3"] = { id = "g3u-gen3", engine = "g3u", gen = 3, dexMax = 386, moveMax = 354 }, +} + +function Policy.ruleset(id) + return Policy.RULESETS[id] +end + +function Policy.rulesetForGen(gen) + return Policy.RULESETS["g3u-gen" .. tostring(gen)] +end + +Policy.GEN_LIMITS = { + [1] = { dexMax = 151, moveMax = 165 }, + [2] = { dexMax = 251, moveMax = 251 }, + [3] = { dexMax = 386, moveMax = 354 }, +} + +Policy.STAT_ORDER = { "hp", "atk", "def", "spe", "spa", "spd" } +Policy.GB_STAT = { hp = "hp", atk = "attack", def = "defense", spe = "speed", spa = "special", spd = "special" } +Policy.GB_STAT_ORDER = { "hp", "attack", "defense", "speed", "special" } +Policy.EV_TOTAL = 510 +Policy.EV_MAX = 255 +Policy.STAT_EXP_MAX = 65535 +Policy.NATURE_NEUTRAL = 0 +Policy.ABILITY_SLOT = 0 +Policy.LEVEL = { min = 1, max = 100 } +Policy.RENTAL_LEVEL = 50 +Policy.MAX_MOVES = 4 + +-- constants/pokemon_data_constants.asm:231 +Policy.GEN2_BASE_HAPPINESS = 70 +-- include/constants/region_map_sections.h:228 +Policy.METLOC_IN_GAME_TRADE = 0xFE +-- include/constants/items.h:13 +Policy.ITEM_POKE_BALL = 4 +-- include/constants/global.h:8 +Policy.GBA_VERSION_ID = { sapphire = 1, ruby = 2, emerald = 3, firered = 4, leafgreen = 5 } +Policy.GBA_LANGUAGE_ENGLISH = 2 + +Policy.NAME_LIMIT = { nickname = 10, ot = 7 } + +Policy.POWER_BANDS = { 0, 40, 70, 100 } + +function Policy.powerBand(power) + power = tonumber(power) or 0 + if power <= 0 then return 0 end + for i = 2, #Policy.POWER_BANDS do + if power <= Policy.POWER_BANDS[i] then return i - 1 end + end + return #Policy.POWER_BANDS +end + +function Policy.maxPp(base, ups) + base = tonumber(base) or 0 + return base + (tonumber(ups) or 0) * math.floor(base / 5) +end + +function Policy.ivFromDv(dv) + return (tonumber(dv) or 0) * 2 + 1 +end + +function Policy.dvFromIv(iv) + return math.floor((tonumber(iv) or 0) / 2) +end + +function Policy.evFromStatExp(statExp) + return math.min(Policy.EV_MAX, math.floor(math.sqrt(math.max(0, tonumber(statExp) or 0)))) +end + +function Policy.statExpFromEv(ev) + local v = math.max(0, tonumber(ev) or 0) + return math.min(Policy.STAT_EXP_MAX, v * v) +end + +Policy.BATTLE = { + { field = "species", rule = "national dex kept; must be <= ruleset dexMax" }, + { field = "level", rule = "kept" }, + { field = "ivs", rule = "Gen 3 IVs kept; Gen 1/2 DV d -> IV 2d+1 (HP from the derived HP DV); Special DV -> SpA and SpD" }, + { field = "evs", rule = "Gen 3 EVs kept; Gen 1/2 Stat Exp s -> EV min(255, floor(sqrt(s))); Special -> SpA and SpD; 510 total cap applied in HP, Atk, Def, Spe, SpA, SpD order" }, + { field = "baseStats", rule = "owner's own game base stats; Gen 1 base Special used for both SpA and SpD" }, + { field = "stats", rule = "Gen 3 formula from projected IV/EV, owner's base stats and nature" }, + { field = "nature", rule = "neutral (Hardy) when a Gen 1/2 player is present, else the Gen 3 nature" }, + { field = "ability", rule = "off when a Gen 1/2 player is present, else the Gen 3 ability" }, + { field = "item", rule = "off when a Gen 1/2 player is present" }, + { field = "types", rule = "owner's own game types, canonical names" }, + { field = "moves", rule = "canonical id <= ruleset moveMax and learnable per the owner's game data; illegal moves are replaced by the player or left empty; order kept" }, + { field = "pp", rule = "owner's base PP with the same PP Up count; current PP full" }, + { field = "gender", rule = "Gen 2 DV rule, Gen 3 personality rule, none for Gen 1" }, + { field = "shiny", rule = "Gen 1/2 DV rule, Gen 3 personality rule" }, + { field = "hp", rule = "full" }, + { field = "status", rule = "clear" }, +} + +Policy.TRADE = { + { field = "species", rule = "national dex kept; must exist in the destination game" }, + { field = "level", rule = "kept" }, + { field = "exp", rule = "kept, clamped to the destination growth curve range of the kept level" }, + { field = "dvs", rule = "Gen 3 -> Gen 1/2: nearest DVs to floor(IV/2) that keep shininess, Gen 2 gender and Unown letter; Special from SpA; HP DV derived" }, + { field = "ivs", rule = "Gen 1/2 -> Gen 3: IV = 2*DV+1, HP from the HP DV, SpA and SpD from Special" }, + { field = "statExp", rule = "Gen 3 EV e -> Stat Exp min(65535, e*e); Special from SpA" }, + { field = "evs", rule = "Gen 1/2 Stat Exp s -> EV min(255, floor(sqrt(s))); Special -> SpA and SpD; 510 cap in slot order" }, + { field = "personality", rule = "Gen 1/2 -> Gen 3: deterministic search from the record digest for a Hardy, even personality that keeps gender, shininess and Unown letter with Secret ID 0" }, + { field = "nature", rule = "from personality" }, + { field = "ability", rule = "Gen 1/2 -> Gen 3: slot 0; Gen 3 -> Gen 3: kept; Gen 3 -> Gen 1/2: lost" }, + { field = "moves", rule = "canonical id must exist in the destination; kept when learnable in the destination game OR learnable in the source game; replacements limited to moves the species learns in the destination game" }, + { field = "pp", rule = "destination base PP with the same PP Up count, full" }, + { field = "nickname", rule = "kept when the destination charset can encode it, else refused; a nickname equal to the species name becomes the destination default" }, + { field = "ot", rule = "OT name and ID kept when encodable, else refused" }, + { field = "item", rule = "kept by canonical name when it exists in the destination, else refused until removed; mail refused; Gen 2 -> Gen 1 stored as catch rate; Gen 1 -> Gen 2 from catch rate" }, + { field = "friendship", rule = "kept; Gen 1 -> Gen 2 is 70; Gen 1 -> Gen 3 is the species base; Gen 1 target loses it" }, + { field = "pokerus", rule = "kept Gen 2 <-> Gen 3; Gen 1 target loses it" }, + { field = "origin", rule = "Gen 3 target: met in a trade, met level = level, Poke Ball, destination game id; Gen 2 target from another gen: caught data cleared; Gen 3 -> Gen 3 kept" }, + { field = "ribbons", rule = "kept Gen 3 -> Gen 3, else lost" }, + { field = "contest", rule = "kept Gen 3 -> Gen 3, else lost" }, + { field = "egg", rule = "refused" }, + { field = "mail", rule = "refused" }, + { field = "hp", rule = "full" }, + { field = "status", rule = "clear" }, + { field = "evolution", rule = "not run here; the destination game applies its own trade evolution after receipt" }, +} + +return Policy diff --git a/src/online/xgen/Project.lua b/src/online/xgen/Project.lua new file mode 100644 index 00000000..8f593866 --- /dev/null +++ b/src/online/xgen/Project.lua @@ -0,0 +1,288 @@ +local bit = require("bit") +local Identity = require("src.online.xgen.Identity") +local Datasets = require("src.online.xgen.Datasets") +local Policy = require("src.online.xgen.Policy") + +local Project = {} + +local function int(v, lo, hi, default) + local n = tonumber(v) + if not n or n ~= n then return default end + n = math.floor(n) + if lo and n < lo then n = lo end + if hi and n > hi then n = hi end + return n +end + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end +Project.copy = copy + +function Project.hpDv(dvs) + -- engine/pokemon/move_mon.asm:1483 + return (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + (dvs.speed % 2) * 2 + (dvs.special % 2) +end + +function Project.shinyDv(dvs) + -- engine/gfx/color.asm:8 + if dvs.defense ~= 10 or dvs.speed ~= 10 or dvs.special ~= 10 then return false end + return dvs.attack % 4 >= 2 +end + +function Project.genderDv(ratio, dvs) + return require("src.core.gen2.Gender").of(ratio, dvs) +end + +function Project.unownLetterDv(dvs) + return require("src.core.gen2.Unown").letterFromDVs(dvs) +end + +function Project.shiny3(personality, otId, otSecretId) + local p = (tonumber(personality) or 0) % 4294967296 + -- src/pokemon.c:6740 + local value = bit.bxor(bit.bxor(int(otId, 0, 65535, 0), int(otSecretId, 0, 65535, 0)), + bit.bxor(math.floor(p / 65536), p % 65536)) + return value < 8 +end + +function Project.gender3(ratio, personality) + -- src/pokemon.c:3452 + if ratio == nil or ratio == 255 then return "unknown" end + if ratio == 0 then return "male" end + if ratio == 254 then return "female" end + return ratio > (int(personality, 0, nil, 0) % 256) and "female" or "male" +end + +function Project.unownLetter3(personality) + return require("src.core.game3.pokemon").unownLetter(personality) +end + +local function gen3MoveRows(rec) + local rows = {} + local list = type(rec.moves) == "table" and rec.moves or {} + for slot = 1, math.min(4, #list) do + local entry = list[slot] + local id = type(entry) == "table" and tonumber(entry.id or entry.moveId or entry.move) or tonumber(entry) + local ups + if type(rec.ppBonusesPacked) == "number" then + ups = math.floor(rec.ppBonusesPacked / 4 ^ (slot - 1)) % 4 + elseif type(rec.ppBonuses) == "number" then + ups = math.floor(rec.ppBonuses / 4 ^ (slot - 1)) % 4 + elseif type(entry) == "table" then + ups = int(entry.ppUps, 0, 3, 0) + end + local pp = tonumber(rec.pp and rec.pp[slot]) or (type(entry) == "table" and tonumber(entry.pp)) or nil + rows[#rows + 1] = { localKey = id, ppUps = ups or 0, pp = pp } + end + return rows +end + +local function gbMoveRows(rec, data) + local rows = {} + for _, entry in ipairs(type(rec.moves) == "table" and rec.moves or {}) do + local key = type(entry) == "table" and (entry.id or entry.move) or entry + local ups = type(entry) == "table" and tonumber(entry.ppUps) or nil + if ups == nil and type(entry) == "table" and tonumber(entry.maxPp) then + local mv = data.moves[Identity.moveOf(data, key) or -1] + local base = mv and mv.pp or 0 + local bonus = math.floor(base / 5) + if bonus > 0 and entry.maxPp > base and (entry.maxPp - base) % bonus == 0 then + ups = (entry.maxPp - base) / bonus + end + end + rows[#rows + 1] = { localKey = key, ppUps = int(ups, 0, 3, 0), + pp = type(entry) == "table" and tonumber(entry.pp) or nil } + end + return rows +end + +function Project.read(rec, data) + if type(rec) ~= "table" then return nil, "bad_record" end + if type(data) ~= "table" then return nil, "missing_import" end + local gen = data.generation + local localSpecies = rec.species + if gen == 3 then localSpecies = tonumber(rec.speciesId or rec.species) end + local national = Identity.speciesOf(data, localSpecies) + if not national then return nil, "species_unknown" end + local sp = data.species[national] + local view = { gen = gen, version = data.version, national = national, localSpecies = localSpecies, + speciesName = sp.name, isEgg = rec.isEgg == true or rec.isBadEgg == true } + local rows = gen == 3 and gen3MoveRows(rec) or gbMoveRows(rec, data) + view.moves = {} + for _, row in ipairs(rows) do + if row.localKey ~= nil and row.localKey ~= 0 then + local id = Identity.moveOf(data, row.localKey) + if not id then return nil, "move_unknown", { move = row.localKey } end + view.moves[#view.moves + 1] = { move = id, ppUps = row.ppUps, pp = row.pp } + end + end + if #view.moves > Policy.MAX_MOVES then return nil, "bad_record", { field = "moves" } end + view.exp = tonumber(rec.exp or rec.experience) + view.level = int(rec.level, 1, 100, nil) + if not view.level and view.exp then view.level = Datasets.levelForExp(data, national, view.exp) end + if not view.level then return nil, "bad_record", { field = "level" } end + view.nickname = type(rec.nickname) == "string" and rec.nickname ~= "" and rec.nickname or nil + view.otName = rec.otName or rec.ot + view.otId = tonumber(rec.otId) + view.otSecretId = gen == 3 and int(rec.otSecretId, 0, 65535, nil) or nil + if gen == 3 and view.otId and view.otId > 65535 then + view.otSecretId = view.otSecretId or math.floor(view.otId / 65536) + view.otId = view.otId % 65536 + end + if gen == 3 then + local iv, ev = type(rec.ivs) == "table" and rec.ivs or {}, type(rec.evs) == "table" and rec.evs or {} + view.ivs, view.evs = {}, {} + for _, k in ipairs(Policy.STAT_ORDER) do + view.ivs[k] = int(iv[k], 0, 31, 0) + view.evs[k] = int(ev[k], 0, 255, 0) + end + view.personality = int(rec.personality, 0, 4294967295, 0) + view.nature = view.personality % 25 + view.abilityNum = int(rec.abilityNum, 0, 1, nil) + if view.abilityNum == nil then + local pair = sp.abilities or {} + view.abilityNum = (pair[2] or 0) ~= 0 and view.personality % 2 or 0 + end + view.ability = (sp.abilities or {})[view.abilityNum + 1] + if view.ability == 0 then view.ability = (sp.abilities or {})[1] end + view.friendship = int(rec.friendship or rec.happiness, 0, 255, nil) + local item = rec.heldItem + if item == nil then item = rec.item end + view.item = tonumber(item) or 0 + else + if type(rec.dvs) ~= "table" then return nil, "bad_record", { field = "dvs" } end + local d = rec.dvs + view.dvs = { attack = int(d.attack, 0, 15, 0), defense = int(d.defense, 0, 15, 0), + speed = int(d.speed, 0, 15, 0), special = int(d.special or d.specialAttack, 0, 15, 0) } + view.dvs.hp = Project.hpDv(view.dvs) + local se = type(rec.statExp) == "table" and rec.statExp or {} + view.statExp = {} + for _, k in ipairs(Policy.GB_STAT_ORDER) do + local v = se[k] + if k == "special" and v == nil then v = se.specialAttack end + view.statExp[k] = int(v, 0, 65535, 0) + end + if gen == 2 then + view.item = rec.item + view.friendship = int(rec.happiness, 0, 255, nil) + else + view.catchRate = int(rec.catchRate, 0, 255, nil) + end + end + view.pokerus = int(rec.pokerus, 0, 255, 0) + return view +end + +function Project.ivs(view) + if view.gen == 3 then return copy(view.ivs) end + local d = view.dvs + return { hp = Policy.ivFromDv(d.hp), atk = Policy.ivFromDv(d.attack), def = Policy.ivFromDv(d.defense), + spe = Policy.ivFromDv(d.speed), spa = Policy.ivFromDv(d.special), spd = Policy.ivFromDv(d.special) } +end + +function Project.evs(view) + if view.gen == 3 then return copy(view.evs) end + local s = view.statExp + local raw = { hp = s.hp, atk = s.attack, def = s.defense, spe = s.speed, spa = s.special, spd = s.special } + local out, total = {}, 0 + for _, k in ipairs(Policy.STAT_ORDER) do + local ev = math.min(Policy.evFromStatExp(raw[k]), Policy.EV_TOTAL - total) + out[k], total = ev, total + ev + end + return out +end + +function Project.natureMultiplier(nature, statIndex) + -- src/pokemon.c:1365 + local up, down = math.floor(nature / 5) + 1, nature % 5 + 1 + if up == down then return 1 end + if statIndex == up then return 1.1 end + if statIndex == down then return 0.9 end + return 1 +end + +function Project.stats3(base, level, ivs, evs, nature, national) + nature = tonumber(nature) or 0 + local out = {} + -- src/pokemon.c:2845 + if national == 292 then + out.hp = 1 + else + out.hp = math.floor(((2 * base.hp + ivs.hp + math.floor(evs.hp / 4)) * level) / 100) + level + 10 + end + -- src/pokemon.c:2814 + for index, key in ipairs({ "atk", "def", "spe", "spa", "spd" }) do + local n = math.floor(((2 * base[key] + ivs[key] + math.floor(evs[key] / 4)) * level) / 100) + 5 + local mul = Project.natureMultiplier(nature, index) + if mul > 1 then n = math.floor(n * 110 / 100) elseif mul < 1 then n = math.floor(n * 90 / 100) end + out[key] = n + end + return out +end + +function Project.moveRecord(data, move, ppUps) + local mv = data.moves[move] + if not mv then return nil end + local maxPp = Policy.maxPp(mv.pp, ppUps) + return { id = mv.id, name = mv.name, type = mv.type, power = mv.power, accuracy = mv.accuracy, + pp = maxPp, maxPp = maxPp, ppUps = ppUps or 0, basePp = mv.pp, priority = mv.priority, + effect = mv.effect, effectChance = mv.effectChance, category = mv.category, + sourceGen = data.generation } +end + +function Project.traits(view, data) + local sp = data.species[view.national] + if view.gen == 3 then + local letter = view.national == 201 and Project.unownLetter3(view.personality) or nil + return { gender = Project.gender3(sp.genderRatio, view.personality), + shiny = Project.shiny3(view.personality, view.otId, view.otSecretId), unownLetter = letter } + end + return { gender = view.gen == 2 and Project.genderDv(sp.genderRatio, view.dvs) or nil, + shiny = Project.shinyDv(view.dvs), + unownLetter = view.national == 201 and Project.unownLetterDv(view.dvs) - 1 or nil } +end + +function Project.battleMon(view, data, opts) + opts = opts or {} + local sp = data.species[view.national] + local legacy = opts.legacyPresent ~= false + local ivs, evs = Project.ivs(view), Project.evs(view) + local nature = (not legacy and view.gen == 3) and view.nature or Policy.NATURE_NEUTRAL + local level = view.level + local stats = Project.stats3(sp.base, level, ivs, evs, nature, view.national) + local moves = {} + local list = opts.moves or view.moves + for _, m in ipairs(list) do + local mv = data.moves[m.move] + if mv then + local ups = math.max(0, math.min(3, math.floor(tonumber(m.ppUps) or 0))) + moves[#moves + 1] = { id = mv.id, pp = Policy.maxPp(mv.pp, ups), ppUps = ups } + end + end + local traits = Project.traits(view, data) + local out = { + national = view.national, species = view.national, nickname = view.nickname or sp.name, + level = level, hp = stats.hp, maxHp = stats.hp, atk = stats.atk, def = stats.def, + spAtk = stats.spa, spDef = stats.spd, speed = stats.spe, moves = moves, + ability = 0, item = 0, ivs = ivs, evs = evs, nature = nature, + gender = traits.gender, shiny = traits.shiny, unownLetter = traits.unownLetter, + sourceGen = view.gen, rental = opts.rental or nil, + } + if not legacy and view.gen == 3 then + out.ability, out.abilityNum, out.personality = view.ability or 0, view.abilityNum, view.personality + out.item = tonumber(view.item) or 0 + end + return out +end + +function Project.mon(rec, data, opts) + local view, code, detail = Project.read(rec, data) + if not view then return nil, code, detail end + return Project.battleMon(view, data, opts) +end + +return Project diff --git a/src/online/xgen/Rentals.lua b/src/online/xgen/Rentals.lua new file mode 100644 index 00000000..5719393a --- /dev/null +++ b/src/online/xgen/Rentals.lua @@ -0,0 +1,139 @@ +local Datasets = require("src.online.xgen.Datasets") +local Policy = require("src.online.xgen.Policy") +local Project = require("src.online.xgen.Project") + +local Rentals = {} + +Rentals.VERSION = 1 +Rentals.LEVEL = 50 +Rentals.IV = 20 +Rentals.PERSONALITY = 150 + +Rentals.DEFINITIONS = { + ["g3u-gen1"] = { + { type = "NORMAL", species = 128, moves = { 70, 33, 59, 87 } }, + { type = "FIGHTING", species = 68, moves = { 66, 67, 126, 89 } }, + { type = "FLYING", species = 142, moves = { 17, 126, 36, 44 } }, + { type = "POISON", species = 89, moves = { 124, 87, 126, 1 } }, + { type = "GROUND", species = 28, moves = { 89, 70, 40, 163 } }, + { type = "ROCK", species = 76, moves = { 157, 88, 126, 89 } }, + { type = "BUG", species = 127, moves = { 66, 70, 11, 15 } }, + { type = "GHOST", species = 94, moves = { 122, 87, 94, 70 } }, + { type = "FIRE", species = 59, moves = { 126, 53, 36, 52 } }, + { type = "WATER", species = 130, moves = { 56, 57, 59, 87 } }, + { type = "GRASS", species = 3, moves = { 75, 22, 15, 33 } }, + { type = "ELECTRIC", species = 135, moves = { 87, 84, 36, 44 } }, + { type = "PSYCHIC", species = 97, moves = { 94, 93, 29, 1 } }, + { type = "ICE", species = 124, moves = { 59, 8, 94, 1 } }, + { type = "DRAGON", species = 149, moves = { 59, 87, 126, 57 } }, + }, + ["g3u-gen2"] = { + { type = "NORMAL", species = 128, moves = { 70, 30, 59, 87 } }, + { type = "FIGHTING", species = 68, moves = { 223, 238, 126, 89 } }, + { type = "FLYING", species = 18, moves = { 17, 16, 211, 185 } }, + { type = "POISON", species = 89, moves = { 188, 124, 87, 126 } }, + { type = "GROUND", species = 51, moves = { 89, 189, 188, 163 } }, + { type = "ROCK", species = 76, moves = { 157, 88, 126, 89 } }, + { type = "BUG", species = 127, moves = { 66, 70, 168, 11 } }, + { type = "GHOST", species = 94, moves = { 247, 122, 87, 94 } }, + { type = "STEEL", species = 208, moves = { 231, 89, 21, 157 } }, + { type = "FIRE", species = 59, moves = { 126, 53, 231, 36 } }, + { type = "WATER", species = 130, moves = { 56, 57, 59, 87 } }, + { type = "GRASS", species = 3, moves = { 202, 75, 15, 22 } }, + { type = "ELECTRIC", species = 135, moves = { 87, 84, 231, 36 } }, + { type = "PSYCHIC", species = 65, moves = { 94, 60, 247, 7 } }, + { type = "ICE", species = 124, moves = { 59, 8, 94, 247 } }, + { type = "DRAGON", species = 149, moves = { 225, 239, 59, 87 } }, + { type = "DARK", species = 197, moves = { 44, 185, 231, 36 } }, + }, + ["g3u-gen3"] = { + { type = "NORMAL", species = 128, moves = { 70, 290, 59, 87 } }, + { type = "FIGHTING", species = 68, moves = { 223, 238, 126, 89 } }, + { type = "FLYING", species = 18, moves = { 17, 332, 211, 290 } }, + { type = "POISON", species = 89, moves = { 188, 124, 87, 126 } }, + { type = "GROUND", species = 51, moves = { 89, 189, 188, 161 } }, + { type = "ROCK", species = 76, moves = { 157, 88, 38, 126 } }, + { type = "BUG", species = 127, moves = { 89, 66, 70, 185 } }, + { type = "GHOST", species = 94, moves = { 247, 325, 87, 94 } }, + { type = "STEEL", species = 208, moves = { 231, 89, 21, 157 } }, + { type = "FIRE", species = 59, moves = { 315, 126, 231, 36 } }, + { type = "WATER", species = 130, moves = { 56, 57, 59, 87 } }, + { type = "GRASS", species = 3, moves = { 202, 345, 89, 188 } }, + { type = "ELECTRIC", species = 135, moves = { 87, 85, 231, 36 } }, + { type = "PSYCHIC", species = 65, moves = { 94, 60, 231, 247 } }, + { type = "ICE", species = 124, moves = { 59, 58, 94, 247 } }, + { type = "DRAGON", species = 149, moves = { 337, 225, 59, 87 } }, + { type = "DARK", species = 197, moves = { 44, 185, 231, 36 } }, + }, +} + +function Rentals.definitions(rulesetId) + return Project.copy(Rentals.DEFINITIONS[rulesetId]) +end + +local function hasType(types, want) + for _, t in ipairs(types or {}) do + if t == want then return true end + end + return false +end + +function Rentals.validate(def, ruleset, data, unsupported) + local sp = data.species[def.species] + if def.species > ruleset.dexMax or not sp then return "species_not_in_ruleset" end + if not hasType(sp.types, def.type) then return "rental_type_mismatch" end + local seen = {} + for _, move in ipairs(def.moves) do + if seen[move] then return "rental_duplicate_move", { move = move } end + seen[move] = true + if move > ruleset.moveMax or not data.moves[move] then return "move_not_in_ruleset", { move = move } end + if unsupported and unsupported[move] then return "move_unsupported", { move = move } end + if not Datasets.learnable(data, def.species, move, Rentals.LEVEL) then return "move_not_legal", { move = move } end + end + if #def.moves < 1 or #def.moves > Policy.MAX_MOVES then return "rental_bad_moves" end + return nil +end + +function Rentals.build(rulesetId, data, opts) + opts = opts or {} + local ruleset = Policy.ruleset(rulesetId) + local out = { version = Rentals.VERSION, ruleset = rulesetId, rentals = {}, excluded = {} } + local defs = Rentals.DEFINITIONS[rulesetId] + if not ruleset or not defs then + out.excluded[1] = { code = "unknown_ruleset", detail = { ruleset = rulesetId } } + return out + end + if type(data) ~= "table" then + out.excluded[1] = { code = "missing_import" } + return out + end + local unsupported = {} + for k, v in pairs(opts.unsupported or {}) do + if v == true then unsupported[tonumber(k) or k] = true elseif tonumber(v) then unsupported[tonumber(v)] = true end + end + for index, def in ipairs(defs) do + local code, detail = Rentals.validate(def, ruleset, data, unsupported) + if code then + out.excluded[#out.excluded + 1] = { index = index, type = def.type, species = def.species, code = code, detail = detail } + else + local ivs, evs = {}, {} + for _, k in ipairs(Policy.STAT_ORDER) do ivs[k], evs[k] = Rentals.IV, 0 end + local view = { gen = 3, national = def.species, level = Rentals.LEVEL, ivs = ivs, evs = evs, + personality = Rentals.PERSONALITY, nature = Rentals.PERSONALITY % 25, otId = 0, otSecretId = 0, + abilityNum = 0, item = 0, moves = {} } + for _, move in ipairs(def.moves) do view.moves[#view.moves + 1] = { move = move, ppUps = 0 } end + local record = Project.battleMon(view, data, { legacyPresent = opts.legacyPresent ~= false, rental = true }) + record.nickname = data.species[def.species].name + local disclosed = {} + for _, move in ipairs(def.moves) do disclosed[#disclosed + 1] = Project.moveRecord(data, move, 0) end + out.rentals[#out.rentals + 1] = { rental = true, index = index, type = def.type, national = def.species, + name = data.species[def.species].name, types = Project.copy(data.species[def.species].types), + level = Rentals.LEVEL, record = record, moves = disclosed, + stats = { hp = record.maxHp, atk = record.atk, def = record.def, spAtk = record.spAtk, + spDef = record.spDef, speed = record.speed } } + end + end + return out +end + +return Rentals diff --git a/src/online/xgen/TradeConvert.lua b/src/online/xgen/TradeConvert.lua new file mode 100644 index 00000000..3b8a584e --- /dev/null +++ b/src/online/xgen/TradeConvert.lua @@ -0,0 +1,634 @@ +local bit = require("bit") +local Identity = require("src.online.xgen.Identity") +local Datasets = require("src.online.xgen.Datasets") +local Policy = require("src.online.xgen.Policy") +local Project = require("src.online.xgen.Project") + +local TradeConvert = {} + +local copy = Project.copy + +local DERIVED = { + stats = true, maxHp = true, types = true, name = true, gender = true, shiny = true, isShiny = true, + nature = true, growthRate = true, speciesNumbering = true, attack = true, defense = true, speed = true, + spAtk = true, spDef = true, unownLetter = true, traded = true, maxPp = true, abilityId = true, + ability = true, speciesId = true, +} + +local function canonicalString(s) + return '"' .. s:gsub('[%c"\\]', function(c) + if c == '"' then return '\\"' end + if c == "\\" then return "\\\\" end + return string.format("\\u%04x", c:byte()) + end) .. '"' +end + +local function isArray(t) + local n = #t + if n == 0 then return next(t) == nil end + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count == n +end + +local function encode(v, out) + local t = type(v) + if t == "boolean" then + out[#out + 1] = v and "true" or "false" + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + out[#out + 1] = "null" + elseif v == math.floor(v) then + out[#out + 1] = string.format("%.0f", v) + else + out[#out + 1] = string.format("%.17g", v) + end + elseif t == "string" then + out[#out + 1] = canonicalString(v) + elseif t == "table" then + if isArray(v) then + out[#out + 1] = "[" + for i = 1, #v do + if i > 1 then out[#out + 1] = "," end + encode(v[i], out) + end + out[#out + 1] = "]" + else + local keys = {} + for k in pairs(v) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) + local ta, tb = type(a), type(b) + if ta ~= tb then return ta < tb end + return a < b + end) + out[#out + 1] = "{" + for i, k in ipairs(keys) do + if i > 1 then out[#out + 1] = "," end + out[#out + 1] = canonicalString(tostring(k)) + out[#out + 1] = ":" + encode(v[k], out) + end + out[#out + 1] = "}" + end + else + out[#out + 1] = "null" + end +end + +function TradeConvert.canonical(rec) + local out = {} + encode(rec, out) + return table.concat(out) +end + +function TradeConvert.digest(rec) + local Fingerprint = require("src.link.Fingerprint") + local text = TradeConvert.canonical(rec) + return ("%08x%08x"):format(Fingerprint.fnv1a32(text, 0x811C9DC5), Fingerprint.fnv1a32(text, 0x050C5D1F)) +end + +local charTokens = {} +local function tokensFor(generation) + if charTokens[generation] then return charTokens[generation] end + local chars = generation == 1 and require("src.save_convert.data.charmap").byToken + or require("src.save_convert.Gen2Layout").charmap + local tokens = {} + for key, value in pairs(chars) do + local glyph = generation == 1 and key or value + local code = generation == 1 and value or key + if type(glyph) == "string" and not glyph:find("[<>@{}]") and code ~= 0x50 then tokens[#tokens + 1] = glyph end + end + table.sort(tokens, function(a, b) + if #a ~= #b then return #a > #b end + return a < b + end) + charTokens[generation] = tokens + return tokens +end + +function TradeConvert.nameFits(text, generation, limit) + if type(text) ~= "string" or text == "" or text:find("%c") then return false end + if generation == 3 then + local codec = require("src.save_convert.Gen3Save").forVersion("emerald") + return codec.decodeString(codec.encodeString(text, limit, 0xFF), 0, limit) == text + end + local tokens = tokensFor(generation) + local at, count = 1, 0 + while at <= #text do + local found + for _, token in ipairs(tokens) do + if #token > 0 and text:sub(at, at + #token - 1) == token then found = token break end + end + if not found then return false end + at, count = at + #found, count + 1 + if count > limit then return false end + end + return true +end + +local function fnv(text) + return require("src.link.Fingerprint").fnv1a32(text, 0x811C9DC5) +end + +function TradeConvert.personalityFor(view, destSpecies) + local tid = math.floor(tonumber(view.otId) or 0) % 65536 + local ratio = destSpecies.genderRatio + local wantGender = Project.genderDv(ratio, view.dvs) + local shiny = Project.shinyDv(view.dvs) + local letter = view.national == 201 and Project.unownLetterDv(view.dvs) - 1 or nil + local seed = fnv(TradeConvert.canonical({ view.national, tid, view.dvs, view.exp or 0, view.level })) + local start = seed % 65536 + local function ok(pid) + if pid % 50 ~= 0 then return false end + if Project.gender3(ratio, pid) ~= wantGender then return false end + if letter and Project.unownLetter3(pid) ~= letter then return false end + return true + end + for i = 0, 65535 do + local high = (start + i) % 65536 + if shiny then + for k = 0, 7 do + local low = bit.band(bit.bxor(bit.bxor(tid, high), k), 0xFFFF) + local pid = high * 65536 + low + if ok(pid) then return pid end + end + else + local first = (-(high * 65536)) % 50 + for low = first, 65535, 50 do + local pid = high * 65536 + low + if bit.bxor(bit.bxor(tid, high), low) >= 8 and ok(pid) then return pid end + end + end + end + return nil +end + +function TradeConvert.dvsFor(view, destGen, destSpecies) + local iv = view.ivs + local target = { attack = Policy.dvFromIv(iv.atk), defense = Policy.dvFromIv(iv.def), + speed = Policy.dvFromIv(iv.spe), special = Policy.dvFromIv(iv.spa) } + local shiny = Project.shiny3(view.personality, view.otId, view.otSecretId) + local gender = destGen == 2 and Project.gender3(destSpecies.genderRatio, view.personality) or nil + local letter = view.national == 201 and Project.unownLetter3(view.personality) or nil + if letter and letter > 25 then return nil, target end + local best, score + for n = 0, 65535 do + local d = { attack = math.floor(n / 4096), defense = math.floor(n / 256) % 16, + speed = math.floor(n / 16) % 16, special = n % 16 } + if Project.shinyDv(d) == shiny and (not gender or Project.genderDv(destSpecies.genderRatio, d) == gender) + and (not letter or Project.unownLetterDv(d) - 1 == letter) then + local distance = math.abs(d.attack - target.attack) + math.abs(d.defense - target.defense) + + math.abs(d.speed - target.speed) + math.abs(d.special - target.special) + if score == nil or distance < score then + best, score = d, distance + if distance == 0 then break end + end + end + end + if best then best.hp = Project.hpDv(best) end + return best, target +end + +local function itemInfo(data, localItem) + if localItem == nil or localItem == 0 then return nil end + local row = data.items.byLocal[localItem] + if row == nil and data.generation == 3 then row = data.items.byLocal[tonumber(localItem)] end + return row +end + +local function gbStats(data, national, level, dvs, statExp) + local localKey = data.species[national].localKey + local def = data.raw.pokemon[localKey] + if data.generation == 1 then + return require("src.pokemon.Stats").calc(def, level, dvs, statExp) + end + return require("src.battle.gen2.Mon").stats(def.baseStats, dvs, level, statExp) +end + +local function gen3Gender(ratio, personality) + local g = Project.gender3(ratio, personality) + return g == "female" and "F" or g == "male" and "M" or "U" +end + +local function destLegalMoves(dst, national, level, exclude) + local out = {} + for move in pairs(Datasets.learnSources(dst, national, level)) do + if dst.moves[move] and not (exclude and exclude[move]) then out[#out + 1] = move end + end + table.sort(out, function(a, b) + local x, y = dst.moves[a].key or "", dst.moves[b].key or "" + if x ~= y then return x < y end + return a < b + end) + return out +end +TradeConvert.destLegalMoves = destLegalMoves + +function TradeConvert.convert(args) + local src, dst = args.source and args.source.data, args.target and args.target.data + local rec = args.mon + local adjustments = args.adjustments or {} + local report = { ok = false, blocks = {}, options = {}, changes = {}, accounting = {} } + local function block(code, field, detail) report.blocks[#report.blocks + 1] = { slot = args.slot, code = code, field = field, detail = detail } end + local function mark(field, fate, from, to) + if report.accounting[field] ~= nil then return end + report.accounting[field] = fate + if fate == "changed" then + report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), to = copy(to), kind = "change" } + elseif fate == "lost" then + report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), kind = "loss" } + end + end + local function change(field, from, to) + report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), to = copy(to), kind = "change" } + end + local function loss(field, from) + report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), kind = "loss" } + end + if type(src) ~= "table" or type(dst) ~= "table" then + block("missing_import", nil, { need = type(src) ~= "table" and "source" or "target" }) + return report + end + local versions = args.versions + if versions and versions.policy ~= nil and versions.policy ~= Policy.VERSION then + block("policy_mismatch", nil, { mine = Policy.VERSION, theirs = versions.policy }) + end + local view, code, detail = Project.read(rec, src) + if not view then + block(code, nil, detail) + return report + end + local g1, g2 = src.generation, dst.generation + local sameGen = g1 == g2 + local national, level = view.national, view.level + if view.isEgg then block("egg", "isEgg") end + local srcItem = g1 ~= 1 and itemInfo(src, view.item) or nil + if (g1 ~= 1 and view.item ~= nil and view.item ~= 0 and not srcItem) then block("item_unknown", "item", { item = view.item }) end + if rec.mail ~= nil and rec.mail ~= 255 and rec.mail ~= false or (srcItem and srcItem.mail) then + block("mail", "item", { item = srcItem and srcItem.key }) + end + local dsp = dst.species[national] + if not dsp then + block("species_missing", "species", { national = national, dexMax = dst.dexMax }) + return report + end + local ssp = src.species[national] + local out = {} + + local destLocal = dsp.localKey + out.species = destLocal + mark("species", "carried") + if g2 == 3 then mark("speciesId", "derived") end + mark("level", "carried") + out.level = level + + local destExpLo = Datasets.expAt(dst, national, level) + local destExpHi = level < 100 and (Datasets.expAt(dst, national, level + 1) - 1) or destExpLo + local exp = view.exp and math.floor(view.exp) or destExpLo + local clamped = math.max(destExpLo, math.min(destExpHi, exp)) + local expField = rec.exp ~= nil and "exp" or rec.experience ~= nil and "experience" or "exp" + if view.exp == nil then + mark(expField, "derived") + elseif clamped ~= view.exp then + mark(expField, "changed", view.exp, clamped) + else + mark(expField, "carried") + end + if rec.exp ~= nil and rec.experience ~= nil then mark("experience", report.accounting.exp) end + if g2 == 2 then out.experience = clamped else out.exp = clamped end + + local srcDefault = ssp.key + local nickname = view.nickname + if nickname and Identity.normalize(nickname) == srcDefault then nickname = nil end + if nickname then + if not TradeConvert.nameFits(nickname, g2, Policy.NAME_LIMIT.nickname) then + block("nickname_unencodable", "nickname", { text = nickname }) + end + out.nickname = nickname + mark("nickname", "carried") + else + if g2 == 3 then out.nickname = dsp.name end + if view.nickname and view.nickname ~= (g2 == 3 and dsp.name or nil) then + mark("nickname", "changed", view.nickname, out.nickname) + else + mark("nickname", "carried") + end + end + + local ot = view.otName + if type(ot) ~= "string" or not TradeConvert.nameFits(ot, g2, Policy.NAME_LIMIT.ot) then + block("ot_unencodable", "otName", { text = ot }) + end + if type(view.otId) ~= "number" or view.otId % 1 ~= 0 or view.otId < 0 or view.otId > 65535 then + block("ot_invalid", "otId", { id = view.otId }) + end + if g2 == 3 then out.otName = ot else out.ot = ot end + out.otId = view.otId + mark("ot", "carried") + mark("otName", "carried") + mark("otId", "carried") + + if g2 == 3 then + if g1 == 3 then + out.otSecretId = view.otSecretId or 0 + mark("otSecretId", "carried") + else + out.otSecretId = 0 + change("otSecretId", nil, 0) + end + elseif g1 == 3 then + mark("otSecretId", "lost", rec.otSecretId) + end + + local legalFlag = {} + local destMoves = {} + local adjustMoves = adjustments.moves or {} + local listed = {} + for _, m in ipairs(view.moves) do listed[m.move] = true end + local legalDest = Datasets.learnSources(dst, national, level) + local moveOptions = nil + for j, m in ipairs(view.moves) do + local adj = adjustMoves[j] + if adj ~= nil then + if adj == 0 or adj == false then + change("moves", m.move, 0) + elseif dst.moves[adj] and legalDest[adj] and not legalFlag[adj] then + destMoves[#destMoves + 1] = { move = adj, ppUps = 0 } + legalFlag[adj] = true + change("moves", m.move, adj) + else + block("replacement_not_legal", "moves", { index = j, move = adj }) + end + else + local exists = dst.moves[m.move] ~= nil + local okDest = exists and legalDest[m.move] ~= nil + local okSrc = exists and Datasets.learnable(src, national, m.move, level) + if not exists then + block("move_missing", "moves", { index = j, move = m.move, moveMax = dst.moveMax }) + moveOptions = moveOptions or {} + moveOptions[j] = destLegalMoves(dst, national, level, listed) + elseif not (okDest or okSrc) then + block("move_not_legal", "moves", { index = j, move = m.move }) + moveOptions = moveOptions or {} + moveOptions[j] = destLegalMoves(dst, national, level, listed) + elseif not legalFlag[m.move] then + destMoves[#destMoves + 1] = { move = m.move, ppUps = m.ppUps, pp = m.pp } + legalFlag[m.move] = true + end + end + end + if moveOptions then report.options.moves = moveOptions end + if #destMoves == 0 and #report.blocks == 0 then block("no_moves", "moves") end + local ppChanged = false + local outMoves = {} + for _, m in ipairs(destMoves) do + local base = dst.moves[m.move].pp + local max = Policy.maxPp(base, m.ppUps) + local srcBase = src.moves[m.move] and src.moves[m.move].pp + if srcBase ~= base or (m.pp ~= nil and m.pp ~= max) then ppChanged = true end + local localKey = dst.moves[m.move].localKey + if g2 == 3 then + outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps } + elseif g2 == 2 then + outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps, maxPp = max } + else + outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps } + end + end + out.moves = outMoves + mark("moves", "carried") + if g1 == 3 then + for _, key in ipairs({ "pp", "ppBonusesPacked", "ppBonuses" }) do mark(key, ppChanged and "changed" or "carried", rec[key], nil) end + end + if ppChanged then change("pp", nil, "full") end + + if g1 == 3 and g2 == 3 then + out.ivs, out.evs = copy(view.ivs), copy(view.evs) + mark("ivs", "carried") + mark("evs", "carried") + out.personality = view.personality + mark("personality", "carried") + out.abilityNum = view.abilityNum + mark("abilityNum", "carried") + local pair = dsp.abilities or {} + out.ability = pair[view.abilityNum + 1] ~= 0 and pair[view.abilityNum + 1] or pair[1] + elseif g1 < 3 and g2 < 3 then + out.dvs = copy(view.dvs) + out.statExp = copy(view.statExp) + mark("dvs", "carried") + mark("statExp", "carried") + elseif g1 < 3 and g2 == 3 then + local ivs = Project.ivs(view) + local evs = Project.evs(view) + out.ivs, out.evs = ivs, evs + mark("dvs", "changed", view.dvs, ivs) + mark("statExp", "changed", view.statExp, evs) + local pid = TradeConvert.personalityFor(view, dsp) + if not pid then + block("personality_unrepresentable", "personality") + else + out.personality = pid + change("personality", nil, pid) + end + out.abilityNum = Policy.ABILITY_SLOT + out.ability = (dsp.abilities or {})[1] or 0 + change("abilityNum", nil, Policy.ABILITY_SLOT) + else + local dvs, target = TradeConvert.dvsFor(view, g2, dsp) + if not dvs then + block("traits_unrepresentable", "personality", { national = national }) + dvs = { attack = target.attack, defense = target.defense, speed = target.speed, special = target.special } + dvs.hp = Project.hpDv(dvs) + end + out.dvs = dvs + mark("ivs", "changed", view.ivs, dvs) + local statExp = { hp = Policy.statExpFromEv(view.evs.hp), attack = Policy.statExpFromEv(view.evs.atk), + defense = Policy.statExpFromEv(view.evs.def), speed = Policy.statExpFromEv(view.evs.spe), + special = Policy.statExpFromEv(view.evs.spa) } + out.statExp = statExp + mark("evs", "changed", view.evs, statExp) + mark("personality", "lost", view.personality) + mark("abilityNum", "lost", view.abilityNum) + loss("nature", view.nature) + loss("ability", view.ability) + if view.evs.spd ~= view.evs.spa then loss("evs.spd", view.evs.spd) end + if view.ivs.hp ~= nil then loss("ivs.hp", view.ivs.hp) end + end + + local traitsBefore = Project.traits(view, src) + if g2 == 3 and out.personality then + out.nature = out.personality % 25 + out.gender = gen3Gender(dsp.genderRatio, out.personality) + end + + local item, catchRate + if g1 == 1 then + if g2 == 2 then + item = require("src.online.Convert").heldItemFromCatchRate(view.catchRate or ssp.catchRate, dst.raw) + if item then change("item", view.catchRate, item) end + mark("catchRate", item and "changed" or "lost", view.catchRate, item) + elseif g2 == 1 then + catchRate = view.catchRate or dsp.catchRate + mark("catchRate", "carried") + else + mark("catchRate", "lost", view.catchRate) + end + elseif srcItem then + if g2 == 1 then + if g1 == 2 then + catchRate = tonumber(srcItem.index) or 0 + mark("item", "changed", srcItem.key, catchRate) + else + block("item_unrepresentable", "item", { item = srcItem.key }) + end + else + local mapped = Identity.localItem(dst, srcItem.key) + if mapped == nil then + block("item_unrepresentable", "item", { item = srcItem.key }) + else + item = mapped + end + mark("item", "carried") + mark("heldItem", "carried") + end + else + mark("item", "carried") + mark("heldItem", "carried") + if g2 == 1 then catchRate = g1 == 2 and 0 or dsp.catchRate end + end + if g2 == 2 then out.item = item elseif g2 == 3 then out.item = item or 0 end + if g2 == 1 then out.catchRate = catchRate end + + local friendship = view.friendship + local friendField = g1 == 3 and "friendship" or "happiness" + if g2 == 1 then + if friendship ~= nil and g1 ~= 1 then mark(friendField, "lost", friendship) end + elseif g1 == 1 then + out[g2 == 2 and "happiness" or "friendship"] = g2 == 2 and Policy.GEN2_BASE_HAPPINESS or dsp.friendship + change(g2 == 2 and "happiness" or "friendship", nil, out[g2 == 2 and "happiness" or "friendship"]) + else + local value = friendship + if value == nil then value = g2 == 2 and Policy.GEN2_BASE_HAPPINESS or dsp.friendship end + out[g2 == 2 and "happiness" or "friendship"] = value + mark(friendField, "carried") + end + if g1 == 3 then mark("happiness", report.accounting.friendship or "carried") end + + if g2 == 1 then + if view.pokerus ~= 0 then mark("pokerus", "lost", view.pokerus) else mark("pokerus", "carried") end + else + out.pokerus = view.pokerus + mark("pokerus", "carried") + end + + if g2 == 2 then + if g1 == 2 then + out.caughtLevel, out.caughtTime, out.caughtLocation = rec.caughtLevel, rec.caughtTime, rec.caughtLocation + out.caughtByGender = rec.caughtByGender or rec.caughtGender + for _, key in ipairs({ "caughtLevel", "caughtTime", "caughtLocation", "caughtByGender", "caughtGender" }) do mark(key, "carried") end + else + out.caughtLevel, out.caughtTime, out.caughtLocation, out.caughtByGender = 0, 0, 0, 0 + change("caughtData", nil, 0) + end + elseif g1 == 2 then + for _, key in ipairs({ "caughtLevel", "caughtTime", "caughtLocation", "caughtByGender", "caughtGender" }) do + if rec[key] ~= nil and rec[key] ~= 0 then mark(key, "lost", rec[key]) else mark(key, "carried") end + end + end + + local GEN3_KEEP = { "otGender", "language", "metLocation", "metLevel", "metGame", "pokeball", "markings", + "fatefulEncounter", "modernFatefulEncounter" } + if g2 == 3 then + if g1 == 3 then + for _, key in ipairs(GEN3_KEEP) do out[key] = copy(rec[key]); mark(key, "carried") end + out.contest = copy(rec.contest) + mark("contest", "carried") + out.ribbons = require("src.core.game3.rse.ribbons").word(rec) + mark("ribbons", "carried") + mark("championRibbon", "carried") + out.otGender = out.otGender or 0 + out.language = out.language or Policy.GBA_LANGUAGE_ENGLISH + else + out.otGender = (g1 == 2 and (rec.caughtByGender or rec.caughtGender)) or 0 + out.language = Policy.GBA_LANGUAGE_ENGLISH + out.metLocation = Policy.METLOC_IN_GAME_TRADE + out.metLevel = level + out.metGame = Policy.GBA_VERSION_ID[dst.version] or 0 + out.pokeball = Policy.ITEM_POKE_BALL + out.markings = 0 + out.ribbons = national == 151 and 2147483648 or 0 + out.contest = { cool = 0, beauty = 0, cute = 0, smart = 0, tough = 0, sheen = 0 } + out.fatefulEncounter = false + -- src/battle_util.c:3898 + out.modernFatefulEncounter = national == 151 + change("origin", nil, { metLocation = out.metLocation, metLevel = level, metGame = out.metGame, + pokeball = out.pokeball, language = out.language, otGender = out.otGender }) + if out.modernFatefulEncounter then change("modernFatefulEncounter", nil, true) end + end + out.isEgg = false + out.eggCycles = 0 + elseif g1 == 3 then + for _, key in ipairs(GEN3_KEEP) do + if rec[key] ~= nil then mark(key, "lost", rec[key]) end + end + if rec.contest ~= nil then mark("contest", "lost", rec.contest) end + local word = require("src.core.game3.rse.ribbons").word(rec) + if word ~= 0 then mark("ribbons", "lost", word) else mark("ribbons", "carried") end + mark("championRibbon", word ~= 0 and "lost" or "carried", rec.championRibbon) + end + + for _, key in ipairs({ "isEgg", "eggSteps", "eggCycles", "isBadEgg", "mail" }) do mark(key, "carried") end + + local hpWas, statusWas = rec.hp, rec.status + if g2 == 3 then + local stats = Project.stats3(dsp.base, level, out.ivs, out.evs, out.nature or 0, national) + out.hp = stats.hp + out.status = "" + else + local stats = gbStats(dst, national, level, out.dvs, out.statExp) + out.stats = stats + out.hp = stats.hp + out.maxHp = stats.hp + out.status = nil + end + if hpWas ~= nil and hpWas ~= out.hp then mark("hp", "changed", hpWas, out.hp) else mark("hp", "derived") end + if statusWas ~= nil and statusWas ~= "" and statusWas ~= 0 then mark("status", "changed", statusWas, nil) else mark("status", "carried") end + if rec.sleepTurns ~= nil then mark("sleepTurns", "changed", rec.sleepTurns, nil) end + if rec.sleep ~= nil then mark("sleep", "changed", rec.sleep, nil) end + + if g2 == 2 then + out.gender = Project.genderDv(dsp.genderRatio, out.dvs) + out.shiny = Project.shinyDv(out.dvs) + if national == 201 then out.unownLetter = Project.unownLetterDv(out.dvs) end + end + local destView = { gen = g2, national = national, dvs = out.dvs, personality = out.personality, + otId = out.otId, otSecretId = out.otSecretId } + if g2 ~= 3 or out.personality then + local after = Project.traits(destView, dst) + if after.shiny ~= traitsBefore.shiny then change("shiny", traitsBefore.shiny, after.shiny) end + if traitsBefore.gender and after.gender and after.gender ~= traitsBefore.gender then change("gender", traitsBefore.gender, after.gender) end + if traitsBefore.gender and not after.gender then loss("gender", traitsBefore.gender) end + if traitsBefore.unownLetter ~= after.unownLetter then change("unownLetter", traitsBefore.unownLetter, after.unownLetter) end + end + + if not sameGen then + if rec.extra ~= nil then mark("extra", "lost", rec.extra) end + else + out.extra = copy(rec.extra) + mark("extra", "carried") + end + + for key in pairs(rec) do + if report.accounting[key] == nil then + if DERIVED[key] then mark(key, "derived") else mark(key, "lost", rec[key]) end + end + end + + report.result = out + report.canonical = TradeConvert.canonical(out) + report.ok = #report.blocks == 0 + if not report.ok then report.result = nil; report.canonical = nil; report.preview = out end + return report +end + +return TradeConvert diff --git a/src/render/Assets.lua b/src/render/Assets.lua index 3469d0f1..0b569120 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -54,11 +54,19 @@ function Assets.resolve(path) return path end +local composed = {} + +function Assets.compose(path, build) + composed[path] = build + cache[path] = nil +end + function Assets.image(path) - local resolved = Assets.resolve(path) + local build = composed[path] + local resolved = build and path or Assets.resolve(path) local image = cache[resolved] if not image then - image = love.graphics.newImage(resolved) + image = love.graphics.newImage(build and build() or resolved) cache[resolved] = image end return image @@ -67,6 +75,8 @@ end -- pixel-level reads (tile-shift variants, the spinner strip blit) resolve -- the same way but stay uncached: the caller keeps the derived product function Assets.imageData(path) + local build = composed[path] + if build then return build() end return love.image.newImageData(Assets.resolve(path)) end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 7354bddd..d6cdbe10 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -793,7 +793,7 @@ end -- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, -- with the current map's tile-id exceptions (if any) applied first -function PaletteFX.worldGroupAt(tileset, mapId, tileId) +function PaletteFX.worldGroupAt(tileset, mapId, tileId, tileSources) local w = PaletteFX.worldPack() local groups = w and w.tileGroups[tileset] if not groups then return nil end @@ -801,6 +801,11 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId) if exc and exc.tiles[tileId] then return exc.group end exc = TILESET_GROUP_EXCEPTIONS[tileset] if exc and exc.tiles[tileId] then return exc.group end + local src = groups[tileId] == nil and tileSources and tileSources[tileId] + if src then + local from = w.tileGroups[src.tileset] + if from and from[src.tile] then return from[src.tile] end + end return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus) end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 171a5a02..fdeb48de 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -427,6 +427,8 @@ end local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data, bgp) local key = imagePath .. gbcKeyFor(mapId) + local tileSources = data and data.tilesets and data.tilesets[tilesetId] + tileSources = tileSources and tileSources.tileSources if not bgp and gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end local img = false if love.image and love.image.newImageData then @@ -440,7 +442,7 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data, bgp) for t = 0, total - 1 do local colors = tileColors[t] if colors == nil then - local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) + local group = PaletteFX.worldGroupAt(tilesetId, mapId, t, tileSources) colors = (group and groupColors[group + 1]) or false tileColors[t] = colors end @@ -504,6 +506,7 @@ function TileRenderer.new(map, data) -- context rather than re-deriving it per draw. gbcCtx.imagePath = map.tileset.image gbcCtx.perRow = map.tileset.tilesPerRow + gbcCtx.tileSources = map.tileset.tileSources self.gbcCtx = gbcCtx self.gbcAtlasKey = map.tileset.image .. gbcCtx.key self.gbcKeyed = {} @@ -711,7 +714,7 @@ local function getKeyedTile(self, tile) if cached ~= nil then return cached or nil end local img = false if ctx.groupColors and love.image and love.image.newImageData then - local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile) + local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile, ctx.tileSources) local colors = group and ctx.groupColors[group + 1] local src = Assets.imageData(ctx.imagePath) local ox = (tile % ctx.perRow) * 8 diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index e29940d9..85357ac9 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -111,10 +111,11 @@ local DATA_MODULES = { hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" }, tradeFlags = { "src.save_convert.data.trade_flags", "src/save_convert/data/trade_flags.lua" }, field = { "data.generated.field", "data/generated/field.lua" }, + text_pointers = { "data.generated.text_pointers", "data/generated/text_pointers.lua" }, trainerHeaders = { "data.generated.trainer_headers", "data/generated/trainer_headers.lua" }, } -local OPTIONAL_MODULES = { tilesets = true, audio = true, field = true, trainerHeaders = true } +local OPTIONAL_MODULES = { tilesets = true, audio = true, field = true, trainerHeaders = true, text_pointers = true } -- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm -- inserts events pokered does not have (the Jessie & James fights, catch @@ -619,6 +620,54 @@ end -- GenSave reproduces every unmodeled region from it; otherwise those regions -- are zero-filled. gameVersion selects the crosswalk tables exactly as in -- importSav. On failure returns nil + a message (never raises). +local function gen2Sealed(saveTable, gameVersion, g2data) + local Safety = require("src.world.gen2.UnionSafety") + local view = { gen2Maps = g2data.maps } + if not Safety.isAdded(view, saveTable.position) then return saveTable end + for key, name in pairs({ gen2Tilesets = "tilesets", gen2Landmarks = "landmarks" }) do + g2data[name] = g2data[name] or loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua") + if not g2data[name] then return nil, gen2CacheMissing(gameVersion) end + view[key] = g2data[name] + end + local out = {} + for k, v in pairs(saveTable) do out[k] = v end + Safety.seal(out, view) + return out +end + +local function gen1Sealed(saveTable, data, gameVersion) + local UnionSafety = require("src.world.gen1.UnionSafety") + if not data.text_pointers then + local p = type(saveTable.player) == "table" and saveTable.player or {} + local def = data.maps and data.maps[p.map] + local desk = UnionSafety.ADDED[p.map] + for _, o in ipairs(type(def) == "table" and def.objects or {}) do + if o.sprite == "SPRITE_LINK_RECEPTIONIST" then desk = true end + end + if desk then return nil, gen1CacheMissing(gameVersion, DATA_MODULES.text_pointers[2]) end + end + return UnionSafety.forWrite(saveTable, data) +end + +local function gen3Sealed(saveTable, gameVersion) + local Spot = require("src.core.game3.link.union_save_spot") + local codec = Gen3Save.forVersion(gameVersion) + local lookup = Spot.cacheLookup(function(rel) return gen3CacheBytes(gameVersion, rel) end, + function(mapId) + local w = codec.cartWarp({ map = mapId }) + return w and (w.group .. "_" .. w.num) or nil + end, codec.mapFor) + if not Spot.isAdded(gameVersion, lookup, saveTable.map, saveTable.x, saveTable.y) then return saveTable end + local o = Spot.resolve(gameVersion, lookup, saveTable) + if not o then return nil, codec.MSG.noMap end + local out = {} + for k, v in pairs(saveTable) do out[k] = v end + out.map, out.x, out.y, out.facing = o.map, o.x, o.y, o.facing + local bit = require("bit") + out.specialSaveWarpFlags = bit.band(tonumber(saveTable.specialSaveWarpFlags) or 0, bit.bnot(codec.L.CONTINUE_GAME_WARP)) + return out +end + local function exportRaw(saveTable, gameVersion, cartImage) if type(saveTable) ~= "table" then return nil, "expected a save table" @@ -626,7 +675,9 @@ local function exportRaw(saveTable, gameVersion, cartImage) local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion) if not supported then return nil, unsupportedWhy end if isGen3(gameVersion) then - local ok, bytes, err = pcall(Gen3Save.forVersion(gameVersion).exportPort, saveTable, gen3ExportOpts(gameVersion, cartImage)) + local sealed, serr = gen3Sealed(saveTable, gameVersion) + if not sealed then return nil, serr end + local ok, bytes, err = pcall(Gen3Save.forVersion(gameVersion).exportPort, sealed, gen3ExportOpts(gameVersion, cartImage)) if not ok then return nil, "encode failed: " .. tostring(bytes) end return bytes, err end @@ -634,12 +685,17 @@ local function exportRaw(saveTable, gameVersion, cartImage) if Gen2Save.layoutFor(gameVersion) then local g2data = ensureGen2Data(gameVersion) if not g2data then return nil, gen2CacheMissing(gameVersion) end - local ok, bytes, err = pcall(Gen2Save.encode, saveTable, gameVersion, cartImage, g2data) + local sealed, serr = gen2Sealed(saveTable, gameVersion, g2data) + if not sealed then return nil, serr end + local ok, bytes, err = pcall(Gen2Save.encode, sealed, gameVersion, cartImage, g2data) if not ok then return nil, "encode failed: " .. tostring(bytes) end return bytes, err end local data, derr = ensureData(gameVersion) if not data then return nil, derr end + local serr + saveTable, serr = gen1Sealed(saveTable, data, gameVersion) + if not saveTable then return nil, serr end local ok, bytes = pcall(GenSave.encode, saveTable, data, saveTable.rawImport or cartImage) if not ok then return nil, "encode failed: " .. tostring(bytes) end return bytes diff --git a/src/ui/g3u/Gen1Screen.lua b/src/ui/g3u/Gen1Screen.lua new file mode 100644 index 00000000..2fbd8e77 --- /dev/null +++ b/src/ui/g3u/Gen1Screen.lua @@ -0,0 +1,1163 @@ +local RomText = require("src.core.RomText") +local Strings = require("src.core.Strings") +local Events = require("src.battle.g3u.Events") + +local Gen1Screen = {} + +local MAPS = setmetatable({}, { __mode = "k" }) + +function Gen1Screen.maps(data) + local m = MAPS[data] + if m then return m end + m = { species = {}, moves = {} } + for key, def in pairs(data and data.pokemon or {}) do + local n = type(def) == "table" and tonumber(def.dex) or nil + if n and n >= 1 and (m.species[n] == nil or tostring(key) < tostring(m.species[n])) then + m.species[n] = key + end + end + for key, def in pairs(data and data.moves or {}) do + local n = type(def) == "table" and tonumber(def.index) or nil + if n and n >= 1 then m.moves[n] = key end + end + MAPS[data] = m + return m +end + +function Gen1Screen.speciesKey(data, national) + return Gen1Screen.maps(data).species[tonumber(national) or -1] +end + +function Gen1Screen.moveKey(data, id) + return Gen1Screen.maps(data).moves[tonumber(id) or -1] +end + +local function speciesName(data, key) + local def = key and data.pokemon and data.pokemon[key] + return def and def.name or nil +end + +local function moveName(data, key) + local def = key and data.moves and data.moves[key] + return def and def.name or (key and tostring(key)) or nil +end + +local function plain(s) + return (tostring(s or ""):upper():gsub("[^%w]", "")) +end + +function Gen1Screen.monFromRecord(rec, data) + if type(rec) ~= "table" then return nil, "record" end + local key = Gen1Screen.speciesKey(data, rec.species) + if not key then return nil, "species" end + local moves = {} + for i, mv in ipairs(rec.moves or {}) do + moves[i] = { id = Gen1Screen.moveKey(data, mv.id) or tostring(mv.id), pp = tonumber(mv.pp) or 0, + ppUps = tonumber(mv.ppUps) or 0 } + end + local nick = rec.nickname + if type(nick) ~= "string" or nick == "" or plain(nick) == plain(speciesName(data, key)) then nick = nil end + return { + species = key, national = rec.species, level = rec.level, hp = rec.hp, + stats = { hp = rec.maxHp, attack = rec.atk, defense = rec.def, speed = rec.speed, special = rec.spAtk }, + moves = moves, nickname = nick, status = nil, + dvs = { attack = 0, defense = 0, speed = 0, special = 0, hp = 0 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + exp = 0, + } +end + +function Gen1Screen.monName(data, mon) + if not mon then return "?" end + return mon.nickname or speciesName(data, mon.species) or tostring(mon.species) +end + +function Gen1Screen.newCtx(data, seat, parties, names) + local ctx = { data = data, seat = seat, peer = 1 - seat, mons = { [0] = {}, [1] = {} }, + active = { [0] = 1, [1] = 1 }, fainted = { [0] = {}, [1] = {} }, + names = { me = names and names.me or "?", foe = names and names.foe or "?" } } + for s = 0, 1 do + for i, rec in ipairs(parties and parties[s] or {}) do + local mon, why = Gen1Screen.monFromRecord(rec, data) + if not mon then return nil, why end + ctx.mons[s][i] = mon + end + end + return ctx +end + +local function refSeat(ref) + if type(ref) == "table" then return tonumber(ref.side) end + if type(ref) == "string" then return Events.seatOf(ref) end + return nil +end + +local function refMon(ctx, ref) + local seat = refSeat(ref) + if seat == nil or not ctx.mons[seat] then return nil end + local idx = type(ref) == "table" and tonumber(ref.index) or nil + return ctx.mons[seat][idx or ctx.active[seat]], seat +end + +function Gen1Screen.who(ctx, ref) + local mon, seat = refMon(ctx, ref) + if not mon then return nil end + local name = Gen1Screen.monName(ctx.data, mon) + if seat ~= ctx.seat then return Strings("Enemy %s", name) end + return name +end + +local function rawName(ctx, ref) + local mon = refMon(ctx, ref) + return mon and Gen1Screen.monName(ctx.data, mon) or nil +end + +local function moveOf(ctx, v) + local id = type(v) == "table" and tonumber(v.move) or tonumber(v) + if not id then return nil end + return moveName(ctx.data, Gen1Screen.moveKey(ctx.data, id)) +end + +local function speciesOf(ctx, v) + local n = type(v) == "table" and tonumber(v.species) or tonumber(v) + return speciesName(ctx.data, Gen1Screen.speciesKey(ctx.data, n)) +end + +local STAT_LABEL = { + attack = "ATTACK", atk = "ATTACK", defense = "DEFENSE", def = "DEFENSE", speed = "SPEED", spe = "SPEED", + spAtk = "SPECIAL", spDef = "SPECIAL", spatk = "SPECIAL", spdef = "SPECIAL", spa = "SPECIAL", spd = "SPECIAL", + special = "SPECIAL", accuracy = "ACCURACY", acc = "ACCURACY", evasion = "EVADE", eva = "EVADE", +} + +local CHARGE = { + STRINGID_PKMNFLEWHIGH = { "_FlewUpHighText", "%s\nflew up high!" }, + STRINGID_PKMNDUGHOLE = { "_DugAHoleText", "%s\ndug a hole!" }, + STRINGID_PKMNWHIPPEDWHIRLWIND = { "_MadeWhirlwindText", "%s\nmade a whirlwind!" }, + STRINGID_PKMNTOOKSUNLIGHT = { "_TookInSunlightText", "%s\ntook in sunlight!" }, + STRINGID_PKMNLOWEREDHEAD = { "_LoweredItsHeadText", "%s\nlowered its head!" }, + STRINGID_PKMNISGLOWING = { "_SkyAttackGlowingText", "%s\nis glowing!" }, +} + +local function rom(ctx, label, fallback, ...) + for i = 1, select("#", ...) do + if select(i, ...) == nil then return nil end + end + return RomText(ctx.data, label, fallback, ...) +end + +local function first(f, ...) + for _, k in ipairs({ ... }) do + if f[k] ~= nil then return f[k] end + end + return nil +end + +local function statText(ctx, ref, f) + local name = Gen1Screen.who(ctx, ref) + local label = STAT_LABEL[f.stat or ""] or STAT_LABEL[tostring(f.stat or ""):lower()] + local delta = tonumber(f.delta) + if not name or not label or not delta or delta == 0 then return nil end + label = Strings(label) + if delta >= 2 then return Strings("%s's\n%s\ngreatly rose!", name, label) end + if delta == 1 then return Strings("%s's\n%s rose!", name, label) end + if delta == -1 then return Strings("%s's\n%s fell!", name, label) end + return Strings("%s's\n%s\ngreatly fell!", name, label) +end + +local function failed(ctx) return rom(ctx, "_ButItFailedText", "But, it failed!") end +local function nothing(ctx) return rom(ctx, "_NothingHappenedText", "Nothing happened!") end +local function didntAffect(ctx, f) return rom(ctx, "_DidntAffectText", "It didn't affect\n%s!", Gen1Screen.who(ctx, f.def)) end + +local function faintedText(ctx, ref) + local _, seat = refMon(ctx, ref) + local name = rawName(ctx, ref) + if not name then return nil end + if seat == ctx.seat then return rom(ctx, "_PlayerMonFaintedText", "%s\nfainted!", name) end + return rom(ctx, "_EnemyMonFaintedText", "Enemy %s\nfainted!", name) +end + +local TEXT = { + STRINGID_CRITICALHIT = function(c) return rom(c, "_CriticalHitText", "Critical hit!") end, + STRINGID_ONEHITKO = function(c) return rom(c, "_OHKOText", "One-hit KO!") end, + STRINGID_SUPEREFFECTIVE = function(c) return rom(c, "_SuperEffectiveText", "It's super\neffective!") end, + STRINGID_NOTVERYEFFECTIVE = function(c) return rom(c, "_NotVeryEffectiveText", "It's not very\neffective...") end, + STRINGID_ITDOESNTAFFECT = function(c, f) + return rom(c, "_DoesntAffectMonText", "It doesn't affect\n%s!", Gen1Screen.who(c, f.def)) + end, + STRINGID_ATTACKMISSED = function(c, f) + return rom(c, "_AttackMissedText", "%s's\nattack missed!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNPROTECTEDBYMIST = function(c, f) + local user = f.atk or (c.lastUser ~= nil and { side = c.lastUser }) or nil + return rom(c, "_AttackMissedText", "%s's\nattack missed!", Gen1Screen.who(c, user)) + end, + STRINGID_PKMNAVOIDEDATTACK = function(c, f) + return rom(c, "_EvadedAttackText", "%s\nevaded attack!", Gen1Screen.who(c, f.def)) + end, + STRINGID_BUTITFAILED = failed, + STRINGID_MIRRORMOVEFAILED = failed, + STRINGID_PKMNHPFULL = failed, + STRINGID_PKMNALREADYCONFUSED = failed, + STRINGID_BUTNOEFFECT = nothing, + STRINGID_BUTNOTHINGHAPPENED = nothing, + STRINGID_STATSWONTINCREASE = nothing, + STRINGID_STATSWONTINCREASE2 = nothing, + STRINGID_STATSWONTDECREASE = nothing, + STRINGID_STATSWONTDECREASE2 = nothing, + STRINGID_PKMNWASNTAFFECTED = didntAffect, + STRINGID_PKMNALREADYPOISONED = didntAffect, + STRINGID_PKMNISALREADYPARALYZED = didntAffect, + STRINGID_PKMNUNAFFECTED = function(c, f) + return rom(c, "_UnaffectedText", "%s's\nunaffected!", Gen1Screen.who(c, f.def)) + end, + STRINGID_HITXTIMES = function(c, f) + local n = tonumber(f.buff1) + if not n then return nil end + if c.lastUser == c.seat then return rom(c, "_MultiHitText", "Hit the enemy\n%d times!", n) end + return rom(c, "_HitXTimesText", "Hit %d times!", n) + end, + STRINGID_TARGETFAINTED = function(c, f) return faintedText(c, f.def) end, + STRINGID_ATTACKERFAINTED = function(c, f) return faintedText(c, f.atk) end, + STRINGID_PKMNHURTBYPOISON = function(c, f) + return rom(c, "_HurtByPoisonText", "%s's\nhurt by poison!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNHURTBYBURN = function(c, f) + return rom(c, "_HurtByBurnText", "%s's\nhurt by the burn!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNSAPPEDBYLEECHSEED = function(c, f) + return rom(c, "_HurtByLeechSeedText", "LEECH SEED saps\n%s!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNWOKEUP = function(c, f) return rom(c, "_WokeUpText", "%s\nwoke up!", Gen1Screen.who(c, f.atk)) end, + STRINGID_PKMNISFROZEN = function(c, f) + return rom(c, "_IsFrozenText", "%s\nis frozen solid!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNISPARALYZED = function(c, f) + return rom(c, "_FullyParalyzedText", "%s's\nfully paralyzed!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNHEALEDCONFUSION = function(c, f) + return rom(c, "_ConfusedNoMoreText", "%s's\nconfused no more!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_ITHURTCONFUSION = function(c) return rom(c, "_HurtItselfText", "It hurt itself in\nits confusion!") end, + STRINGID_PKMNFLINCHED = function(c, f) return rom(c, "_FlinchedText", "%s\nflinched!", Gen1Screen.who(c, f.atk)) end, + STRINGID_PKMNMUSTRECHARGE = function(c, f) + return rom(c, "_MustRechargeText", "%s\nmust recharge!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNFATIGUECONFUSION = function(c, f) + return rom(c, "_BecameConfusedText", "%s\nbecame confused!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNUNLEASHEDENERGY = function(c, f) + return rom(c, "_UnleashedEnergyText", "%s\nunleashed energy!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNSTORINGENERGY = function(c, f) + local name = Gen1Screen.who(c, f.atk) + return name and Strings("%s\nis storing energy!", name) or nil + end, + STRINGID_PKMNRAGEBUILDING = function(c, f) + return rom(c, "_BuildingRageText", "%s's\nRAGE is building!", Gen1Screen.who(c, first(f, "def", "atk"))) + end, + STRINGID_PKMNHITWITHRECOIL = function(c, f) + return rom(c, "_HitWithRecoilText", "%s's\nhit with recoil!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNCRASHED = function(c, f) + return rom(c, "_KeptGoingAndCrashedText", "%s\nkept going and\ncrashed!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNENERGYDRAINED = function(c, f) + return rom(c, "_SuckedHealthText", "Sucked health from\n%s!", Gen1Screen.who(c, f.def)) + end, + STRINGID_PKMNDREAMEATEN = function(c, f) + return rom(c, "_DreamWasEatenText", "%s's\ndream was eaten!", Gen1Screen.who(c, f.def)) + end, + STRINGID_PKMNREGAINEDHEALTH = function(c, f) + return rom(c, "_RegainedHealthText", "%s\nregained health!", Gen1Screen.who(c, first(f, "def", "atk"))) + end, + STRINGID_PKMNWENTTOSLEEP = function(c, f) + return rom(c, "_StartedSleepingEffect", "%s\nstarted sleeping!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNSLEPTHEALTHY = function(c, f) + return rom(c, "_FellAsleepBecameHealthyText", "%s\nfell asleep and\nbecame healthy!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNFELLASLEEP = function(c, f) + return rom(c, "_FellAsleepText", "%s\nfell asleep!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNALREADYASLEEP = function(c, f) + return rom(c, "_AlreadyAsleepText", "%s's\nalready asleep!", Gen1Screen.who(c, f.def)) + end, + STRINGID_PKMNWASPOISONED = function(c, f) + return rom(c, "_PoisonedText", "%s\nwas poisoned!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNBADLYPOISONED = function(c, f) + return rom(c, "_BadlyPoisonedText", "%s's\nbadly poisoned!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNWASBURNED = function(c, f) + return rom(c, "_BurnedText", "%s\nwas burned!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNWASFROZEN = function(c, f) + return rom(c, "_FrozenText", "%s\nwas frozen solid!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNWASPARALYZED = function(c, f) + return rom(c, "_ParalyzedMayNotAttackText", "%s's\nparalyzed! It may\nnot attack!", + Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNWASCONFUSED = function(c, f) + return rom(c, "_BecameConfusedText", "%s\nbecame confused!", Gen1Screen.who(c, first(f, "eff", "def"))) + end, + STRINGID_PKMNWASDEFROSTED = function(c, f) + return rom(c, "_FireDefrostedText", "Fire defrosted\n%s!", Gen1Screen.who(c, first(f, "def", "atk", "eff"))) + end, + STRINGID_ATTACKERSSTATROSE = function(c, f) return statText(c, f.atk, f) end, + STRINGID_ATTACKERSSTATFELL = function(c, f) return statText(c, f.atk, f) end, + STRINGID_DEFENDERSSTATROSE = function(c, f) return statText(c, f.def, f) end, + STRINGID_DEFENDERSSTATFELL = function(c, f) return statText(c, f.def, f) end, + STRINGID_STATCHANGESGONE = function(c) + return rom(c, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") + end, + STRINGID_PKMNSHROUDEDINMIST = function(c, f) + return rom(c, "_ShroudedInMistText", "%s's\nshrouded in mist!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNGETTINGPUMPED = function(c, f) + return rom(c, "_GettingPumpedText", "%s's\ngetting pumped!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNMADESUBSTITUTE = function(c) return rom(c, "_SubstituteText", "It created a\nSUBSTITUTE!") end, + STRINGID_PKMNHASSUBSTITUTE = function(c, f) + return rom(c, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_TOOWEAKFORSUBSTITUTE = function(c) + return rom(c, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") + end, + STRINGID_SUBSTITUTEDAMAGED = function(c, f) + return rom(c, "_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", Gen1Screen.who(c, f.def)) + end, + STRINGID_PKMNSUBSTITUTEFADED = function(c, f) + return rom(c, "_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", Gen1Screen.who(c, f.def)) + end, + STRINGID_PKMNTRANSFORMEDINTO = function(c, f) + return rom(c, "_TransformedText", "%s\ntransformed into\n%s!", Gen1Screen.who(c, f.atk), speciesOf(c, f.buff1)) + end, + STRINGID_PKMNMOVEWASDISABLED = function(c, f) + return rom(c, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", Gen1Screen.who(c, f.def), moveOf(c, f.buff1)) + end, + STRINGID_PKMNMOVEDISABLEDNOMORE = function(c, f) + return rom(c, "_DisabledNoMoreText", "%s's\ndisabled no more!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNMOVEISDISABLED = function(c, f) + return rom(c, "_MoveIsDisabledText", "%s's\n%s is\ndisabled!", Gen1Screen.who(c, f.atk), + moveOf(c, first(f, "currentMove", "buff1"))) + end, + STRINGID_PKMNSEEDED = function(c, f) + return rom(c, "_WasSeededText", "%s\nwas seeded!", Gen1Screen.who(c, f.def)) + end, + STRINGID_COINSSCATTERED = function(c) return rom(c, "_CoinsScatteredText", "Coins scattered\neverywhere!") end, + STRINGID_PKMNRAISEDDEF = function(c, f) + return rom(c, "_ReflectGainedArmorText", "%s\ngained armor!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNRAISEDSPDEF = function(c, f) + return rom(c, "_LightScreenProtectedText", "%s's\nprotected against\nspecial attacks!", Gen1Screen.who(c, f.atk)) + end, + STRINGID_PKMNLEARNEDMOVE2 = function(c, f) + return rom(c, "_MimicLearnedMoveText", "%s\nlearned\n%s!", Gen1Screen.who(c, f.atk), moveOf(c, f.buff1)) + end, + STRINGID_PKMNHASNOMOVESLEFT = function(c, f) + return rom(c, "_NoMovesLeftText", "%s has no\nmoves left!", rawName(c, f.atk)) + end, + STRINGID_NOPPLEFT = function(c) return rom(c, "_MoveNoPPText", "No PP left for\nthis move!") end, + STRINGID_BUTNOPPLEFT = function(c) return rom(c, "_MoveNoPPText", "No PP left for\nthis move!") end, + STRINGID_PKMNCHANGEDTYPE = function(c, f) + local name = Gen1Screen.who(c, f.atk) + local t = type(f.buff1) == "table" and tonumber(f.buff1.type) or nil + local Identity = require("src.online.xgen.Identity") + local typeName = t and Identity.GEN3_TYPES[t] + if not name or not typeName then return nil end + return Strings("%s's type\nbecame %s!", name, Strings(typeName)) + end, + STRINGID_PKMNWASDRAGGEDOUT = function(c, f) + local name = Gen1Screen.who(c, f.def) + return name and Strings("%s was\ndragged out!", name) or nil + end, + STRINGID_PKMNHURTBY = function(c, f) + local name, mv = Gen1Screen.who(c, f.atk), moveOf(c, f.buff1) + return (name and mv) and Strings("%s's\nhurt by %s!", name, mv) or nil + end, +} +TEXT.STRINGID_PKMNEVADEDATTACK = TEXT.STRINGID_PKMNAVOIDEDATTACK +TEXT.STRINGID_PKMNWASDEFROSTED2 = TEXT.STRINGID_PKMNWASDEFROSTED +TEXT.STRINGID_PKMNWASDEFROSTEDBY = TEXT.STRINGID_PKMNWASDEFROSTED +TEXT.STRINGID_STATROSE = function(c, f) return statText(c, first(f, "atk", "def"), f) end +TEXT.STRINGID_STATFELL = function(c, f) return statText(c, first(f, "def", "atk"), f) end + +Gen1Screen.TEXT = TEXT + +local function sleepAnim(ctx, ref, kind) + local _, seat = refMon(ctx, ref) + if seat == nil then return nil end + local mine = seat == ctx.seat + if kind == "sleep" then return { op = "anim", seat = seat, name = mine and "SLP_PLAYER_ANIM" or "SLP_ANIM" } end + return { op = "anim", seat = seat, name = mine and "CONF_PLAYER_ANIM" or "CONF_ANIM" } +end + +function Gen1Screen.textFor(id, fill, ctx) + if id == "STRINGID_USEDMOVE" then + local name = Gen1Screen.who(ctx, fill.atk) + local mv = moveOf(ctx, fill.currentMove) + if not name or not mv then return nil end + return rom(ctx, "_ItemUseText001", "%s\nused %s!", name, mv), true + end + local charge = CHARGE[id] + if charge then + local name = Gen1Screen.who(ctx, fill.atk) + if not name then return nil end + local frag = ctx.data and ctx.data.text and ctx.data.text[charge[1]] + if frag then return name .. frag end + return Strings(charge[2], name) + end + local fn = TEXT[id] + if not fn then return nil end + local ok, text = pcall(fn, ctx, fill or {}) + if not ok then return nil end + return text +end + +local function effectiveness(rest, from) + for i = from or 1, #(rest or {}) do + local e = rest[i] + if e.kind == "move" or (e.kind == "msg" and e.id == "STRINGID_USEDMOVE") then break end + if e.kind == "msg" and e.id == "STRINGID_SUPEREFFECTIVE" then return { sound = "Super_Effective", pitch = 0xe0 } end + if e.kind == "msg" and e.id == "STRINGID_NOTVERYEFFECTIVE" then + return { sound = "Not_Very_Effective", pitch = 0x50 } + end + end + -- engine/battle/animations.asm:2639 + return { sound = "Damage", pitch = 0x20 } +end + +local STATUS = { SLP = "SLP", PSN = "PSN", TOX = "PSN", BRN = "BRN", PAR = "PAR", FRZ = "FRZ" } + +local function baseRowsFor(ev, ctx, rest, restAt) + local rows = {} + if type(ev) ~= "table" then return rows end + local k = ev.kind + if k == "msg" then + local fill = type(ev.fill) == "table" and ev.fill or {} + local text, auto = Gen1Screen.textFor(ev.id, fill, ctx) + if not text then return rows end + rows[#rows + 1] = { op = "say", text = text, auto = auto or nil } + if ev.id == "STRINGID_PKMNISCONFUSED" then + rows[#rows + 1] = sleepAnim(ctx, fill.atk, "confusion") + elseif ev.id == "STRINGID_PKMNTRANSFORMEDINTO" then + local _, seat = refMon(ctx, fill.atk) + local key = Gen1Screen.speciesKey(ctx.data, type(fill.buff1) == "table" and fill.buff1.species or nil) + if seat ~= nil and key then table.insert(rows, 1, { op = "transform", seat = seat, species = key }) end + end + return rows + elseif k == "move" then + local key = Gen1Screen.moveKey(ctx.data, ev.moveId) + if ev.user == nil or not key then return rows end + ctx.lastUser = ev.user + local row = { op = "move", seat = ev.user, target = ev.target, move = key } + ctx.lastMoveRow = row + rows[1] = row + return rows + elseif k == "hp" then + local seat = tonumber(ev.side) + if seat == nil then return rows end + local idx = ctx.active[seat] + local row = { op = "hp", seat = seat, index = idx, from = ev.from, to = tonumber(ev.to) or 0, max = ev.max } + local mv = ctx.lastMoveRow + if ev.hit and mv and not mv.hit and mv.target == seat and (tonumber(ev.to) or 0) < (tonumber(ev.from) or 0) then + local def = ctx.data.moves and ctx.data.moves[mv.move] + local added = def and def.effect ~= nil and def.effect ~= "NO_ADDITIONAL_EFFECT" + local mine = mv.seat == ctx.seat + -- engine/battle/core.asm:3159 + mv.hit = { animType = mine and (added and 5 or 4) or (added and 2 or 1), sfx = effectiveness(rest, restAt) } + end + rows[1] = row + return rows + elseif k == "faint" then + local seat = tonumber(ev.side) + if seat == nil then return rows end + ctx.fainted[seat][ctx.active[seat]] = true + ctx.lastMoveRow = nil + rows[1] = { op = "faint", seat = seat, index = ctx.active[seat] } + return rows + elseif k == "status" then + local seat = tonumber(ev.side) + if seat == nil then return rows end + rows[1] = { op = "status", seat = seat, index = ctx.active[seat], status = STATUS[ev.status] } + return rows + elseif k == "stage" then + local seat = tonumber(ev.side) + if seat == nil then return rows end + rows[1] = { op = "stage", seat = seat, stat = ev.stat, delta = ev.delta, sync = ev.sync } + return rows + elseif k == "withdraw" then + local seat, idx = tonumber(ev.side), tonumber(ev.index) + if seat == nil or not idx then return rows end + if ev.reason == "start" or ctx.fainted[seat][idx] then return rows end + rows[1] = { op = "withdraw", seat = seat, index = idx, reason = ev.reason } + return rows + elseif k == "sendout" then + local seat, idx = tonumber(ev.side), tonumber(ev.index) + if seat == nil or not idx or not ctx.mons[seat][idx] then return rows end + ctx.active[seat] = idx + ctx.lastMoveRow = nil + if ev.reason == "start" then return rows end + rows[1] = { op = "sendout", seat = seat, index = idx, reason = ev.reason } + return rows + end + return rows +end + +TEXT.STRINGID_PKMNFASTASLEEP = function(c, f) + return rom(c, "_FastAsleepText", "%s\nis fast asleep!", Gen1Screen.who(c, f.atk)) +end +TEXT.STRINGID_PKMNISCONFUSED = function(c, f) + return rom(c, "_IsConfusedText", "%s\nis confused!", Gen1Screen.who(c, f.atk)) +end + +function Gen1Screen.rowsFor(ev, ctx, rest, restAt) + if type(ev) == "table" and ev.kind == "msg" and ev.id == "STRINGID_PKMNFASTASLEEP" then + local fill = type(ev.fill) == "table" and ev.fill or {} + local text = Gen1Screen.textFor(ev.id, fill, ctx) + if not text then return {} end + local anim = sleepAnim(ctx, fill.atk, "sleep") + -- engine/battle/core.asm:3341 + if anim and anim.seat == ctx.seat then return { anim, { op = "say", text = text } } end + return { { op = "say", text = text }, anim } + end + return baseRowsFor(ev, ctx, rest, restAt) +end + +function Gen1Screen.endRows(result, ctx) + local rows = {} + local function say(t) if t then rows[#rows + 1] = { op = "say", text = t } end end + result = result or {} + local me, foe = ctx.names.me, ctx.names.foe + local why, outcome = result.why, result.outcome + if why == "forfeit" then + say(Strings("%s forfeited\nthe match!", outcome == "win" and foe or me)) + elseif why == "desync" or why == "disconnect" then + say(Strings("The link was\nlost.")) + return rows + elseif why ~= "faint" then + say(Strings("The battle can't\ncontinue.")) + return rows + end + if outcome == "win" then + -- engine/battle/core.asm:956 + say(RomText(ctx.data, "_TrainerDefeatedText", "%s defeated\n%s!", me, foe)) + elseif outcome == "lose" then + -- engine/battle/core.asm:1157 + say(RomText(ctx.data, "_LinkBattleLostText", "%s lost to\n%s!", me, foe)) + else + say(Strings("The match ended\nin a draw!")) + end + return rows +end + +function Gen1Screen.menuFor(legal) + local out = { slots = {}, switches = {}, struggle = false, locked = nil, forfeit = false, any = false } + for _, a in ipairs(legal or {}) do + if a.kind == "move" then + if a.locked then + out.locked = { kind = "move", slot = a.slot, locked = true } + elseif (tonumber(a.slot) or 0) >= 1 then + out.slots[a.slot] = true + out.any = true + else + out.struggle = true + end + elseif a.kind == "switch" then + out.switches[a.index] = true + elseif a.kind == "forfeit" then + out.forfeit = true + end + end + return out +end + +function Gen1Screen.liveMoves(bs, seat, data) + local m = bs and bs.match + local mon, b + if bs and type(bs.activeMon) == "function" then mon, b = bs:activeMon(seat) end + if not mon then + local party = m and m:party(seat) + local i = m and m:active(seat) + mon = party and i and party[i] + end + if not mon then return nil end + local out, disabled = {}, nil + for i = 1, 4 do + local id = mon.moves and tonumber(mon.moves[i]) + if id and id > 0 then + out[#out + 1] = { id = Gen1Screen.moveKey(data, id) or tostring(id), pp = tonumber(mon.pp and mon.pp[i]) or 0, + ppUps = tonumber(mon.ppUps and mon.ppUps[i]) or 0 } + if b and b.expDisabledMove and tonumber(b.expDisabledMove) == id then disabled = #out end + end + end + return out, disabled +end + +local OWN_AFTER = { g3u = true, g3uMenu = true, g3uReplace = true, g3uEnd = true } +local STOP = { prompt = true, waiting = true, over = true } + +local Host = {} +Host.__index = Host +Host.isOpaque = false + +function Host:pump() + local bs = self.bs + local ok, err = pcall(bs.update, bs) + if not ok then + require("src.core.Logger").warn("g3u gen1: session update failed: %s", tostring(err)) + if not bs.result then pcall(bs.quit, bs) end + end + for _, ev in ipairs(bs:events()) do self.pending[#self.pending + 1] = ev end +end + +function Host:close() + if self.closed then return end + self.closed = true + local game = self.game + for i = #game.stack.states, 1, -1 do + if game.stack.states[i] == self then + while game.stack:top() ~= self do game.stack:pop() end + game.stack:pop() + break + end + end + if self.onDone then + local cb = self.onDone + self.onDone = nil + cb(self.bs.result) + end +end + +function Host:update() + if self.closed then return end + if self.battle then + self:close() + return + end + self:pump() + for i, ev in ipairs(self.pending) do + if ev.kind == "ready" then + table.remove(self.pending, i) + local ok, err = pcall(self.startBattle, self, ev) + if not ok then + require("src.core.Logger").warn("g3u gen1: battle failed to start: %s", tostring(err)) + pcall(self.bs.quit, self.bs) + self:closeWithText(Strings("The battle can't\ncontinue.")) + end + return + end + end + if self.bs.result then + local ctx = { data = self.game.data, names = self.names } + local rows = Gen1Screen.endRows(self.bs.result, ctx) + self:closeWithText(rows[1] and rows[1].text or Strings("The link was\nlost.")) + end +end + +function Host:closeWithText(text) + if self.texting then return end + self.texting = true + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, text, function() self:close() end)) +end + +function Host:draw() + if self.battle or self.texting then return end + local Font = require("src.render.Font") + Font.drawBox(3, 10, 13, 3) + love.graphics.setColor(0, 0, 0, 1) + -- engine/link/print_waiting_text.asm:21 + Font.draw(Strings("Waiting...!"), 32, 88) + love.graphics.setColor(1, 1, 1, 1) +end + +local function wrapUi(s, inst) + local base = inst.update + inst.update = function(self, dt) + s.g3uHost:pump() + if s.g3uBs.result and not s.g3uEnding then + local stack = s.game.stack + if stack:top() == self then stack:pop() end + return + end + return base(self, dt) + end + return inst +end + +local function battlerFor(s, seat) + return seat == s.g3uSeat and s.player or s.enemy +end + +local function applyRows(s, rows) + local data = s.data + local game = s.game + local BattleState = require("src.battle.BattleState") + local Timing = require("src.core.Timing") + local Sound = require("src.core.Sound") + local mons = s.g3uCtx.mons + for _, r in ipairs(rows) do + if r.op == "say" then + if r.auto then s:sayAuto(r.text) else s:say(r.text) end + elseif r.op == "move" then + local row = { anim = r.move, attackerIsPlayer = r.seat == s.g3uSeat } + s:act(function() + row.hit = r.hit + if row.hit and (row.hit.animType == 4 or row.hit.animType == 5) then + row.hit.blink = battlerFor(s, r.target) + end + end) + table.insert(s.queue, row) + elseif r.op == "anim" then + table.insert(s.queue, { anim = r.name, attackerIsPlayer = r.seat == s.g3uSeat }) + elseif r.op == "hp" then + s:act(function() + local mon = mons[r.seat][r.index] + if not mon then return end + mon.hp = math.max(0, math.min(r.to, mon.stats.hp)) + local b = battlerFor(s, r.seat) + if b and b.mon == mon then s:drainNext(b, mon.hp) end + end) + elseif r.op == "status" then + s:act(function() + local mon = mons[r.seat][r.index] + if not mon then return end + mon.status = r.status + if mon.status ~= "SLP" then mon.sleepTurns = nil end + local b = battlerFor(s, r.seat) + if b and b.mon == mon then b.shownStatus = mon.status end + end) + elseif r.op == "stage" then + s:act(function() + local b = battlerFor(s, r.seat) + if not b then return end + b.stages = b.stages or {} + b.stages[r.stat] = (b.stages[r.stat] or 0) + (tonumber(r.delta) or 0) + end) + elseif r.op == "transform" then + s:act(function() + local b = battlerFor(s, r.seat) + if b then b.sprite = s:speciesSprite(r.species, b.isPlayer) or b.sprite end + end) + elseif r.op == "faint" then + s:act(function() + local b = battlerFor(s, r.seat) + if not b then return end + b.fainted = true + -- engine/battle/core.asm:1042 + if b.isPlayer then + s.faintCry = Sound.playCry(data, b.mon.species, 4) + else + Sound.play(data, "Faint_Fall") + end + s.fx = s.fx or {} + s.fx.faint = { battler = b, frames = Timing.FAINT_SLIDE } + s:waitNext(Timing.FAINT_SLIDE) + if b.isPlayer then + s:waitSfxNext(function() return s.faintCry end) + else + s:actNext(function() Sound.play(data, "Faint_Thud") end) + end + end) + elseif r.op == "withdraw" then + if r.seat == s.g3uSeat then + s:act(function() + if not s.player or s.player.fainted then return end + -- engine/battle/core.asm:2419 + s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON) + s:queueRetreatAnim() + s:actNext(function() s.sendingOut = true end) + end) + else + s:act(function() + if not s.enemy or s.enemy.fainted then return end + -- engine/battle/trainer_ai.asm:598 + s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!", s.opponentName, s.enemy.name)) + s:actNext(function() s.enemySendingOut = true end) + end) + end + elseif r.op == "sendout" then + local quiet = r.reason == "roar" + if r.seat == s.g3uSeat then + s:act(function() + local mon = mons[r.seat][r.index] + s.player = BattleState.makeBattler(data, mon, true, nil) + s.g3uShown[r.seat] = r.index + s:syncSides() + s.menuIndex, s.moveIndex, s.playerMoveListIndex = 1, 1, 1 + s.sendingOut = true + if not quiet then s:sayNextAuto(s:sendOutText(s.player.name)) end + s:animNext("POOF_ANIM", false) + s:actNext(function() + s.sendingOut = false + s:startGrowIn(s.player) + s:waitSfxNext(s:playEntranceCry(s.player)) + end) + end) + else + s:act(function() + local mon = mons[r.seat][r.index] + s.enemy = BattleState.makeBattler(data, mon, false, nil) + s.g3uShown[r.seat] = r.index + s:syncSides() + s.enemySendingOut = true + if not quiet then + -- engine/battle/core.asm:1421 + s:sayNextAuto(s:romText("_TrainerSentOutText", "%s sent\nout %s!", s.opponentName, s.enemy.name)) + end + s:actNext(function() + s.enemySendingOut = false + s:startGrowIn(s.enemy) + s:queueEnemySendOutCry(false) + end) + end) + end + end + end + return game +end + +local function submit(s, act) + local bs = s.g3uBs + s.g3uWaiting = true + s.phase = "g3u" + s.msgHold, s.shown = nil, nil + if not bs.result then bs:choose(act) end +end + +local function openReplacement(s) + s.g3uPick = nil + s.phase = "messages" + s.afterQueue = "g3uReplace" + local legal = Gen1Screen.menuFor(s.g3uBs:legal()) + s:ui(function() + return wrapUi(s, s:buildScreen("PartyMenu", { + battle = s, + party = s.playerParty, + forceSwitch = true, + keepOpen = true, + onSwitch = function(mon, menu) + local idx + for i, m in ipairs(s.playerParty) do if m == mon then idx = i end end + if not idx or not legal.switches[idx] then + -- engine/battle/core.asm:1484 + if menu then menu:refuse(s:romText("_NoWillText", "There's no will\nto fight!")) end + return + end + s.g3uPick = { kind = "switch", index = idx } + if menu then menu:close() end + end, + })) + end) +end + +local function syncMenu(s) + local bs = s.g3uBs + local moves, disabled = Gen1Screen.liveMoves(bs, s.g3uSeat, s.data) + if moves and #moves > 0 and s.player then + s.player.curMoves = moves + s.player.disabledSlot = disabled + end + s.g3uLegal = Gen1Screen.menuFor(bs:legal()) +end + +local function handleStop(s, ev) + local bs = s.g3uBs + if ev.kind == "waiting" then + s.g3uWaiting = true + return + end + if ev.kind == "over" then + s.g3uEnding = true + s.g3uWaiting = false + local result = { outcome = ev.outcome, why = ev.why, detail = ev.detail } + if result.outcome == "win" and result.why == "faint" then s:playVictoryMusic() end + applyRows(s, Gen1Screen.endRows(result, s.g3uCtx)) + s.phase = "messages" + s.afterQueue = "g3uEnd" + return + end + if ev.what == "move" then + if bs.phase ~= "choose" then return end + syncMenu(s) + s.g3uWaiting = false + if s.g3uLegal.locked then return submit(s, s.g3uLegal.locked) end + s.phase = "menu" + elseif ev.what == "replace" then + if bs.phase ~= "replace" then return end + s.g3uWaiting = false + openReplacement(s) + end +end + +local function advance(s) + local pending = s.g3uHost.pending + while pending[1] do + local ev = pending[1] + if STOP[ev.kind] then + table.remove(pending, 1) + handleStop(s, ev) + if s.phase ~= "g3u" then return end + else + while pending[1] and not STOP[pending[1].kind] do + local e = table.remove(pending, 1) + local ok, rows = pcall(Gen1Screen.rowsFor, e, s.g3uCtx, pending, 1) + if ok then applyRows(s, rows) end + end + if #s.queue > 0 then + s.g3uWaiting = false + s.phase = "messages" + s.afterQueue = "g3u" + return + end + end + end +end + +local function afterOwn(s, dest) + if dest == "g3uEnd" then + s:finish() + return + end + s.msgHold, s.shown = nil, nil + if dest == "g3uMenu" then + local pick = s.g3uPick + s.g3uPick = nil + if pick then return submit(s, pick) end + s.phase = "menu" + elseif dest == "g3uReplace" then + local pick = s.g3uPick + s.g3uPick = nil + if pick then + s.g3uWaiting = true + s.phase = "g3u" + if not s.g3uBs.result then s.g3uBs:pickReplacement(pick.index) end + else + openReplacement(s) + end + else + s.phase = "g3u" + end +end + +local function install(s, host) + local BattleState = require("src.battle.BattleState") + local game = host.game + local bs = host.bs + local baseUpdate = BattleState.update + + s.enterCommandMenu = function(self) self.phase = "g3u" end + s.swapMoves = function() end + + s.enter = function(self, ...) + BattleState.enter(self, ...) + for i, item in ipairs(self.queue) do + if item.text == self.introText then + table.insert(self.queue, i + 1, { text = Strings("UNION RULES: GEN 3\nbattle mechanics.") }) + table.insert(self.queue, i + 2, { text = Strings("No abilities or\nheld items.") }) + break + end + end + end + + s.chooseMenu = function(self, choice) + if self.phase ~= "menu" then return nil, "battle menu is not active" end + if choice == "fight" then + local legal = self.g3uLegal or Gen1Screen.menuFor(bs:legal()) + if not legal.any and legal.struggle then + self:say(self:romText("_NoMovesLeftText", "%s has no\nmoves left!", self.player.name)) + self.g3uPick = { kind = "move", slot = 0 } + self.phase = "messages" + self.afterQueue = "g3uMenu" + return true + end + self.phase = "moveSelect" + self.moveIndex = math.max(1, math.min(self.moveIndex, #self.player.curMoves)) + self.moveSwapIndex = nil + return true + end + return BattleState.chooseMenu(self, choice) + end + + s.chooseMove = function(self, index) + if self.phase ~= "moveSelect" then return nil, "move menu is not active" end + local move = self.player.curMoves[index] + if not move then return nil, "invalid move slot" end + self.moveIndex = index + local legal = self.g3uLegal or Gen1Screen.menuFor(bs:legal()) + if not legal.slots[index] then + if self.player.disabledSlot == index or (move.pp or 0) > 0 then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + else + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + end + self.phase = "messages" + self.afterQueue = "g3uMenu" + return true + end + self.playerMoveListIndex = index + submit(self, { kind = "move", slot = index }) + return true + end + + s.resolveTurn = function() end + s.resolveSwitch = function() end + + s.tryRun = function(self) + self.g3uPick = nil + self.phase = "messages" + self.afterQueue = "g3uMenu" + self:sayAuto(Strings("Forfeit the\nmatch?")) + self:ui(function() + local ChoiceBox = require("src.ui.ChoiceBox") + return wrapUi(self, ChoiceBox.new(game, function(yes) + if yes then self.g3uPick = { kind = "forfeit" } end + end, { defaultNo = true })) + end) + end + + s.openItems = function(self) + self:say(Strings("Items can't be\nused in a link\nbattle!")) + self.phase = "messages" + self.afterQueue = "g3uMenu" + end + + s.openParty = function(self) + self.g3uPick = nil + self.phase = "messages" + self.afterQueue = "g3uMenu" + local legal = self.g3uLegal or Gen1Screen.menuFor(bs:legal()) + self:ui(function() + return wrapUi(self, self:buildScreen("PartyMenu", { + battle = self, + party = self.playerParty, + keepOpen = true, + onSwitch = function(mon, menu) + local idx + for i, m in ipairs(self.playerParty) do if m == mon then idx = i end end + local refusal + -- engine/battle/core.asm:2403 + if mon == self.player.mon then + refusal = self:romText("_AlreadyOutText", "%s is\nalready out!", self.player.name) + elseif mon.hp <= 0 then + refusal = self:romText("_NoWillText", "There's no will\nto fight!") + elseif not idx or not legal.switches[idx] then + refusal = self:romText("_CantEscapeText", "Can't escape!") + end + if refusal then + if menu then menu:refuse(refusal) end + return + end + self.g3uPick = { kind = "switch", index = idx } + if menu then menu:close() end + end, + })) + end) + end + + s.update = function(self, dt) + if self.g3uFinished then return end + host:pump() + if bs.result and not self.g3uEnding and (self.phase == "menu" or self.phase == "moveSelect") then + self.phase = "g3u" + end + if self.phase == "moveSelect" then self.moveSwapIndex = nil end + if self.phase == "messages" and OWN_AFTER[self.afterQueue] then + self:tickFx() + if not self:updateQueue() then + local dest = self.afterQueue + self.afterQueue, self.nextInsert, self.waitFrames = nil, nil, nil + afterOwn(self, dest) + end + self:tickTextScroll() + return + end + if self.phase == "g3u" then + self:tickFx() + advance(self) + return + end + return baseUpdate(self, dt) + end + + s.draw = function(self, ...) + BattleState.draw(self, ...) + if self.phase == "g3u" and self.g3uWaiting and not self.g3uEnding then + local Font = require("src.render.Font") + Font.drawBox(3, 10, 13, 3) + love.graphics.setColor(0, 0, 0, 1) + -- engine/link/print_waiting_text.asm:21 + Font.draw(Strings("Waiting...!"), 32, 88) + love.graphics.setColor(1, 1, 1, 1) + end + end + + s.finish = function(self) + if self.g3uFinished then return end + self.g3uFinished = true + require("src.core.Sound").stopLoop("Low_Health_Alarm") + require("src.core.Music").restoreMap(self.data) + if game.stack:top() == self then game.stack:pop() end + local Transition = require("src.render.Transition") + game.stack:push(Transition.battleReturn(game, function() host:close() end)) + end +end + +function Host:startBattle(ready) + local game, bs = self.game, self.bs + local BattleState = require("src.battle.BattleState") + local data = game.data + local seat = bs.seat + local peer = 1 - seat + local ctx, why = Gen1Screen.newCtx(data, seat, ready.parties, self.names) + if not ctx then error("g3u gen1: " .. tostring(why)) end + for _, e in ipairs(self.pending) do + if e.kind == "sendout" and e.reason == "start" and ctx.mons[e.side] and ctx.mons[e.side][e.index] then + ctx.active[e.side] = e.index + end + end + local myMon = ctx.mons[seat][ctx.active[seat]] + local foeMon = ctx.mons[peer][ctx.active[peer]] + if not myMon or not foeMon then error("g3u gen1: empty party") end + local dex = game.save and game.save.pokedex + local seen = dex and dex.seen and dex.seen[foeMon.species] + local s = BattleState.newWild(game, foeMon.species, foeMon.level) + if dex and dex.seen then dex.seen[foeMon.species] = seen end + s.dead = nil + s.kind = "link" + s.g3u = true + s.g3uHost, s.g3uBs, s.g3uCtx, s.g3uSeat = self, bs, ctx, seat + s.g3uShown = { [seat] = ctx.active[seat], [peer] = ctx.active[peer] } + s.player = BattleState.makeBattler(data, myMon, true, nil) + s.enemy = BattleState.makeBattler(data, foeMon, false, nil) + s.playerParty = ctx.mons[seat] + s.enemyParty = ctx.mons[peer] + s.opponentName = ctx.names.foe + -- data/text/text_2.asm:1257 + s.introText = s:romText("_TrainerWantsToFightText", "%s wants\nto fight!", ctx.names.foe) + install(s, self) + self.battle = s + game.stack:push(s) +end + +function Gen1Screen.start(game, bs, opts) + if type(game) ~= "table" or type(bs) ~= "table" then return nil, "bad_args" end + opts = opts or {} + local names = opts.names or {} + local seat = bs.seat or 0 + local me = names.me or (bs.names and bs.names[seat]) or (game.save and game.save.player and game.save.player.name) + local foe = names.foe or (bs.names and bs.names[1 - seat]) or Strings("FOE") + local host = setmetatable({ + game = game, bs = bs, onDone = opts.onDone, pending = {}, + names = { me = me or "?", foe = foe }, + }, Host) + game.stack:push(host) + return { host = host, bs = bs, battle = function() return host.battle end } +end + +Gen1Screen.Host = Host + +return Gen1Screen diff --git a/src/ui/g3u/Gen2Facade.lua b/src/ui/g3u/Gen2Facade.lua new file mode 100644 index 00000000..2b9c6260 --- /dev/null +++ b/src/ui/g3u/Gen2Facade.lua @@ -0,0 +1,1143 @@ +local Battle = require("src.battle.gen2.Battle") +local Effects = require("src.battle.gen2.Effects") +local Identity = require("src.online.xgen.Identity") +local Strings = require("src.core.Strings") + +local Gen2Facade = {} + +-- engine/link/link.asm:443 +Gen2Facade.LINK_CLASS = "CAL" + +Gen2Facade.FAIL_HOLD_FRAMES = 240 + +Gen2Facade.STATUS = { + SLP = "sleep", PSN = "poison", TOX = "toxic", BRN = "burn", PAR = "paralyze", FRZ = "freeze", +} + +Gen2Facade.STATS = { + attack = "attack", defense = "defense", speed = "speed", spAtk = "specialAttack", + spDef = "specialDefense", accuracy = "accuracy", evasion = "evasion", + atk = "attack", def = "defense", spe = "speed", spa = "specialAttack", spd = "specialDefense", +} + +Gen2Facade.WEATHER = { + SUN = "sun", SUNNY = "sun", HARSH_SUN = "sun", RAIN = "rain", RAINY = "rain", DOWNPOUR = "rain", + SAND = "sandstorm", SANDSTORM = "sandstorm", +} + +Gen2Facade.ANIMS = { + status = { + SLEEP = "ANIM_SLP", POISON = "ANIM_PSN", BURN = "ANIM_BRN", PARALYSIS = "ANIM_PAR", + FREEZE = "ANIM_FRZ", CONFUSION = "ANIM_CONFUSED", INFATUATION = "ANIM_IN_LOVE", + NIGHTMARE = "ANIM_IN_NIGHTMARE", + }, + general = { + LEECH_SEED_DRAIN = "ANIM_SAP", SANDSTORM_CONTINUES = "ANIM_IN_SANDSTORM", + }, +} + +Gen2Facade.MOVE_ANIMS = { TURN_TRAP = "arg", FUTURE_SIGHT_HIT = 248 } + +Gen2Facade.FIELDS = { + "player", "enemy", "party", "enemyParty", "trainer", "wild", "over", "outcome", "battleType", + "random", "participants", "payDay", "amuletCoin", "timeOfDay", "boxFilled", "data", +} + +Gen2Facade.NILABLE = { + battleType = true, payDay = true, amuletCoin = true, timeOfDay = true, boxFilled = true, + outcome = true, +} + +Gen2Facade.METHODS = { + "takeTurn", "takeEvents", "volatile", "lockedInMove", "moveDisabled", "hasUsableMoves", + "switchLocked", "switch", "shiftSwitch", "resolveForget", "declineForget", "partyMoves", + "clearAllVolatiles", "useBattleItem", "tryRun", "endBattle", "forcedReplacement", +} + +local S = Strings.source +local T = { + rules1 = S("UNION RULES!"), + rules2 = S("GEN 3 battle rules.\nNo abilities or\nheld items."), + waiting = S("Waiting..."), + waitingFor = S("Waiting for\n%s..."), + noItems = S("Items can't be used in a link battle!"), + runAgain = S("Choose RUN again\nto forfeit."), + cantNow = S("That can't be\nchosen now."), + defeated = S("%s was defeated!"), + noMore = S("You have no more POKéMON!"), + draw = S("The battle ended\nin a draw!"), + foeForfeit = S("%s forfeited\nthe match!"), + meForfeit = S("You forfeited\nthe match."), + desync = S("The link fell out\nof sync. It's a draw."), + left = S("%s left the battle."), + illegal = S("The other game sent\na bad move. Draw."), + broken = S("The battle could\nnot continue."), + fainted = S("%s fainted!"), + withdrew = S("%s withdrew %s!"), + go = S("Go! %s!"), + usedMove = S("%s\nused %s!"), + woreOff = S("%s's %s wore off!"), +} +Gen2Facade.TEXT = T + +local GENDER = { [0] = "male", [1] = "female" } + +local IDLE = { + menu = true, moves = true, ["locked-in"] = true, ["link-wait"] = true, ["link-hold"] = true, + ["refuse-menu"] = true, ["refuse-move"] = true, ["refuse-switch"] = true, +} +Gen2Facade.IDLE = IDLE + +local Facade = {} +Facade.__index = Facade +Gen2Facade.Facade = Facade + +local function lcg(seed) + local s = (tonumber(seed) or 1) % 2147483647 + if s <= 0 then s = s + 2147483646 end + return function(n) + s = (s * 16807) % 2147483647 + n = math.max(1, math.floor(tonumber(n) or 1)) + return s % n + end +end + +function Gen2Facade.new(game, bs, opts) + opts = opts or {} + local names = opts.names or {} + local seat = bs.seat + local self = setmetatable({ + game = game, bs = bs, seat = seat, peer = 1 - seat, + data = (game and game.data) or {}, + myName = names.me or (bs.names and bs.names[seat]) or "PLAYER", + foeName = names.foe or (bs.names and bs.names[1 - seat]) or "FOE", + views = { [0] = {}, [1] = {} }, activeIndex = {}, + party = {}, enemyParty = {}, player = nil, enemy = nil, + wild = false, over = false, outcome = nil, linkBattle = true, + participants = {}, events = {}, vol = setmetatable({}, { __mode = "k" }), + awaiting = nil, ready = false, ended = false, weather = nil, + random = lcg(bs.seed), + }, Facade) + self.trainer = { name = self.foeName, classId = opts.foeClass or Gen2Facade.LINK_CLASS } + return self +end + +function Facade:sideName(seat) + if seat == nil then return nil end + return seat == self.seat and "player" or "enemy" +end + +function Facade:speciesKey(n) + if not self._dex then + self._dex = {} + for key, def in pairs(self.data.pokemon or {}) do + if type(def) == "table" then + local dex = tonumber(def.dex) or tonumber(def.index) + if dex and self._dex[dex] == nil then self._dex[dex] = key end + end + end + end + return self._dex[tonumber(n) or -1] +end + +function Facade:moveKey(id) + if not self._moves then + self._moves = {} + for key, def in pairs(self.data.moves or {}) do + if type(def) == "table" and tonumber(def.index) then self._moves[def.index] = key end + end + end + return self._moves[tonumber(id) or -1] +end + +function Facade:moveName(id) + local key = self:moveKey(id) + local def = key and self.data.moves[key] + return (def and def.name) or tostring(key or id or "?") +end + +function Facade:speciesName(n) + local key = self:speciesKey(n) + local def = key and self.data.pokemon[key] + return (def and def.name) or tostring(key or n or "?") +end + +function Facade:viewOf(rec) + local key = self:speciesKey(rec.species) + local def = key and self.data.pokemon[key] or nil + local moves, slots = {}, {} + for i, mv in ipairs(rec.moves or {}) do + moves[i] = { id = self:moveKey(mv.id) or mv.id, national = mv.id, pp = mv.pp, maxPp = mv.pp, + ppUps = mv.ppUps or 0 } + slots[mv.id] = i + end + local nick = rec.nickname + if type(nick) ~= "string" or nick == "" then nick = def and def.name or nil end + return { + species = key or rec.species, national = rec.species, nickname = nick, + name = def and def.name or nil, level = rec.level, hp = rec.hp, maxHp = rec.maxHp, + status = nil, gender = GENDER[rec.gender], moves = moves, engineSlots = slots, + types = def and def.types or nil, friendship = rec.friendship, + stats = { hp = rec.maxHp, attack = rec.atk, defense = rec.def, speed = rec.speed, + specialAttack = rec.spAtk, specialDefense = rec.spDef }, + } +end + +function Facade:onReady(ev) + for seat = 0, 1 do + local list = {} + for i, rec in ipairs((ev.parties and ev.parties[seat]) or {}) do list[i] = self:viewOf(rec) end + self.views[seat] = list + end + self.party = self.views[self.seat] + self.enemyParty = self.views[self.peer] + self:setActive(self.seat, 1) + self:setActive(self.peer, 1) + self.ready = true +end + +function Facade:setActive(seat, index) + self.activeIndex[seat] = index + local v = self.views[seat][index] + if seat == self.seat then + self.player = v + self.participants[index] = true + else + self.enemy = v + end + return v +end + +function Facade:active(seat) + return self.views[seat] and self.views[seat][self.activeIndex[seat] or 1] or nil +end + +function Facade:ref(r) + if type(r) == "string" then + if r == "player" then return self:active(0) end + if r == "enemy" then return self:active(1) end + return nil + end + if type(r) ~= "table" or r.side == nil then return nil end + local list = self.views[r.side] + return list and (list[r.index] or self:active(r.side)) or nil +end + +function Facade:nameOf(v) + if not v then return "?" end + return v.nickname or v.name or tostring(v.species or "?") +end + +function Facade:N(r) + return self:nameOf(self:ref(r)) +end + +local function moveOf(v) + if type(v) == "table" then return v.move end + return tonumber(v) +end + +local function stageText(self, ref, stat, delta) + return Effects.stageMessage(self:N(ref), Gen2Facade.STATS[stat] or stat, tonumber(delta) or 0) +end + +local function wontText(self, ref, stat, up) + local label = Strings(Effects.STAT_NAMES[Gen2Facade.STATS[stat] or stat] or tostring(stat)) + if up then return Strings("%s's %s won't rise anymore!", self:N(ref), label) end + return Strings("%s's %s won't drop anymore!", self:N(ref), label) +end + +local function role(src, key) + return function(self, f) return Strings(src, self:N(f[key])) end +end + +local function plain(src) + return function() return Strings(src) end +end + +local function inflict(status, key) + return function(self, f) return Strings(Battle.STATUS_INFLICT_TEMPLATES[status], self:N(f[key])) end +end + +local function weatherText(tbl, kind) + return function() return Strings(tbl[kind]) end +end + +local MSG = { + STRINGID_ATTACKERSSTATROSE = function(self, f) return stageText(self, f.atk, f.stat, f.delta) end, + STRINGID_ATTACKERSSTATFELL = function(self, f) return stageText(self, f.atk, f.stat, f.delta) end, + STRINGID_DEFENDERSSTATROSE = function(self, f) return stageText(self, f.def, f.stat, f.delta) end, + STRINGID_DEFENDERSSTATFELL = function(self, f) return stageText(self, f.def, f.stat, f.delta) end, + STRINGID_STATSWONTINCREASE = function(self, f) return wontText(self, f.atk or f.def, f.buff1, true) end, + STRINGID_STATSWONTDECREASE = function(self, f) return wontText(self, f.def or f.atk, f.buff1, false) end, + STRINGID_ATTACKMISSED = role("%s's attack missed!", "atk"), + STRINGID_BELLCHIMED = plain("A bell chimed!"), + STRINGID_BUTITFAILED = plain("But it failed!"), + STRINGID_MIRRORMOVEFAILED = plain("But it failed!"), + STRINGID_BUTNOTHINGHAPPENED = plain("But nothing\nhappened."), + STRINGID_BUTNOEFFECT = plain("But nothing\nhappened."), + STRINGID_COINSSCATTERED = plain("Coins scattered\neverywhere!"), + STRINGID_CRITICALHIT = plain("A critical hit!"), + STRINGID_FAINTINTHREE = plain("Both POKéMON will\nfaint in 3 turns!"), + STRINGID_HITXTIMES = function(_, f) return Strings("Hit %d times!", tonumber(f.buff1) or 0) end, + STRINGID_ITDOESNTAFFECT = role("It didn't affect\n%s!", "def"), + STRINGID_PKMNUNAFFECTED = role("It didn't affect\n%s!", "def"), + STRINGID_PKMNWASNTAFFECTED = role("It didn't affect\n%s!", "def"), + STRINGID_ITHURTCONFUSION = plain("It hurt itself in its confusion!"), + STRINGID_MAGNITUDESTRENGTH = function(_, f) return Strings("Magnitude %d!", tonumber(f.buff1) or 0) end, + STRINGID_NOTVERYEFFECTIVE = plain("It's not very\neffective…"), + STRINGID_SUPEREFFECTIVE = plain("It's super-\neffective!"), + STRINGID_ONEHITKO = plain("It's a one-hit KO!"), + STRINGID_PKMNALREADYASLEEP = role("%s's already asleep!", "def"), + STRINGID_PKMNALREADYASLEEP2 = role("%s's already asleep!", "atk"), + STRINGID_PKMNALREADYCONFUSED = role("%s's already confused!", "def"), + STRINGID_PKMNALREADYPOISONED = role("%s's already poisoned!", "def"), + STRINGID_PKMNISALREADYPARALYZED = role("%s's already paralyzed!", "def"), + STRINGID_PKMNALREADYHASBURN = role("%s's already burned!", "def"), + STRINGID_PKMNATTACK = function(self, f) + local sp = type(f.buff1) == "table" and f.buff1.species + return Strings("%s's attack!", sp and self:speciesName(sp) or "?") + end, + STRINGID_PKMNBADLYPOISONED = inflict("toxic", "eff"), + STRINGID_PKMNWASPOISONED = inflict("poison", "eff"), + STRINGID_PKMNWASBURNED = inflict("burn", "eff"), + STRINGID_PKMNWASFROZEN = inflict("freeze", "eff"), + STRINGID_PKMNWASPARALYZED = inflict("paralyze", "eff"), + STRINGID_PKMNFELLASLEEP = inflict("sleep", "eff"), + STRINGID_PKMNWASCONFUSED = inflict("confuse", "eff"), + STRINGID_PKMNBRACEDITSELF = role("%s braced itself!", "atk"), + STRINGID_PKMNBUFFETEDBYSANDSTORM = role("%s is buffeted by the sandstorm!", "atk"), + STRINGID_PKMNCHANGEDTYPE = function(self, f) + local t = type(f.buff1) == "table" and f.buff1.type + return Strings("%s transformed into the %s type!", self:N(f.atk), + t and (Identity.GEN3_TYPES[t] == "MYSTERY" and "???" or Identity.GEN3_TYPES[t]) or "?") + end, + STRINGID_PKMNCOPIEDSTATCHANGES = function(self, f) + return Strings("%s\ncopied the stat\fchanges of\n%s!", self:N(f.atk), self:N(f.def)) + end, + STRINGID_PKMNCOVEREDBYVEIL = role("%s's covered by a veil!", "atk"), + STRINGID_PKMNUSEDSAFEGUARD = role("%s's covered by a veil!", "atk"), + STRINGID_PKMNCRASHED = role("%s kept going and crashed!", "atk"), + STRINGID_PKMNCUTHPMAXEDATTACK = role("%s\ncut its HP and\nmaximized ATTACK!", "atk"), + STRINGID_PKMNDREAMEATEN = role("%s's dream was eaten!", "def"), + STRINGID_PKMNDUGHOLE = role("%s dug a hole!", "atk"), + STRINGID_PKMNENDUREDHIT = role("%s endured the hit!", "def"), + STRINGID_PKMNENERGYDRAINED = role("%s's energy was drained!", "def"), + STRINGID_PKMNEVADEDATTACK = role("%s evaded the attack!", "def"), + STRINGID_PKMNAVOIDEDATTACK = role("%s evaded the attack!", "def"), + STRINGID_PKMNFASTASLEEP = role("%s is fast asleep!", "atk"), + STRINGID_PKMNFATIGUECONFUSION = role("%s became confused due to fatigue!", "atk"), + STRINGID_PKMNFELLINLOVE = role("%s\nfell in love!", "def"), + STRINGID_PKMNFLEWHIGH = role("%s flew up high!", "atk"), + STRINGID_PKMNFLINCHED = role("%s flinched!", "atk"), + STRINGID_PKMNFORESAWATTACK = role("%s foresaw an attack!", "atk"), + STRINGID_PKMNFREEDFROM = function(self, f) + return Strings("%s was released from %s!", self:N(f.atk), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNGETTINGPUMPED = role("%s's\ngetting pumped!", "atk"), + STRINGID_PKMNGOTENCORE = role("%s got an ENCORE!", "def"), + STRINGID_PKMNENCOREENDED = role("%s's ENCORE ended!", "atk"), + STRINGID_PKMNHASSUBSTITUTE = role("%s has a SUBSTITUTE!", "atk"), + STRINGID_PKMNHEALEDCONFUSION = role("%s's confused no more!", "atk"), + STRINGID_PKMNHITWITHRECOIL = role("%s is hit with recoil!", "atk"), + STRINGID_PKMNHPFULL = role("%s's HP is full!", "def"), + STRINGID_PKMNHURTBY = function(self, f) + return Strings("%s's hurt by %s!", self:N(f.atk), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNHURTBYBURN = role("%s is hurt by its burn!", "atk"), + STRINGID_PKMNHURTBYPOISON = role("%s is hurt by poison!", "atk"), + STRINGID_PKMNHURTBYSPIKES = role("%s is hurt by SPIKES!", "scrActive"), + STRINGID_PKMNIDENTIFIED = function(self, f) return Strings("%s identified %s!", self:N(f.atk), self:N(f.def)) end, + STRINGID_PKMNIMMOBILIZEDBYLOVE = role("%s's infatuation kept\nit from attacking!", "atk"), + STRINGID_PKMNINLOVE = function(self, f) + return Strings("%s\nis in love with\n%s!", self:N(f.atk), self:N(f.scrActive or f.def)) + end, + STRINGID_PKMNISCONFUSED = role("%s is confused!", "atk"), + STRINGID_PKMNISFROZEN = role("%s is frozen solid!", "atk"), + STRINGID_PKMNISGLOWING = role("%s is glowing!", "atk"), + STRINGID_PKMNISPARALYZED = role("%s's fully paralyzed!", "atk"), + STRINGID_PKMNLEARNEDMOVE2 = function(self, f) + return Strings("%s learned %s!", self:N(f.atk), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNLOWEREDHEAD = role("%s lowered its head!", "atk"), + STRINGID_PKMNMADESUBSTITUTE = role("%s made a SUBSTITUTE!", "atk"), + STRINGID_TOOWEAKFORSUBSTITUTE = plain("Too weak to make\na SUBSTITUTE!"), + STRINGID_PKMNMOVEDISABLEDNOMORE = role("%s's move is no longer disabled!", "atk"), + STRINGID_PKMNMOVEISDISABLED = function(self, f) + return Strings("%s's %s is DISABLED!", self:N(f.active or f.atk), self:moveName(moveOf(f.currentMove))) + end, + STRINGID_PKMNMOVEWASDISABLED = function(self, f) + return Strings("%s's %s was disabled!", self:N(f.def), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNMUSTRECHARGE = role("%s must recharge!", "atk"), + STRINGID_PKMNPERISHCOUNTFELL = function(self, f) + return Strings("%s's PERISH count is %d!", self:N(f.atk), tonumber(f.buff1) or 0) + end, + STRINGID_PKMNPROTECTEDBYMIST = role("%s's protected by MIST.", "scrActive"), + STRINGID_PKMNPROTECTEDITSELF = role("%s protected itself!", "def"), + STRINGID_PKMNPROTECTEDITSELF2 = role("%s protected itself!", "atk"), + STRINGID_PKMNRAGEBUILDING = role("%s's RAGE is building!", "def"), + STRINGID_PKMNRAISEDDEF = role("%s's DEFENSE rose!", "atk"), + STRINGID_PKMNRAISEDSPDEF = role("%s's SPCL.DEF rose!", "atk"), + STRINGID_PKMNREDUCEDPP = function(self, f) + return Strings("%s's %s was reduced by %d!", self:N(f.def), self:moveName(moveOf(f.buff1)), + tonumber(f.buff2) or 0) + end, + STRINGID_PKMNREGAINEDHEALTH = role("%s regained health!", "def"), + STRINGID_PKMNSAFEGUARDEXPIRED = role("%s's SAFEGUARD faded!", "atk"), + STRINGID_PKMNSAPPEDBYLEECHSEED = role("LEECH SEED saps %s!", "atk"), + STRINGID_PKMNSEEDED = role("%s was seeded!", "def"), + STRINGID_PKMNSHROUDEDINMIST = role("%s's\nshrouded in MIST!", "atk"), + STRINGID_PKMNSKETCHEDMOVE = function(self, f) + return Strings("%s\nSKETCHED\v%s!", self:N(f.atk), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNSLEPTHEALTHY = role("%s fell asleep and became healthy!", "atk"), + STRINGID_PKMNWENTTOSLEEP = role("%s went to sleep!", "atk"), + STRINGID_PKMNSQUEEZEDBYBIND = function(self, f) + return Strings("%s used BIND on %s!", self:N(f.atk), self:N(f.def)) + end, + STRINGID_PKMNWRAPPEDBY = function(self, f) + return Strings("%s was WRAPPED by %s!", self:N(f.def), self:N(f.atk)) + end, + STRINGID_PKMNCLAMPED = function(self, f) + return Strings("%s was CLAMPED by %s!", self:N(f.def), self:N(f.atk)) + end, + STRINGID_PKMNTRAPPEDINVORTEX = role("%s was trapped!", "def"), + STRINGID_PKMNSTORINGENERGY = role("%s is storing energy!", "atk"), + STRINGID_PKMNSUBSTITUTEFADED = role("%s's SUBSTITUTE broke!", "def"), + STRINGID_SUBSTITUTEDAMAGED = role("The SUBSTITUTE took damage for %s!", "def"), + STRINGID_PKMNSXWOREOFF = function(self, f) + local mv = moveOf(f.buff1) + local seat = f.atk == "enemy" and 1 or 0 + local label = Strings(Battle.SCREEN_SIDE_LABEL[self:sideName(seat)]) + if mv == 113 then return Strings(Battle.SCREEN_FALL_TEXT.lightScreen, label) end + if mv == 115 then return Strings(Battle.SCREEN_FALL_TEXT.reflect, label) end + return Strings(T.woreOff, label, self:moveName(mv)) + end, + STRINGID_PKMNTOOKAIM = role("%s took aim!", "atk"), + STRINGID_PKMNTOOKATTACK = function(self, f) + return Strings("%s took the %s attack!", self:N(f.def), self:moveName(moveOf(f.buff1))) + end, + STRINGID_PKMNTOOKFOE = role("%s took its foe down with it!", "atk"), + STRINGID_PKMNTRYINGTOTAKEFOE = role("%s is trying to take its foe with it!", "atk"), + STRINGID_PKMNTOOKSUNLIGHT = role("%s took in sunlight!", "atk"), + STRINGID_PKMNUNLEASHEDENERGY = role("%s unleashed energy!", "atk"), + STRINGID_PKMNWASDEFROSTED = role("%s thawed out!", "def"), + STRINGID_PKMNWASDEFROSTED2 = role("%s thawed out!", "atk"), + STRINGID_PKMNWASDRAGGEDOUT = role("%s was dragged out!", "def"), + STRINGID_PKMNWHIPPEDWHIRLWIND = role("%s made a whirlwind!", "atk"), + STRINGID_PKMNWOKEUP = role("%s woke up!", "atk"), + STRINGID_PKMNBEGANTONAP = role("%s began to nap!", "atk"), + STRINGID_PKMNHASNOMOVESLEFT = role("%s has no moves left!", "atk"), + STRINGID_PKMNAFFLICTEDBYCURSE = role("%s's hurt by the CURSE!", "atk"), + STRINGID_PKMNLAIDCURSE = function(self, f) + return Strings("%s cut its own HP and put a CURSE on %s!", self:N(f.atk), self:N(f.def)) + end, + STRINGID_PKMNFELLINTONIGHTMARE = role("%s\nstarted to have a\vNIGHTMARE!", "def"), + STRINGID_PKMNLOCKEDINNIGHTMARE = role("%s\nhas a NIGHTMARE!", "atk"), + STRINGID_PKMNBLEWAWAYSPIKES = role("%s blew away SPIKES!", "atk"), + STRINGID_PKMNSHEDLEECHSEED = role("%s was freed from LEECH SEED!", "atk"), + STRINGID_TARGETCANTESCAPENOW = role("%s can't escape now!", "def"), + STRINGID_SHAREDPAIN = plain("The battlers shared\ntheir pain!"), + STRINGID_SPIKESSCATTERED = plain("Spikes were scattered all around!"), + STRINGID_STATCHANGESGONE = plain("All stat changes\nwere eliminated!"), + STRINGID_NOPPLEFT = plain("There's no PP left\nfor this move!"), + STRINGID_BUTNOPPLEFT = plain("But there was no\nPP left!"), + STRINGID_STARTEDTORAIN = weatherText(Effects.WEATHER_START_TEXT, "rain"), + STRINGID_SUNLIGHTGOTBRIGHT = weatherText(Effects.WEATHER_START_TEXT, "sun"), + STRINGID_SANDSTORMBREWED = weatherText(Effects.WEATHER_START_TEXT, "sandstorm"), + STRINGID_RAINCONTINUES = weatherText(Effects.WEATHER_TURN_TEXT, "rain"), + STRINGID_SUNLIGHTSTRONG = weatherText(Effects.WEATHER_TURN_TEXT, "sun"), + STRINGID_SANDSTORMRAGES = weatherText(Effects.WEATHER_TURN_TEXT, "sandstorm"), + STRINGID_SANDSTORMISRAGING = weatherText(Effects.WEATHER_TURN_TEXT, "sandstorm"), + STRINGID_ITISRAINING = weatherText(Effects.WEATHER_TURN_TEXT, "rain"), + STRINGID_RAINSTOPPED = weatherText(Effects.WEATHER_END_TEXT, "rain"), + STRINGID_SUNLIGHTFADED = weatherText(Effects.WEATHER_END_TEXT, "sun"), + STRINGID_SANDSTORMSUBSIDED = weatherText(Effects.WEATHER_END_TEXT, "sandstorm"), +} +Gen2Facade.MSG = MSG + +local SILENT = { STRINGID_SWITCHINMON = true, STRINGID_USEDMOVE = true, STRINGID_INTROMSG = true, + STRINGID_INTROSENDOUT = true, STRINGID_RETURNMON = true, STRINGID_BATTLEEND = true, + STRINGID_EMPTYSTRING4 = true } +Gen2Facade.SILENT = SILENT + +function Facade:msgText(id, fill) + local fn = MSG[id] + if not fn then return nil end + local ok, text = pcall(fn, self, fill or {}) + if ok then return text end + return nil +end + +function Facade:effectivenessAfter(evs, i) + for j = i + 1, #evs do + local e = evs[j] + if e.kind == "hp" or e.kind == "move" or e.kind == "faint" then return 10 end + if e.kind == "msg" then + if e.id == "STRINGID_SUPEREFFECTIVE" then return 20 end + if e.id == "STRINGID_NOTVERYEFFECTIVE" then return 5 end + if e.id == "STRINGID_USEDMOVE" then return 10 end + end + end + return 10 +end + +function Facade:animEvent(seat, out, fields) + local side = self:sideName(seat) + local v = self:active(seat) + local ev = { kind = "damage", side = side, amount = 0, hp = v and v.hp or 0, anim = false } + for k, x in pairs(fields) do ev[k] = x end + out[#out + 1] = ev +end + +function Facade:revert(v) + if v and v.transformedFrom then + v.species = v.transformedFrom + v.transformedFrom = nil + end + if v then self.vol[v] = nil end +end + +function Facade:endText(why, outcome, out) + local foe = self.foeName + if why == "faint" then + if outcome == "win" then + out[#out + 1] = { kind = "message", text = Strings(T.defeated, foe) } + out[#out + 1] = { kind = "trainer-return" } + elseif outcome == "lose" then + out[#out + 1] = { kind = "message", text = Strings(T.noMore) } + else + out[#out + 1] = { kind = "message", text = Strings(T.draw) } + end + elseif why == "forfeit" then + if outcome == "win" then + out[#out + 1] = { kind = "message", text = Strings(T.foeForfeit, foe) } + elseif outcome == "lose" then + out[#out + 1] = { kind = "message", text = Strings(T.meForfeit) } + else + out[#out + 1] = { kind = "message", text = Strings(T.draw) } + end + elseif why == "desync" then + out[#out + 1] = { kind = "message", text = Strings(T.desync) } + elseif why == "disconnect" then + out[#out + 1] = { kind = "message", text = Strings(T.left, foe) } + elseif why == "illegal" then + out[#out + 1] = { kind = "message", text = Strings(T.illegal) } + else + out[#out + 1] = { kind = "message", text = Strings(T.broken) } + end +end + +function Facade:finishWith(outcome) + self.over = true + self.outcome = outcome + self.awaiting = nil +end + +function Facade:reconcile() + local v = self.player + if not v then return end + local live = self:liveMon(self.seat) + if not (live and type(live.moves) == "table") then return end + local same = true + local count = 0 + for i = 1, 4 do + local id = tonumber(live.moves[i]) + if id and id ~= 0 then + count = count + 1 + if not v.engineSlots[id] then same = false end + end + end + if count ~= #v.moves then same = false end + if not same then + local old = {} + for _, mv in ipairs(v.moves) do old[mv.national] = mv end + local moves, slots = {}, {} + for i = 1, 4 do + local id = tonumber(live.moves[i]) + if id and id ~= 0 then + local prev = old[id] + moves[#moves + 1] = { id = self:moveKey(id) or id, national = id, + pp = tonumber(live.pp and live.pp[i]) or 0, + maxPp = prev and prev.maxPp or tonumber(live.pp and live.pp[i]) or 0, ppUps = prev and prev.ppUps or 0 } + slots[id] = i + end + end + v.moves, v.engineSlots = moves, slots + return + end + for i = 1, 4 do + local id = tonumber(live.moves[i]) + if id and id ~= 0 then + v.engineSlots[id] = i + for _, mv in ipairs(v.moves) do + if mv.national == id then mv.pp = tonumber(live.pp and live.pp[i]) or mv.pp end + end + end + end +end + +function Facade:liveMon(seat) + local m = self.bs and self.bs.match + local st = m and m.st + if type(st) ~= "table" then return nil end + local b = st.battlers and st.battlers[seat] or (seat == 0 and st.player or st.enemy) + return type(b) == "table" and b.mon or nil +end + +function Facade:translate(evs) + local out = {} + local fainted = {} + local lastUser, hitSince = nil, false + local i = 1 + while i <= #evs do + local ev = evs[i] + local k = ev.kind + if k == "ready" then + self:onReady(ev) + elseif k == "sendout" then + local v = self:setActive(ev.side, ev.index) + if ev.reason ~= "start" and v then + local side = self:sideName(ev.side) + local text + if ev.reason ~= "roar" then + if side == "player" then + text = Strings(T.go, self:nameOf(v)) + else + text = Battle.sentOutText(self.foeName, self:nameOf(v)) + end + end + out[#out + 1] = { kind = "send", side = side, mon = v, hp = v.hp or 0, status = v.status or false, + level = v.level, text = text } + end + elseif k == "withdraw" then + local v = self.views[ev.side] and self.views[ev.side][ev.index] + if ev.reason ~= "start" and v then + self:revert(v) + if self:sideName(ev.side) == "enemy" and ev.reason == "switch" and (v.hp or 0) > 0 then + out[#out + 1] = { kind = "message", text = Strings(T.withdrew, self.foeName, self:nameOf(v)) } + end + end + elseif k == "move" then + lastUser, hitSince = ev.user, false + out[#out + 1] = { kind = "move", side = self:sideName(ev.user), move = self:moveKey(ev.moveId) or ev.moveId } + elseif k == "hp" then + local v = self:active(ev.side) + local to = tonumber(ev.to) or 0 + local from = tonumber(ev.from) or (v and v.hp) or to + if v then v.hp = to end + local side = self:sideName(ev.side) + if to < from then + local e = { kind = "damage", side = side, amount = from - to, hp = to } + if ev.hit then + hitSince = true + e.effectiveness = self:effectivenessAfter(evs, i) + else + e.anim = false + end + out[#out + 1] = e + elseif to > from then + out[#out + 1] = { kind = "heal", side = side, amount = to - from, hp = to } + end + elseif k == "status" then + local v = self:active(ev.side) + local s = Gen2Facade.STATUS[ev.status] + if v then v.status = s end + out[#out + 1] = { kind = "status", side = self:sideName(ev.side), status = s or false } + elseif k == "stage" then + local delta = tonumber(ev.delta) or 0 + if delta < 0 and not ev.sync and lastUser ~= nil and lastUser ~= ev.side and not hitSince then + if self:sideName(ev.side) == "enemy" then + self:animEvent(ev.side, out, { anim = "ANIM_ENEMY_STAT_DOWN", animSide = "player" }) + else + self:animEvent(ev.side, out, { anim = "ANIM_WOBBLE", animSide = "enemy" }) + end + end + elseif k == "anim" then + local seat = ev.user ~= nil and ev.user or ev.target + local group = Gen2Facade.ANIMS[ev.anim] + local id = group and group[ev.name] + local moveAnim = Gen2Facade.MOVE_ANIMS[ev.name] + if seat ~= nil and id then + if id == "ANIM_SAP" then + self:animEvent(seat, out, { anim = id }) + else + self:animEvent(seat, out, { anim = id, animSide = self:sideName(seat) }) + end + elseif seat ~= nil and moveAnim then + local mv = moveAnim == "arg" and tonumber(ev.arg) or moveAnim + local key = mv and self:moveKey(mv) + if key then + local target = ev.name == "FUTURE_SIGHT_HIT" and (ev.target or seat) or seat + self:animEvent(target, out, { animMove = key }) + end + end + elseif k == "faint" then + local v = self:active(ev.side) + if v then v.hp = 0 end + fainted[ev.side] = true + out[#out + 1] = { kind = "faint", side = self:sideName(ev.side), text = Strings(T.fainted, self:nameOf(v)) } + elseif k == "weather" then + self.weather = Gen2Facade.WEATHER[tostring(ev.weather or ""):upper()] + elseif k == "msg" then + local f = ev.fill or {} + if ev.id == "STRINGID_USEDMOVE" then + local atk = type(f.atk) == "table" and f.atk or {} + local mv = moveOf(f.currentMove) + local nxt = evs[i + 1] + local e = { kind = "move", side = self:sideName(atk.side), move = self:moveKey(mv) or mv, + text = Strings(T.usedMove, self:N(f.atk), self:moveName(mv)) } + lastUser, hitSince = atk.side, false + if nxt and nxt.kind == "move" and nxt.user == atk.side then + e.move = self:moveKey(nxt.moveId) or nxt.moveId + i = i + 1 + else + e.missed = true + end + out[#out + 1] = e + elseif ev.id == "STRINGID_TARGETFAINTED" or ev.id == "STRINGID_ATTACKERFAINTED" then + local r = ev.id == "STRINGID_TARGETFAINTED" and f.def or f.atk + if not (type(r) == "table" and fainted[r.side]) then + out[#out + 1] = { kind = "message", text = Strings(T.fainted, self:N(r)) } + end + elseif ev.id == "STRINGID_PKMNTRANSFORMEDINTO" then + local v = self:ref(f.atk) + local sp = type(f.buff1) == "table" and f.buff1.species + local key = sp and self:speciesKey(sp) + if v and key then + local from = v.transformedFrom or v.species + v.transformedFrom = from + v.species = key + self:volatile(v).transformed = true + out[#out + 1] = { kind = "transform", side = self:sideName(f.atk.side), mon = v, from = from } + end + out[#out + 1] = { kind = "message", + text = Strings("%s TRANSFORMED into %s!", self:N(f.atk), sp and self:speciesName(sp) or "?") } + elseif not SILENT[ev.id] then + local text = self:msgText(ev.id, f) + if text then out[#out + 1] = { kind = "message", text = text } end + end + elseif k == "end" then + local r = ev.result or {} + local outcome = r.draw and "draw" or (r.winner == self.seat and "win" or "lose") + self.ended = true + self:finishWith(outcome) + self:endText(r.why, outcome, out) + elseif k == "over" then + self.result = { outcome = ev.outcome, why = ev.why, detail = ev.detail } + if not self.ended then + self.ended = true + self:finishWith(ev.outcome) + self:endText(ev.why, ev.outcome, out) + end + elseif k == "prompt" then + if ev.what == "move" then + self.awaiting = "move" + self:reconcile() + elseif ev.what == "replace" then + self.awaiting = "replace" + out[#out + 1] = { kind = "choose-switch" } + end + end + i = i + 1 + end + for _, e in ipairs(out) do self.events[#self.events + 1] = e end + return out +end + +function Facade:takeEvents() + local out = self.events + self.events = {} + return out +end + +function Facade:takeTurn() + return {} +end + +function Facade:volatile(mon) + if mon == nil then return {} end + local v = self.vol[mon] + if not v then + v = {} + self.vol[mon] = v + end + return v +end + +function Facade:clearAllVolatiles() + for seat = 0, 1 do + for _, v in ipairs(self.views[seat] or {}) do self:revert(v) end + end +end + +function Facade:legal() + if not self.bs then return {} end + return self.bs:legal() +end + +function Facade:slotOf(moveKey) + local v = self.player + if not v then return nil end + if moveKey == Battle.STRUGGLE then return 0 end + for _, mv in ipairs(v.moves) do + if mv.id == moveKey then return v.engineSlots[mv.national] end + end + return nil +end + +function Facade:slotMove(slot) + if not slot or slot == 0 then return Battle.STRUGGLE end + local v = self.player + for id, s in pairs(v and v.engineSlots or {}) do + if s == slot then return self:moveKey(id) or id end + end + return Battle.STRUGGLE +end + +function Facade:lockedAction() + if self.awaiting ~= "move" then return nil end + for _, a in ipairs(self:legal()) do + if a.kind == "move" and a.locked then return a end + end + return nil +end + +function Facade:lockedInMove(mon) + if mon ~= self.player then return nil end + local a = self:lockedAction() + return a and self:slotMove(a.slot) or nil +end + +function Facade:hasUsableMoves(mon) + if mon ~= self.player then return true end + for _, a in ipairs(self:legal()) do + if a.kind == "move" and (a.locked or (a.slot or 0) >= 1) then return true end + end + return false +end + +function Facade:moveDisabled(mon, moveKey) + if mon ~= self.player then return false end + local slot = self:slotOf(moveKey) + if not slot then return true end + for _, a in ipairs(self:legal()) do + if a.kind == "move" and a.slot == slot and not a.locked then return false end + end + return true +end + +function Facade:switchLocked() + local bench = false + for i, v in ipairs(self.party) do + if i ~= self.activeIndex[self.seat] and (v.hp or 0) > 0 then bench = true end + end + if not bench then return false end + for _, a in ipairs(self:legal()) do + if a.kind == "switch" then return false end + end + return true +end + +function Facade:switch() return false end +function Facade:shiftSwitch() return false end +function Facade:forcedReplacement() return false end +function Facade:tryRun() return false end +function Facade:useBattleItem() return false end +function Facade:resolveForget() end +function Facade:declineForget() end + +function Facade:partyMoves(mon) + return (mon and mon.moves) or {} +end + +function Facade:endBattle(outcome) + self:finishWith(outcome) +end + +function Facade:actionFor(action) + local legal = self:legal() + local function find(pred) + for _, a in ipairs(legal) do + if pred(a) then return { kind = a.kind, slot = a.slot, index = a.index } end + end + return nil + end + if action.kind == "switch" then + return find(function(a) return a.kind == "switch" and a.index == action.index end) + end + if action.kind ~= "move" then return nil end + local locked = find(function(a) return a.kind == "move" and a.locked end) + if locked then return locked end + local slot = self:slotOf(action.move) + if slot == nil then return nil end + return find(function(a) return a.kind == "move" and a.slot == slot end) +end + +function Facade:wait(s) + s.phase = "link-wait" + s.message = Strings(T.waiting) + s.typedText = nil + s.messageTimer = 0 +end + +function Facade:hooks() + local facade = self + local hooks = {} + hooks.submit = function(s, action) + if facade.over then return end + if action.kind == "item" then return s:refuseMenu(T.noItems) end + if action.kind == "run" then return hooks.menuChoice(s, "run") end + local act = facade.awaiting == "move" and facade:actionFor(action) or nil + if not act or not facade.bs:choose(act) then return s:refuseMenu(T.cantNow) end + facade.awaiting = nil + facade.runArmed = nil + facade:wait(s) + end + hooks.menuChoice = function(s, choice) + if choice == "item" then + facade.runArmed = nil + s:refuseMenu(T.noItems) + return true + end + if choice == "run" then + if facade.runArmed and facade.awaiting == "move" then + facade.runArmed = nil + if facade.bs:choose({ kind = "forfeit" }) then + facade.awaiting = nil + facade:wait(s) + end + return true + end + facade.runArmed = true + s:refuseMenu(T.runAgain) + return true + end + facade.runArmed = nil + return false + end + hooks.forcedSwitch = function(s, index) + if facade.awaiting ~= "replace" or not facade.bs:pickReplacement(index) then + return s:refuseSwitch(true) + end + facade.awaiting = nil + facade:wait(s) + return true + end + return hooks +end + +function Facade:fakeSave() + local save = self.game and self.game.save + local player = (save and save.player) or {} + return { + party = self.party, + player = { name = player.name or self.myName, gender = player.gender }, + inventory = {}, pokedex = { seen = {}, caught = {} }, options = {}, modData = {}, + } +end + +local Host = {} +Host.__index = Host +Gen2Facade.Host = Host + +function Gen2Facade.banner() + return { + { kind = "message", text = Strings(T.rules1) }, + { kind = "message", text = Strings(T.rules2) }, + } +end + +function Host:wrap(state) + if state == nil or state == self or self.wrapped[state] or type(state.update) ~= "function" then return end + local host = self + local prev = rawget(state, "update") + local base = state.update + self.wrapped[state] = { prev = prev } + state.update = function(st, dt) + if not host.finished then host:pump() end + local r = base(st, dt) + if not host.finished then host:wrapTop() end + return r + end +end + +function Host:wrapTop() + local stack = self.game and self.game.stack + if stack then self:wrap(stack:top()) end +end + +function Host:unwrap() + for state, w in pairs(self.wrapped) do rawset(state, "update", w.prev) end + self.wrapped = {} +end + +function Host:kick() + local s = self.screen + if not s or self.screenDone then return end + local stack = self.game.stack + local facade = self.facade + if facade.over and stack:top() ~= s then + local seen = false + for _, st in ipairs(stack.states or {}) do + if st == s then seen = true end + end + if seen then + while stack:top() ~= s do stack:pop() end + end + end + if stack:top() ~= s then return end + if IDLE[s.phase] and #s.queue > 0 then + s.phase = "resolving" + s.message = nil + s.messageTimer = 0 + s:advanceQueue() + end +end + +function Host:pump() + local bs, facade = self.bs, self.facade + if not bs.result then bs:update() end + local evs = bs:events() + if #evs > 0 then facade:translate(evs) end + if bs.result and not facade.result then facade.result = bs.result end + if self.screen then + local pending = facade:takeEvents() + if #pending > 0 then self.screen:pushAll(pending) end + self:kick() + end +end + +function Host:world() + return self.game and self.game.world +end + +function Host:buildScreen() + local BattleState = require("src.ui.gen2.BattleState") + local game, facade = self.game, self.facade + local host = self + local screen + screen = BattleState.new(game, { + battle = facade, + save = facade:fakeSave(), + link = facade:hooks(), + music = { class = facade.trainer.classId }, + onDone = function() + host.screenDone = true + if game.stack:top() == screen then game.stack:pop() end + local world = host:world() + if world then + world.battleActive = nil + if type(world.battleReturnFade) == "function" then world:battleReturnFade() end + if type(world.restoreMapMusic) == "function" then world:restoreMapMusic() end + end + host.stage = "closing" + end, + }) + screen.kind = "link" + screen.g3u = true + local banner = Gen2Facade.banner() + for j = #banner, 1, -1 do table.insert(screen.queue, 1, banner[j]) end + local baseAdvance = screen.advanceQueue + screen.advanceQueue = function(s) + local r = baseAdvance(s) + if (s.phase == "menu" or s.phase == "locked-in") and facade.awaiting ~= "move" and not facade.over then + facade:wait(s) + end + return r + end + local pending = facade:takeEvents() + if #pending > 0 then screen:pushAll(pending) end + self.screen = screen + return screen +end + +function Host:openBattle() + local game = self.game + local world = self:world() + local screen = self:buildScreen() + self.stage = "battle" + if world and type(world.playBattleMusic) == "function" then + world:playBattleMusic({ trainer = { classId = self.facade.trainer.classId } }) + end + local function push() + if world then world.battleActive = true end + game.stack:push(screen) + end + local pushed = false + if world and type(world.pushBattleTransition) == "function" then + pushed = world:pushBattleTransition(self.facade, { trainer = true }, push) + end + if not pushed then push() end +end + +function Host:finish() + if self.finished then return end + self.finished = true + self:unwrap() + local stack = self.game.stack + if stack:top() == self then stack:pop() end + local result = self.bs.result + local onDone = self.onDone + self.onDone = nil + if onDone then onDone(result) end +end + +function Host:update() + if self.finished then return end + self:pump() + self:step() + if not self.finished then self:wrapTop() end +end + +function Host:step() + local facade, bs = self.facade, self.bs + if self.stage == "setup" then + if facade.ready and (facade.awaiting or facade.over) then return self:openBattle() end + if bs.result then + self.stage = "failed" + self.frames = 0 + local out = {} + facade:endText(bs.result.why, bs.result.outcome, out) + self.failText = out[1] and out[1].text or Strings(T.broken) + end + return + end + if self.stage == "failed" then + self.frames = self.frames + 1 + local input = self.game.input + local pressed = input and input.wasPressed and (input:wasPressed("a") or input:wasPressed("b")) + if pressed or self.frames >= Gen2Facade.FAIL_HOLD_FRAMES then self:finish() end + return + end + if self.stage == "closing" and bs.result then self:finish() end +end + +function Host:text() + if self.stage == "failed" then return self.failText end + if self.stage == "setup" then return Strings(T.waitingFor, self.facade.foeName) end + return nil +end + +function Host:draw() + local text = self:text() + if not text then return end + local Chrome = require("src.ui.gen2.Chrome") + Chrome.textbox(0, 12, 18, 4) + Chrome.printWrapped(text, 1, 13, 18, 4) +end + +function Gen2Facade.start(game, bs, opts) + opts = opts or {} + if type(game) ~= "table" or type(game.stack) ~= "table" then return nil, "no_stack" end + if type(bs) ~= "table" then return nil, "no_session" end + local facade = Gen2Facade.new(game, bs, opts) + local host = setmetatable({ + game = game, bs = bs, facade = facade, stage = "setup", frames = 0, onDone = opts.onDone, wrapped = {}, + }, Host) + game.stack:push(host) + return host +end + +return Gen2Facade diff --git a/src/ui/g3u/Gen3Presenter.lua b/src/ui/g3u/Gen3Presenter.lua new file mode 100644 index 00000000..173cfbc5 --- /dev/null +++ b/src/ui/g3u/Gen3Presenter.lua @@ -0,0 +1,1057 @@ +local M = {} + +M.LAYER = "g3u_battle" +M.RULES_TEXT = "Union rules: Gen 3 battle mechanics. Abilities and held items are off." +M.NATIVE_WAIT = 60 + +local S = function(text, ...) return require("src.core.Strings")(text, ...) end + +local END_TEXT = { + desync = "The battle fell out of sync.\nIt ended in a draw.", + disconnect = "The link with the other player\nwas lost.", + illegal = "The other player sent something\nthat can't be used. It's a draw.", + bad_table = "The battle rules couldn't be agreed.", + bad_party = "A team couldn't be used in\nthis battle.", + error = "The battle couldn't continue.", +} + +local ENGINE_SEAT = { player = 0, enemy = 1 } +local CONTROL = { ready = true, prompt = true, wait = true, over = true } +local BATTLER_KEYS = { + atk = true, def = true, eff = true, active = true, scrActive = true, atkMon1 = true, atkPartner = true, + scrActivePartyMon = true, playerMon1 = true, playerMon2 = true, opponentMon1 = true, opponentMon2 = true, + linkPlayerMon1 = true, linkPlayerMon2 = true, linkOpponentMon1 = true, linkOpponentMon2 = true, +} +local BUFF_KEYS = { buff1 = true, buff2 = true, buff3 = true } +local STAT_KEYS = { attack = true, defense = true, speed = true, spAtk = true, spDef = true, accuracy = true, + evasion = true } +local SWITCH_TEXT_IDS = { STRINGID_SWITCHINMON = true, STRINGID_PKMNWASDRAGGEDOUT = true } +local EFFECTIVENESS = { STRINGID_SUPEREFFECTIVE = 2, STRINGID_NOTVERYEFFECTIVE = 0.5, STRINGID_ITDOESNTAFFECT = 0 } +local GENDER = { [0] = "M", [1] = "F", [2] = "U" } +local INPUT_PHASES = { menu = true, moves = true, party = true, confirm = true } + +function M.sideOf(seat, mySeat) + if seat == nil then return nil end + return seat == mySeat and "player" or "enemy" +end + +function M.idOf(seat, mySeat) + if seat == nil then return nil end + return seat == mySeat and 0 or 1 +end + +function M.map(ev, mySeat) + local k = ev and ev.kind + local function side(s) return M.sideOf(s, mySeat) end + local function id(s) return M.idOf(s, mySeat) end + if k == "msg" then return { act = "text", id = ev.id, fill = ev.fill or {} } end + if k == "move" then + return { act = "anim", ev = { kind = "move", moveId = ev.moveId, attacker = side(ev.user), target = side(ev.target), + attackerId = id(ev.user), targetId = id(ev.target), turn = ev.turn or 0 } } + end + if k == "hp" then + return { act = "hp", seat = ev.side, to = ev.to, max = ev.max, + ev = { kind = ev.hit and "hit" or "hp", side = side(ev.side), battler = id(ev.side), from = ev.from, to = ev.to, + maxHp = ev.max } } + end + if k == "status" then + local none = ev.status == nil or ev.status == "NONE" + return { act = "status", seat = ev.side, status = (not none) and ev.status or nil, + ev = { kind = none and "status_clear" or "status_apply", side = side(ev.side), battler = id(ev.side), + status = (not none) and ev.status or nil } } + end + if k == "stage" then return { act = "stage", seat = ev.side, stat = ev.stat, delta = ev.delta or 0 } end + if k == "faint" then + return { act = "faint", seat = ev.side, ev = { kind = "faint", side = side(ev.side), battler = id(ev.side) } } + end + if k == "withdraw" then return { act = "withdraw", seat = ev.side, index = ev.index, reason = ev.reason } end + if k == "sendout" then return { act = "sendout", seat = ev.side, index = ev.index, reason = ev.reason } end + if k == "weather" then return { act = "weather", weather = ev.weather, turns = ev.turns } end + if k == "anim" then + return { act = "anim", ev = { kind = "anim", anim = ev.anim, name = ev.name, attacker = side(ev.user), + target = side(ev.target), attackerId = id(ev.user), targetId = id(ev.target), arg = ev.arg } } + end + if k == "end" then return { act = "end", result = ev.result } end + if k == "ready" then return { act = "ready" } end + if k == "prompt" then return { act = "prompt", what = ev.what, reason = ev.reason, turn = ev.turn } end + if k == "waiting" then return { act = "wait", what = ev.what } end + if k == "over" then return { act = "over", outcome = ev.outcome, why = ev.why, detail = ev.detail } end + return { act = "none" } +end + +function M.menu(legal) + local out = { moves = {}, switches = {}, struggle = nil, locked = nil, forfeit = nil, count = 0 } + for _, a in ipairs(legal or {}) do + if a.kind == "move" then + if a.locked then out.locked = a + elseif a.slot == 0 then out.struggle = a + else out.moves[a.slot] = a end + elseif a.kind == "switch" then + out.switches[a.index] = a + out.count = out.count + 1 + elseif a.kind == "forfeit" then + out.forfeit = a + end + end + return out +end + +local function resolveValue(k, v, mySeat, R, depth) + local t = type(v) + if t == "table" then + if v.side ~= nil and (v.index ~= nil or BATTLER_KEYS[k]) and type(v.side) == "number" then + return { side = M.sideOf(v.side, mySeat), name = R.mon(v.side, v.index) } + end + if v.move ~= nil then return R.move(v.move) end + if v.species ~= nil then return R.species(v.species) end + if v.ability ~= nil then return R.ability(v.ability) end + if v.type ~= nil then return R.type(v.type) end + if depth > 2 then return nil end + local out = {} + for kk, vv in pairs(v) do out[kk] = resolveValue(kk, vv, mySeat, R, depth + 1) end + return out + end + if t == "string" then + if (BATTLER_KEYS[k] or k == "side") and ENGINE_SEAT[v] ~= nil then return M.sideOf(ENGINE_SEAT[v], mySeat) end + if BUFF_KEYS[k] and STAT_KEYS[v] then return R.stat(v) end + return v + end + if t == "number" and k == "side" then return M.sideOf(v, mySeat) end + return v +end + +function M.resolveFill(fill, mySeat, R) + local out = {} + for k, v in pairs(fill or {}) do + if k ~= "stat" and k ~= "delta" then out[k] = resolveValue(k, v, mySeat, R, 0) end + end + if fill and fill.stat ~= nil and fill.delta ~= nil then + out.buff1 = R.stat(fill.stat) + out.buff2 = R.change(fill.delta) + end + return out +end + +local Script = {} +Script.__index = Script + +function M.newScript(mySeat) + return setmetatable({ seat = mySeat, queue = {}, qi = 1 }, Script) +end + +function Script:push(events) + for _, ev in ipairs(events or {}) do self.queue[#self.queue + 1] = ev end +end + +function Script:pending() + return self.qi <= #self.queue +end + +function Script:next() + local ops, sent = {}, {} + while self.qi <= #self.queue do + local ev = self.queue[self.qi] + local a = M.map(ev, self.seat) + if CONTROL[a.act] then + if #ops > 0 then return { kind = "segment", ops = ops } end + self.qi = self.qi + 1 + return { kind = a.act, action = a, ev = ev } + end + if a.act == "sendout" then + if sent[a.seat] then return { kind = "segment", ops = ops } end + sent[a.seat] = true + end + if a.act ~= "none" then ops[#ops + 1] = a end + self.qi = self.qi + 1 + end + if #ops > 0 then return { kind = "segment", ops = ops } end + return nil +end + +M.Script = Script + +local function lazy(name) return package.loaded[name] or require(name) end + +local function once(fn) + local done = false + return function(...) + if done then return end + done = true + if fn then return fn(...) end + end +end + +local function nameOf(mon) + if not mon then return "" end + if type(mon.nickname) == "string" and mon.nickname ~= "" then return mon.nickname end + return lazy("src.core.game3.pokemon").name(mon.species) +end + +local function dispMon(rec, t) + local Pokemon = lazy("src.core.game3.pokemon") + local Policy = lazy("src.online.xgen.Policy") + local moves, pp, maxPp, ups, types = {}, {}, {}, {}, {} + for i, mv in ipairs(rec.moves or {}) do + moves[i], pp[i], ups[i] = mv.id, mv.pp, mv.ppUps or 0 + local row = t and t.moves and t.moves[mv.id] + maxPp[i] = row and math.min(64, Policy.maxPp(row[4], ups[i])) or mv.pp + types[i] = row and row[2] + end + local mon = { + species = Pokemon.speciesFromNational(rec.species) or rec.species, level = rec.level, hp = rec.hp, + maxHp = rec.maxHp, attack = rec.atk, defense = rec.def, spAtk = rec.spAtk, spDef = rec.spDef, + speed = rec.speed, moves = moves, pp = pp, maxPp = maxPp, ppUps = ups, moveTypes = types, + nickname = rec.nickname, gender = GENDER[rec.gender] or "U", personality = rec.gender == 1 and 0 or 255, isShiny = false, otId = 0, otSecretId = 0, + friendship = rec.friendship or 0, ability = 0, abilityId = 0, item = 0, heldItem = 0, + ivs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, + evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, + } + lazy("src.core.game3.battle.experience").syncExpToLevel(mon) + return mon +end + +local function trainerPic(gender) + local okC, C = pcall(function() return lazy("src.core.game3.constants").active() end) + if not okC or not C then return nil end + local function id(name) + local ok, v = pcall(C.id, C, "trainer_classes", name) + return ok and v or nil + end + if gender == 1 then return id("TRAINER_PIC_LEAF") or id("TRAINER_PIC_MAY") end + return id("TRAINER_PIC_RED") or id("TRAINER_PIC_BRENDAN") +end + +local function playerGender(session) + local g = session and session.gender + if g == 1 or g == "female" or g == "F" then return 1 end + return 0 +end + +local function battleSong() + local ok, song = pcall(function() + local LB = lazy("src.core.game3.link.battle") + local songs = lazy("src.core.game3.link.family").linkBattleSongs() + return LB[songs.trainer] + end) + return ok and song or nil +end + +-- pokefirered/src/battle_message.c:1281 +local function standbyText() + local RomText = lazy("src.core.game3.rom_text") + for _, key in ipairs({ "gText_LinkStandby", "BattleText_LinkStandby" }) do + if RomText.has(key) then return RomText.plain(key) end + end + return nil +end + +local function playSe(id) + pcall(function() lazy("src.core.game3.audio").playSe(lazy("src.core.game3.se_ids")[id]) end) +end + +local Run = {} +Run.__index = Run + +function Run:R() + if self._R then return self._R end + local Pokemon = lazy("src.core.game3.pokemon") + local Types = lazy("src.core.game3.battle.types") + local Secondary = lazy("src.core.game3.battle.effects.secondary") + local RomText = lazy("src.core.game3.rom_text") + self._R = { + mon = function(seat, index) + local party = self.disp[seat] or {} + local i = index or self.active[seat] or 1 + return nameOf(party[i]) + end, + move = function(n) return Pokemon.moveName(n) end, + species = function(n) return Pokemon.name(Pokemon.speciesFromNational(n) or n) end, + ability = function(n) return Pokemon.abilityName(n) or "" end, + type = function(n) return Types.name(n) end, + stat = function(k) return Secondary.statName(k) end, + change = function(delta) + if delta >= 2 then return Secondary.sharpChange("STRINGID_STATSHARPLY", "STRINGID_STATROSE") end + if delta >= 1 then return RomText.plain("STRINGID_STATROSE") end + if delta <= -2 then return Secondary.sharpChange("STRINGID_STATHARSHLY", "STRINGID_STATFELL") end + return RomText.plain("STRINGID_STATFELL") + end, + } + return self._R +end + +function Run:text(id, fill) + local BattleText = lazy("src.core.game3.battle.battle_text") + local Adapter = lazy("src.core.game3.battle.adapter") + local resolved = M.resolveFill(fill, self.bs.seat, self:R()) + local ok, out = pcall(BattleText.get, id, Adapter.fill(self.pst, resolved)) + if ok then return out end + print("[g3u/gen3] no text for " .. tostring(id) .. ": " .. tostring(out)) + return nil +end + +function Run:pushMsg(text, wait, id) + local Ui = lazy("src.core.game3.battle.ui") + local AnimSeq = lazy("src.core.game3.battle.anim_seq") + Ui.pushTimed(text, tonumber(wait) or (AnimSeq.isMoveUsedId(id) and 0 or 64)) +end + +function Run:standby() + local Ui = lazy("src.core.game3.battle.ui") + local Message = lazy("src.ui.game3.message") + local text = standbyText() + Ui._mode = "none" + if not text then return end + Ui._timed = nil + Ui._showing = false + Ui._linger = true + Message.show(text, { frame = "battle", battle = true, stay = true }) +end + +function Run:battlerFor(seat) + local State = lazy("src.core.game3.battle.state") + local id = M.idOf(seat, self.bs.seat) + return State.battler(self.pst, id), id +end + +function Run:setActive(seat, index) + local State = lazy("src.core.game3.battle.state") + local id = M.idOf(seat, self.bs.seat) + local party = self.disp[seat] + local b = State.makeBattler(party[index], State.sideOf(id), { partyIndex = index, id = id }) + if id == 0 then self.pst.player = b else self.pst.enemy = b end + if self.pst.battlers then self.pst.battlers[id] = b end + self.active[seat] = index + return b +end + +function Run:apply(ops) + local SwitchSeq = lazy("src.core.game3.battle.switch_seq") + local evs, pending = {}, {} + local function add(ev) evs[#evs + 1] = ev end + local function msg(text, id) if text then add({ kind = "msg", text = text, id = id }) end end + for i, op in ipairs(ops) do + local a = op.act + if a == "text" then + msg(self:text(op.id, op.fill), op.id) + elseif a == "anim" then + add(op.ev) + elseif a == "hp" then + local b = self:battlerFor(op.seat) + if b and b.mon then + b.mon.hp = op.to + b.fainted = (op.to or 0) <= 0 + end + if op.ev.kind == "hit" then + for j = i + 1, #ops do + local o = ops[j] + if o.act == "anim" and o.ev.kind == "move" then break end + if o.act == "text" and EFFECTIVENESS[o.id] ~= nil then + op.ev.effectiveness = EFFECTIVENESS[o.id] + break + end + end + end + add(op.ev) + elseif a == "status" then + local b = self:battlerFor(op.seat) + if b then + b.status = op.status + if b.mon then b.mon.status = op.status end + end + add(op.ev) + elseif a == "faint" then + local b = self:battlerFor(op.seat) + if b and b.mon then b.mon.hp, b.fainted = 0, true end + add(op.ev) + elseif a == "stage" then + local b = self:battlerFor(op.seat) + if b and b.stages and op.stat then + b.stages[op.stat] = math.max(-6, math.min(6, (b.stages[op.stat] or 0) + op.delta)) + end + elseif a == "weather" then + self.pst.weather = op.weather ~= "NONE" and op.weather or nil + self.pst.weatherTurns = op.turns + elseif a == "withdraw" then + local b, id = self:battlerFor(op.seat) + local alive = b and b.mon and (tonumber(b.mon.hp) or 0) > 0 + pending[op.seat] = op.index + if alive and op.reason == "switch" then msg(SwitchSeq.returnText(self.pst, id), "STRINGID_RETURNMON") end + elseif a == "sendout" then + if op.reason == "start" then + self:setActive(op.seat, op.index) + else + local from = pending[op.seat] or self.active[op.seat] + pending[op.seat] = nil + local b, id = self:setActive(op.seat, op.index), M.idOf(op.seat, self.bs.seat) + local nxt = ops[i + 1] + local engineText = nxt and nxt.act == "text" and SWITCH_TEXT_IDS[nxt.id] + local own = op.reason ~= "roar" and not engineText + add({ kind = "switch", side = b.side, battler = id, from = from, to = op.index, + reason = (own or engineText) and "baton_pass" or op.reason }) + if own then + msg(lazy("src.core.game3.battle.battle_text").get("STRINGID_SWITCHINMON", + SwitchSeq.switchInFill(self.pst, b)), "STRINGID_SWITCHINMON") + end + SwitchSeq.stampSwitchIn(self.pst) + end + end + end + return evs +end + +function Run:syncMoves() + local party = self.bs:myParty() + local live = party and party[self.active[self.bs.seat]] + local b = self.pst.player + if not (live and b and b.mon) then return end + local rows = self.bs.table and self.bs.table.moves or {} + b.mon.moveTypes = b.mon.moveTypes or {} + for i = 1, 4 do + b.mon.moves[i] = live.moves and live.moves[i] + b.mon.pp[i] = live.pp and live.pp[i] + local row = rows[b.mon.moves[i] or -1] + b.mon.moveTypes[i] = row and row[2] + end +end + +function Run:begin() + local State = lazy("src.core.game3.battle.state") + local Ui = lazy("src.core.game3.battle.ui") + local Anim = lazy("src.core.game3.battle.anim") + local AnimSeq = lazy("src.core.game3.battle.anim_seq") + local IntroSeq = lazy("src.core.game3.battle.intro_seq") + local SwitchSeq = lazy("src.core.game3.battle.switch_seq") + local BattleBg = lazy("src.core.game3.battle.bg") + local Battle = lazy("src.core.game3.battle") + local bs = self.bs + local t = bs.table + self.disp = { [0] = {}, [1] = {} } + for seat = 0, 1 do + for i, rec in ipairs(self.parties[seat] or {}) do self.disp[seat][i] = dispMon(rec, t) end + end + self.active = { [0] = 1, [1] = 1 } + local mine, foe = bs.seat, bs.peer + local pst = State.new({ playerParty = self.disp[mine], foeParty = self.disp[foe], playerIndex = 1, foeIndex = 1, + rng = function() return 0 end }) + pst.link = true + pst.g3u = true + pst.session = self.session + pst.playerName = self.names.me or (self.session and self.session.name) + pst.peerName = self.names.foe or "" + pst.trainerName = pst.peerName + pst.trainerClassName = "" + pst.trainerPicId = trainerPic(self.opts.foeGender) + pst.playerGender = playerGender(self.session) + self.pst = pst + self.prevSt = Battle._st + Battle._st = pst + Ui.reset({}) + Ui.bindState(pst, self.session) + Anim.reset({ double = false }) + AnimSeq.reset() + IntroSeq.reset() + SwitchSeq.reset() + BattleBg.setTerrain(BattleBg.resolveOverride(BattleBg.resolveOpts({}), { link = true, trainer = true })) + Anim.syncDisplayFromState(pst) + SwitchSeq.stampSwitchIn(pst) + local pushMsg = function(text) Ui.push(text) end + if IntroSeq.begin(pst, { pushMsg = pushMsg, trainerPicId = pst.trainerPicId, playerGender = pst.playerGender }) then + local texts = { IntroSeq.introText(pst), IntroSeq.sendOutText(pst, "enemy") } + local n, rulesAt = 0, nil + for i, step in ipairs(IntroSeq._steps or {}) do + if step.kind == "msg" and n < 2 then + n = n + 1 + step.data.text = texts[n] + if n == 1 then rulesAt = i + 1 end + end + end + table.insert(IntroSeq._steps, rulesAt or 1, { kind = "msg", data = { text = S(M.RULES_TEXT) } }) + else + Ui.push(IntroSeq.introText(pst)) + Ui.push(S(M.RULES_TEXT)) + Ui.push(IntroSeq.sendOutText(pst, "enemy")) + Ui.push(IntroSeq.sendOutText(pst, "player")) + end + self.phase = "intro" +end + +function Run:openMenu() + local Ui = lazy("src.core.game3.battle.ui") + self:syncMoves() + local menu = M.menu(self.bs:legal()) + self.legal = menu + if menu.locked then + self.bs:choose({ kind = "move", slot = menu.locked.slot, locked = true }) + return self:afterChoice() + end + Ui.openMenu(0) + self.phase = "menu" +end + +function Run:afterChoice() + self.phase = "play" + self:standby() +end + +function Run:choose(act) + if self.bs:choose(act) then return self:afterChoice() end + self.phase = "menu" +end + +function Run:selMsg(text, back) + local Ui = lazy("src.core.game3.battle.ui") + Ui._mode = "none" + if text then Ui.push(text) end + self.phase = "selmsg" + self.selBack = back +end + +function Run:moveError(slot) + local Commands = lazy("src.core.game3.battle.commands") + local State = lazy("src.core.game3.battle.state") + local st = self.bs.match and self.bs.match.st + local function proxy(b, side) + if not b then return nil end + return setmetatable({ side = side }, { __index = b }) + end + local view = st and { wild = false, player = proxy(State.battler(st, self.bs.seat), "player"), + enemy = proxy(State.battler(st, self.bs.peer), "enemy") } + local ok, err = pcall(Commands.selectionError, view, slot) + if ok and err then return err end + local mon = self.pst.player and self.pst.player.mon + if mon and (tonumber(mon.pp[slot]) or 0) == 0 then return self:text("STRINGID_NOPPLEFT", {}) end + return self:text("STRINGID_PKMNMOVEISDISABLED", { active = { side = self.bs.seat, index = self.active[self.bs.seat] }, + currentMove = mon and mon.moves[slot] }) +end + +function Run:openParty(forced) + local PartyMenu = lazy("src.ui.game3.party_menu") + local Commands = lazy("src.core.game3.battle.commands") + local RomText = lazy("src.core.game3.rom_text") + local Ui = lazy("src.core.game3.battle.ui") + local menu = M.menu(self.bs:legal()) + local pst = self.pst + local activeSlot = self.active[self.bs.seat] + Ui._mode = "party" + self.phase = "party" + self.partyForced = forced + self.partyPick = nil + PartyMenu.show(pst.playerParty, nil, { + mode = forced and "battle_faint" or "battle_switch", + session = self.session, + activeSlot = activeSlot, + battle = true, + validate = function(slot) + if menu.switches[slot] then return nil end + local why = Commands.switchError(pst, slot, forced) + if why then return why end + local ok, txt = pcall(RomText.ascii, "gText_PkmnCantSwitchOut", { stringVars = { nameOf(pst.player.mon) } }) + return ok and txt or "" + end, + onSelect = function(slot) + if slot ~= nil and menu.switches[slot] then self.partyPick = slot end + end, + }) +end + +function Run:partyStep() + local PartyMenu = package.loaded["src.ui.game3.party_menu"] + if PartyMenu and PartyMenu.isOpen() then return end + local Ui = lazy("src.core.game3.battle.ui") + local slot, forced = self.partyPick, self.partyForced + self.partyPick = nil + if slot then + if forced then + if self.bs:pickReplacement(slot) then return self:afterChoice() end + return self:openParty(true) + end + return self:choose({ kind = "switch", index = slot }) + end + if forced then return self:openParty(true) end + Ui._mode = "menu" + self.phase = "menu" +end + +function Run:handleMenu(input) + local Ui = lazy("src.core.game3.battle.ui") + local function nav(index, maxN) + local c = (index or 1) - 1 + if input:wasPressed("left") or input:wasPressed("right") then + c = (c % 2 == 0) and (c + 1) or (c - 1) + elseif input:wasPressed("up") or input:wasPressed("down") then + c = (c < 2) and (c + 2) or (c - 2) + else + return index, false + end + if c < 0 or c >= maxN then return index, false end + return c + 1, true + end + if self.phase == "menu" then + local idx, moved = nav(Ui._menuIndex, 4) + if moved then + Ui._menuIndex = idx + playSe("SE_SELECT") + return + end + if not input:wasPressed("a") then return end + playSe("SE_SELECT") + local pick = Ui._menuIndex + if pick == 1 then + local menu = self.legal + if menu.struggle then + local text = self:text("STRINGID_PKMNHASNOMOVESLEFT", + { active = { side = self.bs.seat, index = self.active[self.bs.seat] } }) + self.afterSel = { kind = "move", slot = 0 } + return self:selMsg(text, "choose") + end + Ui._openMoveMenu() + self.phase = "moves" + elseif pick == 2 then + self:selMsg(self:text("STRINGID_ITEMSCANTBEUSEDNOW", {}), "menu") + elseif pick == 3 then + self:openParty(false) + else + self:confirmForfeit() + end + elseif self.phase == "moves" then + local mon = self.pst.player and self.pst.player.mon + local n = 0 + for i = 1, 4 do if mon and mon.moves[i] and mon.moves[i] ~= 0 then n = i end end + local idx, moved = nav(Ui._moveIndex, math.max(1, n)) + if moved then + Ui._moveIndex = idx + playSe("SE_SELECT") + return + end + if input:wasPressed("a") then + playSe("SE_SELECT") + local slot = Ui._moveIndex + if self.legal.moves[slot] then return self:choose({ kind = "move", slot = slot }) end + return self:selMsg(self:moveError(slot), "moves") + elseif input:wasPressed("b") then + playSe("SE_SELECT") + Ui._mode = "menu" + self.phase = "menu" + end + end +end + +function Run:confirmForfeit() + local Ui = lazy("src.core.game3.battle.ui") + local RomText = lazy("src.core.game3.rom_text") + local text + if RomText.has("sText_QuestionForfeitMatch") then + text = RomText.plain("sText_QuestionForfeitMatch") + else + text = S("Would you like to forfeit\nthis Union battle?") + end + Ui._mode = "none" + self.phase = "confirm" + Ui.askYesNo(text, function(yes) + if self.phase ~= "confirm" then return end + if yes and self.bs.phase == "choose" then + self.bs:forfeit() + return self:afterChoice() + end + Ui._mode = "menu" + self.phase = "menu" + Ui._linger = false + end) +end + +function Run:endText(over) + local why = over.why + if why == "faint" or why == "forfeit" then + local word = ({ win = "won", lose = "lost", draw = "drew" })[over.outcome] or "drew" + local BattleText = lazy("src.core.game3.battle.battle_text") + local Adapter = lazy("src.core.game3.battle.adapter") + local ok, text = pcall(BattleText.get, "STRINGID_BATTLEEND", + Adapter.fill(self.pst, { outcome = word, linkRan = why == "forfeit" or nil })) + return ok and text or nil + end + return S(END_TEXT[why] or END_TEXT.error) +end + +function Run:handleInput(input) + if not input then return end + local Choice = lazy("src.ui.game3.choice") + local Message = lazy("src.ui.game3.message") + if Message.setSpeedUp then Message.setSpeedUp(input:isDown("a") or input:isDown("b")) end + if Choice.active then + if input:wasPressed("up") then Choice.move(-1) + elseif input:wasPressed("down") then Choice.move(1) + elseif input:wasPressed("a") then Choice.confirm() + elseif input:wasPressed("b") then Choice.cancel() end + return + end + if self.phase == "menu" or self.phase == "moves" then return self:handleMenu(input) end +end + +function Run:playNext() + local Ui = lazy("src.core.game3.battle.ui") + local AnimSeq = lazy("src.core.game3.battle.anim_seq") + local beat = self.script:next() + if not beat then return false end + if beat.kind == "segment" then + local evs = self:apply(beat.ops) + if #evs > 0 then + AnimSeq.beginEvents(evs, function(text, wait, id) self:pushMsg(text, wait, id) end) + self.phase = "animating" + end + return true + end + local a = beat.action + if beat.kind == "prompt" then + if a.what == "replace" then + self:openParty(true) + else + self:openMenu() + end + elseif beat.kind == "wait" then + if a.what == "replace" then self:standby() end + elseif beat.kind == "over" then + self.over = a + Ui._mode = "none" + local text = self:endText(a) + if text then Ui.push(text) end + self.phase = "ending" + end + return true +end + +function Run:finish() + if self.phase == "done" or self.phase == "fading" then return end + self.phase = "fading" + local Fade = lazy("src.ui.game3.fade") + pcall(function() lazy("src.core.game3.audio").fadeOutBgm(5) end) + Fade.begin(Fade.MODE.TO_BLACK, 1, function() self:close() end) +end + +function Run:close() + if self.phase == "done" then return end + self.phase = "done" + local Stack = lazy("src.ui.game3.stack") + local Battle = lazy("src.core.game3.battle") + local Fade = lazy("src.ui.game3.fade") + for _, name in ipairs({ "src.core.game3.battle.anim_seq", "src.core.game3.battle.intro_seq", + "src.core.game3.battle.switch_seq" }) do + pcall(function() lazy(name).reset() end) + end + pcall(function() lazy("src.core.game3.battle.anim").reset({ headless = true }) end) + pcall(function() + local PartyMenu = package.loaded["src.ui.game3.party_menu"] + if PartyMenu and PartyMenu.isOpen() then PartyMenu._onClose = nil PartyMenu.close() end + end) + pcall(function() + local Ui = lazy("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(nil) + end) + pcall(function() + local Message = lazy("src.ui.game3.message") + Message.setFrame("dialogue") + Message.close() + end) + if Battle._st == self.pst then Battle._st = self.prevSt end + Stack.pop(M.LAYER) + if self.task then lazy("src.core.game3.task").cancel(self.task.id) end + local Field = package.loaded["src.core.game3.field"] + if Field and Field.unlock then Field.unlock() end + pcall(function() lazy("src.core.game3.audio").restoreMapSong() end) + Fade.begin(Fade.MODE.FROM_BLACK, 1) + self.onDone(self.bs.result) +end + +function Run:abort(why) + print("[g3u/gen3] presenter failed: " .. tostring(why)) + if not self.bs.result then pcall(self.bs.quit, self.bs) end + if self.phase == "connect" then + self:connectFailed() + return + end + self.phase = "fading" + local Fade = lazy("src.ui.game3.fade") + Fade.clear() + self.phase = "x" + self:close() +end + +function Run:connectFailed() + self.phase = "done" + local Message = lazy("src.ui.game3.message") + if self.task then lazy("src.core.game3.task").cancel(self.task.id) end + local Field = package.loaded["src.core.game3.field"] + local r = self.bs.result or {} + Message.show(S(END_TEXT[r.why] or END_TEXT.error), { done = function() + if Field and Field.unlock then Field.unlock() end + self.onDone(self.bs.result) + end }) +end + +function Run:pushLayer() + local Stack = lazy("src.ui.game3.stack") + local run = self + local layer = { + isMenu = false, + update = function() end, + handleInput = function(input) run:handleInput(input) end, + draw = function() run:draw() end, + } + self.layer = layer + Stack.push(M.LAYER, layer, { + hideBelow = true, + fullscreen = function() + local Fade = package.loaded["src.ui.game3.fade"] + return not (Fade and (Fade.active or (Fade.t or 0) > 0)) + end, + }) +end + +function Run:draw() + if not self.pst then return end + local Ui = lazy("src.core.game3.battle.ui") + local Display = lazy("src.core.game3.display") + local Message = lazy("src.ui.game3.message") + local Choice = lazy("src.ui.game3.choice") + local Bg = lazy("src.core.game3.bg") + Ui.draw(Display.W, Display.H) + if Message.isOpen() then Message.draw() end + if Choice.active and Choice.draw then Choice.draw() end + if Bg.hasVisible() then Bg.flushAll() end +end + +function Run:connectStep() + local Message = lazy("src.ui.game3.message") + while self.script:pending() do + local q = self.script.queue[self.script.qi] + if q.kind == "ready" then + self.script.qi = self.script.qi + 1 + self.parties = q.parties + if Message.isOpen() then Message.close() end + self.phase = "transition" + return self:startTransition() + end + if q.kind == "over" then + if Message.isOpen() then Message.close() end + return self:connectFailed() + end + self.script.qi = self.script.qi + 1 + end +end + +function Run:startTransition() + local BattleTransition = lazy("src.core.game3.battle_transition") + local song = battleSong() + if song then pcall(function() lazy("src.core.game3.audio").playSong(song) end) end + local function go() + local ok, err = pcall(function() + self:pushLayer() + self:begin() + end) + if not ok then self:abort(err) end + end + local mine = self.parties[self.bs.seat] or {} + local foe = self.parties[self.bs.peer] or {} + local pickOpts = { wild = false, playerLevel = mine[1] and mine[1].level or 5, + enemyLevel = foe[1] and foe[1].level or 5, playerGender = playerGender(self.session) } + local ok = pcall(function() + BattleTransition.start(BattleTransition.pick(pickOpts), pickOpts, go) + end) + if not ok then go() end +end + +function Run:interrupt() + local Ui = lazy("src.core.game3.battle.ui") + local Choice = package.loaded["src.ui.game3.choice"] + if Choice and Choice.active then Choice.reset() end + local PartyMenu = package.loaded["src.ui.game3.party_menu"] + if PartyMenu and PartyMenu.isOpen() then + PartyMenu._onClose = nil + PartyMenu._onSelect = nil + PartyMenu.close() + end + Ui._mode = "none" + Ui._pendingYesNo = nil + self.partyPick = nil + self.phase = "play" +end + +function Run:step(dt) + local bs = self.bs + bs:update() + self.script:push(bs:events()) + if self.phase == "connect" then return self:connectStep() end + if self.phase == "transition" or self.phase == "done" or self.phase == "fading" or self.phase == "x" then return end + local Ui = lazy("src.core.game3.battle.ui") + local Anim = lazy("src.core.game3.battle.anim") + local AnimSeq = lazy("src.core.game3.battle.anim_seq") + local IntroSeq = lazy("src.core.game3.battle.intro_seq") + local Message = lazy("src.ui.game3.message") + Anim.update(dt or 0) + pcall(function() lazy("src.core.game3.audio").tickCry(dt or 1 / 60) end) + local PartyMenu = package.loaded["src.ui.game3.party_menu"] + if PartyMenu and PartyMenu.isOpen() and PartyMenu.update then PartyMenu.update(dt or 1 / 60) end + if not (PartyMenu and PartyMenu.isOpen()) and Message.tick then Message.tick() end + if bs.result and INPUT_PHASES[self.phase] then self:interrupt() end + local phase = self.phase + if phase == "intro" then + if not Ui.pump() then return end + if IntroSeq.update() then self.phase = "play" end + return + end + if phase == "menu" or phase == "moves" then + Ui.tick() + Ui.pump() + return + end + if phase == "confirm" then + Ui.pump() + return + end + if phase == "party" then return self:partyStep() end + if phase == "selmsg" then + if not Ui.pump() then return end + local back = self.selBack + self.selBack = nil + if back == "choose" then + local act = self.afterSel + self.afterSel = nil + return self:choose(act) + end + Ui._mode = (back == "moves") and "moves" or "menu" + self.phase = back == "moves" and "moves" or "menu" + return + end + if phase == "animating" then + if Anim.busy() then return end + if not Ui.pump() then return end + if AnimSeq.update() then self.phase = "play" end + return + end + if phase == "play" then + if Anim.busy() or not Ui.pump() then return end + for _ = 1, 8 do + if self.phase ~= "play" then break end + if not self:playNext() then break end + end + return + end + if phase == "ending" then + if Anim.busy() or not Ui.pump() then return end + self:finish() + end +end + +function M.start(game, bs, opts) + opts = opts or {} + if type(bs) ~= "table" then return nil, "no_session" end + local Battle = lazy("src.core.game3.battle") + if Battle.isActive() then return nil, "battle_active" end + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() or nil + local run = setmetatable({ + game = game, bs = bs, opts = opts, session = session, names = opts.names or {}, + script = M.newScript(bs.seat), phase = "connect", onDone = once(opts.onDone), + }, Run) + local Field = package.loaded["src.core.game3.field"] + if Field and Field.lock then Field.lock() end + pcall(function() + local text = standbyText() + if text then lazy("src.ui.game3.message").show(text, { stay = true }) end + end) + run.task = lazy("src.core.game3.task").spawn(function(_, dt) + local ok, err = pcall(run.step, run, dt) + if not ok then run:abort(err) end + return run.phase == "done" + end) + return { kind = "gen3", run = run, bs = bs, stop = function() run:abort("stopped") end } +end + +function M.nativeTeam(session) + local size = tonumber(session and session.go and session.go.size) or 6 + local out = {} + for _, i in ipairs(type(session and session.team) == "table" and session.team or {}) do + if #out < size and tonumber(i) then out[#out + 1] = tonumber(i) end + end + if #out == 0 then + if size >= 6 then return nil end + for i = 1, size do out[i] = i end + end + return out +end + +function M.startNative(game, session, opts) + opts = opts or {} + session = session or {} + local done = once(opts.onDone) + local Battle = lazy("src.core.game3.battle") + if Battle.isActive() then return nil, "battle_active" end + local L = lazy("src.core.game3.link") + local LB = lazy("src.core.game3.link.battle") + local Game3Link = lazy("src.link.Game3Link") + local Task = lazy("src.core.game3.task") + local Message = lazy("src.ui.game3.message") + if type(session.net) ~= "table" then return nil, "no_session" end + local lk, why = L.openRelay({ session = session.net, client = session.client, + linkType = Game3Link.LINKTYPE.BATTLE }) + if not lk then return nil, why or "no_link" end + local Field = package.loaded["src.core.game3.field"] + if Field and Field.lock then Field.lock() end + local standby = standbyText() + if standby then Message.show(standby, { stay = true }) end + local h = { kind = "native", gen = 3, stage = "link", waited = 0 } + local function leave(word) + if h.stage == "over" then return end + h.stage = "over" + h.result = word + L.closeLink("exit_link_room") + done(word) + end + local function fail(reason) + if Message.isOpen() then Message.close() end + if Field and Field.unlock then Field.unlock() end + h.why = reason + if LB.state == "setup" then + LB.refuse(reason) + end + leave("error") + end + h.task = Task.spawn(function(_, dt) + if h.stage == "over" then return true end + h.waited = h.waited + (tonumber(dt) or 0) + if h.stage == "link" then + local live = L.link + if not (live and live:isOpen()) then fail("link_closed") return true end + if not live:isReady() then + if h.waited > M.NATIVE_WAIT then fail("timeout") return true end + return false + end + if Message.isOpen() then Message.close() end + if Field and Field.unlock then Field.unlock() end + local ok, err = LB.startUnionRoomBattle(function(word) + h.word = word or "draw" + h.stage = "ending" + end, { team = M.nativeTeam(session) }) + if not ok then fail(err or "setup_failed") return true end + local seed = tonumber(session.go and session.go.seed) + if seed then LB.seed = math.floor(seed) % 4294967296 end + h.stage = "setup" + return false + end + if h.stage == "setup" then + if LB.state == "setup" then LB.pumpUnionSetup() end + if LB.state == "battle" then h.stage = "battle" return false end + if LB.state ~= "setup" and h.stage == "setup" then fail(LB.endReason or "setup_failed") return true end + if h.waited > M.NATIVE_WAIT * 2 then fail("timeout") return true end + return false + end + if h.stage == "ending" then + leave(h.word) + return true + end + return false + end) + return h +end + +return M diff --git a/src/ui/g3u/Launch.lua b/src/ui/g3u/Launch.lua new file mode 100644 index 00000000..fe462627 --- /dev/null +++ b/src/ui/g3u/Launch.lua @@ -0,0 +1,239 @@ +local BattleSession = require("src.online.union.BattleSession") + +local Launch = {} + +Launch.PRESENTERS = { + [1] = "src.ui.g3u.Gen1Screen", + [2] = "src.ui.g3u.Gen2Facade", + [3] = "src.ui.g3u.Gen3Presenter", +} + +Launch.NATIVE_WAIT = 60 + +local function version(game) + local GameVersion = require("src.core.GameVersion") + return (game and game.save and game.save.version) or GameVersion.get() +end + +local function once(fn) + local done = false + return function(...) + if done then return end + done = true + if fn then return fn(...) end + end +end + +function Launch.linkState(client, roomId) + if type(client) ~= "table" then return nil end + return function() + local room = type(client.room) == "function" and client.room() or nil + if not room or (roomId ~= nil and room.room ~= roomId) then return "gone" end + if type(client.state) == "function" and client.state() ~= "online" then return "resuming" end + return "ok" + end +end + +function Launch.names(session, seat) + local n = session.names or {} + return { me = n[seat], foe = n[1 - seat] } +end + +function Launch.session(game, session) + local seat = tonumber(session.seat) + if seat ~= 0 and seat ~= 1 then return nil, "no_seat" end + local gens = session.gens or {} + local lower = BattleSession.lowerSeat(gens) + local data = session.data + if not data and lower == seat and not session.table then + local Datasets = require("src.online.xgen.Datasets") + local why + data, why = Datasets.get(version(game)) + if not data then return nil, why or "missing_import" end + end + local client = session.client + return BattleSession.new({ + net = session.net, + seat = seat, + go = session.go, + gens = gens, + data = data, + table = session.table, + records = session.records, + names = session.names, + client = client, + linkState = session.linkState or Launch.linkState(client, session.roomId), + timeouts = session.timeouts, + }) +end + +local function startG3u(game, gen, session) + local bs, why = Launch.session(game, session) + if not bs then return nil, why end + local mod = require(Launch.PRESENTERS[gen]) + local done = once(session.onDone) + local handle, err = mod.start(game, bs, { + names = Launch.names(session, bs.seat), + foeClass = session.foeClass, + onDone = function(result) done(result) end, + }) + if not handle then + bs:quit() + return nil, err or "presenter_failed" + end + return { kind = "g3u", gen = gen, bs = bs, screen = handle } +end + +local Native = {} +Native.__index = Native + +function Native.new(game, gen, session) + local Protocol = require("src.link.Protocol") + local party = game.save and game.save.party or {} + local size = tonumber(session.go and session.go.size) or 6 + local indices = {} + for _, i in ipairs(session.team or {}) do + if party[i] and #indices < size then indices[#indices + 1] = i end + end + if #indices == 0 then + for i = 1, math.min(#party, size) do indices[i] = i end + end + if #indices == 0 then return nil, "no_party" end + local packed = gen == 2 and Protocol.packParty2(party, indices) or Protocol.packParty(party, indices) + local self = setmetatable({ + game = game, gen = gen, session = session, net = session.net, + seat = tonumber(session.seat) or 0, packed = packed, stage = "party", + waited = 0, done = once(session.onDone), isOpaque = false, + }, Native) + return self +end + +function Native:enter() + self.net:send({ type = "party", mons = self.packed }) +end + +function Native:takeParty() + local net = self.net + if type(net.take) == "function" then return net:take("party") end + if type(net.takeWhere) == "function" then + return net:takeWhere(function(m) return m.type == "party" end) + end + return nil +end + +function Native:finish(result) + if self.stage == "over" then return end + self.stage = "over" + local game = self.game + if game.linkNet == self.net then game.linkNet = nil end + game.linkSession = nil + if game.stack:top() == self then game.stack:pop() end + local client = self.session.client + if client and (result == "win" or result == "lose" or result == "draw") and type(client.report) == "function" then + pcall(client.report, result) + end + self.done(result) +end + +function Native:start(theirs) + local session, game = self.session, self.game + local names = Launch.names(session, self.seat) + local opts = { + myParty = self.packed, + theirParty = theirs.mons, + theirName = names.foe or "FOE", + seed = tonumber(session.go and session.go.seed) or 1, + verdict = "full", + strict = true, + keepNetOpen = true, + } + local LB = require(self.gen == 2 and "src.link.LinkBattle2" or "src.link.LinkBattle") + local battle, why + if self.seat == 0 then + battle, why = LB.newHost(game, self.net, opts) + else + battle, why = LB.newGuest(game, self.net, opts) + end + if not battle then + self.net:send({ type = "bye" }) + return self:finish("error", why) + end + self.battle = battle + self.stage = "battle" + game.linkSession = true + game.stack:push(battle) +end + +function Native:update(dt) + if self.stage == "party" then + if type(self.net.update) == "function" then self.net:update() end + local theirs = self:takeParty() + if theirs and type(theirs.mons) == "table" then return self:start(theirs) end + self.waited = self.waited + (dt or 0) + if self.net.closed or self.waited > Launch.NATIVE_WAIT then return self:finish("error") end + return + end + if self.stage == "battle" and self.game.stack:top() == self then + local battle = self.battle + self.battle = nil + self:finish((battle and battle.result) or "draw") + end +end + +function Native:draw() end + +local function startNative(game, gen, session) + if gen == 3 then + local Gen3 = require(Launch.PRESENTERS[3]) + return Gen3.startNative(game, session, { onDone = once(session.onDone) }) + end + local state, why = Native.new(game, gen, session) + if not state then return nil, why end + game.stack:push(state) + return { kind = "native", gen = gen, state = state } +end + +Launch.RESULT_TEXT = { + [3] = { win = "You won the battle!", lose = "You lost the battle.", draw = "The battle ended in a draw.", + desync = "The battle stopped: the two games disagreed.", disconnect = "The link was lost.", + error = "The battle could not continue." }, + gb = { win = "You won the\nbattle!", lose = "You lost the\nbattle.", draw = "The battle was\na draw.", + desync = "The games fell\nout of step.", disconnect = "The link was\nlost.", + error = "The battle could\nnot go on." }, +} + +function Launch.resultText(gen, result) + local set = Launch.RESULT_TEXT[gen == 3 and 3 or "gb"] + if type(result) == "string" then return set[result] or set.error end + if type(result) ~= "table" then return set.error end + local why = result.why + if why == "desync" or why == "disconnect" then return set[why] end + if why == "illegal" or why == "error" or why == "bad_table" or why == "bad_party" then return set.error end + return set[result.outcome] or set.error +end + +function Launch.start(game, gen, session, act) + gen = tonumber(gen) + if not Launch.PRESENTERS[gen] then return nil, "bad_gen" end + if type(session) ~= "table" or type(session.net) ~= "table" then return nil, "no_session" end + if act ~= nil then + local inner = session.onDone + local wrapped = {} + for k, v in pairs(session) do wrapped[k] = v end + wrapped.onDone = function(result) + if inner then inner(result) end + if type(act) == "table" and type(act.finish) == "function" then + act:finish("battle_end", Launch.resultText(gen, result)) + end + end + session = wrapped + end + local ruleset = session.ruleset or (session.go and session.go.ruleset) + if ruleset == "native" then return startNative(game, gen, session) end + if ruleset ~= "g3u" then return nil, "bad_ruleset" end + return startG3u(game, gen, session) +end + +Launch.Native = Native + +return Launch diff --git a/src/ui/game3/boot.lua b/src/ui/game3/boot.lua index 48a97bf6..8d98214e 100644 --- a/src/ui/game3/boot.lua +++ b/src/ui/game3/boot.lua @@ -419,6 +419,7 @@ function Boot.update(state, input, dt) if pending == "continue" then state.fadeT, state.fadeTarget = 0, 0 require("src.core.game3.link.trade").resumePending() + require("src.online.union.TradeTxn").resumePending(nil) return { action = "continue" } elseif pending == "new_game" then state.fadeT, state.fadeTarget = 0, 0 diff --git a/src/ui/game3/link_tags.lua b/src/ui/game3/link_tags.lua index 27c1641a..d1331fa3 100644 --- a/src/ui/game3/link_tags.lua +++ b/src/ui/game3/link_tags.lua @@ -10,6 +10,11 @@ LinkTags.EDGE = 1 LinkTags.BLENDER_EM_BASE = 0x7F00 LinkTags.BLENDER_RS_BASE = 0xF0 LinkTags.MAX_SEATS = 5 +LinkTags.BADGE_GAP = 1 +LinkTags.NEAR_CELLS = 2 +LinkTags.UNION_PAD = 3 +LinkTags.UNION_HEAD_GAP = 1 +LinkTags.RS_TEXT_DY = -6 LinkTags.PLATE = { 0.06, 0.07, 0.12, 0.62 } LinkTags.PLATE_SELF = { 0.08, 0.2, 0.46, 0.7 } @@ -97,9 +102,14 @@ local function tagRow(i) row = {} LinkTags._rows[i] = row end + row.badge, row.union, row.near = nil, nil, nil return row end +local function badgeModule() + return require("src.online.union.Badge") +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 @@ -146,14 +156,22 @@ function LinkTags.sources() 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 + local inUnion = U and U.isActive and U.isActive() and U.onUnionRoomMap and U.onUnionRoomMap() + if inUnion then active = true + local P = package.loaded["src.core.game3.player"] + local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY) 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) + row.badge = tonumber(p.sourceGen) or 3 + row.union = true + local cx, cy = U.avatarCell(slot) + row.near = U._talkSlot == slot or (px ~= nil and cx ~= nil + and math.abs(cx - px) + math.abs(cy - py) <= LinkTags.NEAR_CELLS) src.byVobj[U.vobjId(slot)] = row end end @@ -180,6 +198,7 @@ function LinkTags.sources() 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() + me.badge, me.hidden = nil, inUnion and true or nil if me.name and me.name ~= "" then src.player = me end return src end @@ -215,7 +234,8 @@ 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 badgeW = tag.badge and (badgeModule().SIZE + LinkTags.BADGE_GAP) or 0 + local w = LinkTags.PAD * 2 + textW + lead + badgeW local x = math.floor(headX - w / 2 + 0.5) if viewW then x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x)) @@ -223,6 +243,7 @@ function LinkTags.layout(tag, headX, headY, viewW, measure, out) 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 + out.badgeX = tag.badge and (out.textX + textW + LinkTags.BADGE_GAP) or nil return out end @@ -281,7 +302,16 @@ local function opaqueTop(spr) return top end +local function foreignHeight(eo) + local Avatars = require("src.online.union.Avatars") + local entry = Avatars.resolve(eo.foreign, eo.foreign.host) + if entry.standin then return Avatars.STANDIN_H end + return tonumber(entry.h) or Avatars.GB_FRAME +end + local function headTop(a, camY) + local eo = a.eventObject + if eo and eo.foreign then return a.y - camY + 16 - foreignHeight(eo) end 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 @@ -295,7 +325,60 @@ local function headTop(a, camY) return a.y - camY + 16 - h + offY + top end +local function textDy() + local Family = require("src.core.game3.link.family") + return Family.isRubySapphire(Family.activeVersion()) and LinkTags.RS_TEXT_DY or LinkTags.TEXT_DY +end + +function LinkTags.unionLayout(tag, headX, headY, viewW, measure, out) + local Badge = badgeModule() + out = out or {} + local bs = Badge.SIZE + if not tag.near then + out.x, out.y, out.w, out.h = math.floor(headX - bs / 2), math.floor(headY - bs - LinkTags.UNION_HEAD_GAP), bs, bs + out.textW, out.badgeX, out.badgeY, out.plate = 0, out.x, out.y, false + return out + end + local textW = LinkTags.textWidth(tag.name, measure) + local pad = LinkTags.UNION_PAD + local w = pad + textW + LinkTags.BADGE_GAP + 1 + bs + 1 + local h = LinkTags.PLATE_H + local x = math.floor(headX - w / 2) + if viewW then x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x)) end + local y = math.floor(headY - h - LinkTags.UNION_HEAD_GAP) + out.x, out.y, out.w, out.h, out.textW, out.plate = x, y, w, h, textW, true + out.textX = x + pad + out.badgeX = x + w - 1 - bs + out.badgeY = y + math.floor((h - bs) / 2) + return out +end + +local function windowPlate(x, y, w, h) + local FrlgFont = require("src.ui.game3.frlg_font") + local Chrome = require("src.ui.game3.chrome") + local fill = Chrome.windowFillColor() + local edge = FrlgFont.COLOR.NORMAL.fg + local g = love.graphics + g.setColor(edge[1], edge[2], edge[3], 1) + g.rectangle("fill", x + 1, y, w - 2, h) + g.rectangle("fill", x, y + 1, w, h - 2) + g.setColor(fill[1], fill[2], fill[3], 1) + g.rectangle("fill", x + 1, y + 1, w - 2, h - 2) +end + +local function drawUnionTag(a, tag, camX, camY, viewW) + local L = LinkTags.unionLayout(tag, math.floor(a.x - camX) + 8, math.floor(headTop(a, camY)), viewW, nil, LAYOUT) + if L.plate then + windowPlate(L.x, L.y, L.w, L.h) + local FrlgFont = require("src.ui.game3.frlg_font") + TEXT_OPTS.colors, TEXT_OPTS.maxWidth = FrlgFont.COLOR.NORMAL, L.textW + pcall(FrlgFont.draw, tag.name, L.textX, L.y + textDy(), TEXT_OPTS) + end + badgeModule().draw(L.badgeX, L.badgeY, tag.badge, tag.badge, 1) +end + local function drawTag(a, tag, camX, camY, viewW, isSelf) + if tag.union then return drawUnionTag(a, tag, camX, camY, viewW) end 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 @@ -308,14 +391,18 @@ local function drawTag(a, tag, camX, camY, viewW, isSelf) 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) + pcall(FrlgFont.draw, tag.name, L.textX, L.y + textDy(), TEXT_OPTS) + if L.badgeX then + local Badge = badgeModule() + Badge.draw(L.badgeX, L.y + math.floor((L.h - Badge.SIZE) / 2), tag.badge, tag.badge, 1) + end 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 + if tag and tag.name and tag.name ~= "" and not tag.hidden 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 diff --git a/src/ui/game3/rs/link_records.lua b/src/ui/game3/rs/link_records.lua index 753f1682..f0ed8509 100644 --- a/src/ui/game3/rs/link_records.lua +++ b/src/ui/game3/rs/link_records.lua @@ -9,6 +9,13 @@ function M.show(session) local Space = package.loaded["src.core.game3.scripting.space"] M.owner = Space and Space.vm end +function M.isVisible() + if M.visible then + local Space = package.loaded["src.core.game3.scripting.space"] + if M.owner ~= (Space and Space.vm) then M.reset() end + end + return M.visible +end function M.eraseBox(l, t, r, b) if l <= 1 and t <= 0 and r >= 28 and b >= 18 then M.reset() end end diff --git a/src/ui/game3/union_room.lua b/src/ui/game3/union_room.lua index 28a04d74..ef1af25f 100644 --- a/src/ui/game3/union_room.lua +++ b/src/ui/game3/union_room.lua @@ -414,6 +414,7 @@ UnionRoomScreen.LABELS = RomText.lazy({ }) function UnionRoomScreen.labelFor(item) + if type(item) == "table" and type(item.label) == "string" then return item.label end return UnionRoomScreen.LABELS[item and item.key] or "" end diff --git a/src/ui/gen2/union/Activity.lua b/src/ui/gen2/union/Activity.lua new file mode 100644 index 00000000..0b48cd5b --- /dev/null +++ b/src/ui/gen2/union/Activity.lua @@ -0,0 +1,144 @@ +local Dialog = require("src.ui.gen2.union.Dialog") +local Strings = require("src.core.Strings") +local Text = require("src.ui.gen2.union.Text") + +local Activity = {} +Activity.__index = Activity + +Activity.screens = { battle = nil, trade = nil } + +local function peerOf(room) + local xr = room:xgRoom() + local seat = room.client.seat and room.client.seat() + for _, row in ipairs(xr and xr.players or {}) do + if row.seat ~= seat then + local av = type(row.avatar) == "table" and row.avatar or {} + return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat } + end + end + return { name = "?" } +end + +local function flow() + return require("src.ui.union.Flow") +end + +function Activity.begin(game, room, mode, opts) + opts = opts or {} + if not Activity.installed then + Activity.installed = true + flow().install(Activity) + end + local self = setmetatable({ + game = game, room = room, mode = mode == "trade" and "trade" or "battle", + session = opts.session, prep = room:prep(), peer = peerOf(room), + state = "preparing", done = false, why = nil, events = {}, + }, Activity) + if self.session then self.session:uiOpen("activity") end + self:show() + return self +end + +function Activity:readyText() + return Text.say(self.mode == "trade" and "readyTrade" or "readyBattle", self.peer.name) +end + +function Activity:show() + self.waiter = Dialog.hold(self.game, self:readyText(), function(_, input) + self:tick(input) + end) +end + +function Activity:closeUi() + if self.waiter then self.waiter:close() end + self.waiter = nil +end + +function Activity:finish(why, text) + if self.done then return end + if why == "go" and not self.launched then + self.state = "battle" + self:closeUi() + if flow().launch(self, 2) then return end + why, text = "error", require("src.ui.g3u.Launch").resultText(2, "error") + end + self.why = why + self.state = "done" + self:closeUi() + flow().leaveRoom(self) + local session = self.session + local function after() + self.done = true + if session then session:uiDone() end + end + if text then + Dialog.say(self.game, text, after) + else + after() + end +end + +function Activity:cancel(why) + local prep = self.prep + if prep then + prep:cancel(why or "cancel") + prep:leave() + end + Dialog.sfx(self.game, "Sfx_ReadText2") + self:finish(why or "cancel", Text.cancelled(self.game)) +end + +function Activity:abort(why) + if self.done then return end + local prep = self.prep + if prep and prep:open() then + prep:cancel(why or "left") + prep:leave() + end + self:closeUi() + self.state = "done" + self.why = why or "left" + self.done = true +end + +function Activity:handle(e) + self.events[#self.events + 1] = e.kind + if e.kind == "closed" then + local mine = e.seat ~= nil and e.seat == self.prep:seat() + if mine then + self:finish("closed", Text.cancelled(self.game)) + else + Dialog.sfx(self.game, "Sfx_Wrong") + self:finish("peer", Text.say("peerCancel", self.peer.name)) + end + return true + end + if e.kind == "blocked" then + self.prep:cancel("blocked") + self.prep:leave() + self:finish("blocked", Strings(Text.S.mismatch)) + return true + end + return false +end + +function Activity:tick(input) + if self.done or self.state == "done" then return end + local prep = self.prep + if not prep then return self:finish("gone", Text.cancelled(self.game)) end + for _, e in ipairs(prep:poll()) do + if self:handle(e) then return end + end + if prep.state == "prep" and self.state == "preparing" then + self.state = "ready" + local screen = Activity.screens[self.mode] + if screen then + self:closeUi() + screen(self) + return + end + end + if Dialog.pressed(input, "b") then self:cancel("cancel") end +end + +return Activity diff --git a/src/ui/gen2/union/Dialog.lua b/src/ui/gen2/union/Dialog.lua new file mode 100644 index 00000000..3a255ddd --- /dev/null +++ b/src/ui/gen2/union/Dialog.lua @@ -0,0 +1,110 @@ +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") + +local Dialog = {} + +-- constants/menu_constants.asm:21 +local STATICMENU_CURSOR = 0x80 + +Dialog.MENU_LEFT, Dialog.MENU_RIGHT = 11, 19 +Dialog.MENU_BOTTOM = 11 + +local function popIf(stack, state) + if state and stack:top() == state then + stack:pop() + return true + end + return false +end + +function Dialog.sfx(game, name) + local data = game and game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + +function Dialog.say(game, text, onDone) + local box = TextBox.new(game, text, onDone) + game.stack:push(box) + return box +end + +function Dialog.ask(game, text, onAnswer, opts) + opts = opts or {} + local box = TextBox.new(game, text, nil, { + choice = function(yes) onAnswer(yes and true or false) end, + defaultNo = opts.defaultNo, + }) + game.stack:push(box) + return box +end + +function Dialog.menuHeader(labels) + local items = {} + for i, label in ipairs(labels) do items[i] = Strings(label) end + local top = Dialog.MENU_BOTTOM - (#items * 2 + 1) + return { + left = Dialog.MENU_LEFT, right = Dialog.MENU_RIGHT, + top = top, bottom = Dialog.MENU_BOTTOM, + items = items, dataFlags = STATICMENU_CURSOR, cursor = 1, + } +end + +function Dialog.menu(game, text, labels, onChoose) + local stack = game.stack + local box, menu + box = TextBox.new(game, text, nil, { stay = { onShown = function() + menu = ScriptMenu.new(game, { + header = Dialog.menuHeader(labels), kind = "union_talk", + onChoose = function(index) + popIf(stack, menu) + popIf(stack, box) + onChoose(index) + end, + }) + stack:push(menu) + end } }) + stack:push(box) + return box +end + +local Waiter = {} +Waiter.__index = Waiter +Waiter.isOpaque = false + +function Waiter:update() + if self.closed then return end + self.tick(self, self.game and self.game.input) +end + +function Waiter:draw() end + +function Waiter:close() + if self.closed then return end + self.closed = true + local stack = self.game.stack + popIf(stack, self) + popIf(stack, self.box) +end + +function Waiter:shown() + return self.game.stack:top() == self +end + +function Dialog.hold(game, text, tick) + local stack = game.stack + local waiter = setmetatable({ game = game, tick = tick, closed = false }, Waiter) + waiter.box = TextBox.new(game, text, nil, { stay = { onShown = function() + if not waiter.closed then stack:push(waiter) end + end } }) + stack:push(waiter.box) + return waiter +end + +function Dialog.pressed(input, key) + return input and input.wasPressed and input:wasPressed(key) and true or false +end + +return Dialog diff --git a/src/ui/gen2/union/Tag.lua b/src/ui/gen2/union/Tag.lua new file mode 100644 index 00000000..b4d0823d --- /dev/null +++ b/src/ui/gen2/union/Tag.lua @@ -0,0 +1,57 @@ +local Badge = require("src.online.union.Badge") +local Font = require("src.render.Font") + +local Tag = {} + +Tag.HOST_GEN = 2 +Tag.PAD = 2 +Tag.GLYPH_H = 8 +Tag.LIFT = 2 +Tag.FILL = { 1, 1, 1 } +Tag.EDGE = { 0, 0, 0 } + +local widths = setmetatable({}, { __mode = "k" }) + +function Tag.nameWidth(name) + local hit = widths[name] + if hit then return hit end + local w = Font.width and Font.width(name) or #name * 8 + widths[name] = w + return w +end + +function Tag.size(name, withName) + local bw, bh = Badge.size(1) + if not withName then return bw, bh end + local w = Tag.nameWidth(name) + Tag.PAD * 3 + bw + local h = math.max(Tag.GLYPH_H, bh) + Tag.PAD * 2 + return w, h +end + +function Tag.draw(x, y, name, digit, withName, scale) + local G = love.graphics + local s = math.max(1, math.floor(tonumber(scale) or 1)) + local w, h = Tag.size(name, withName) + local left = -math.floor(w / 2) + local top = -Tag.LIFT - h + G.push() + G.translate(math.floor(x), math.floor(y)) + G.scale(s, s) + if withName then + G.setColor(Tag.EDGE[1], Tag.EDGE[2], Tag.EDGE[3], 1) + G.rectangle("fill", left, top, w, h) + G.setColor(Tag.FILL[1], Tag.FILL[2], Tag.FILL[3], 1) + G.rectangle("fill", left + 1, top + 1, w - 2, h - 2) + G.setColor(0, 0, 0, 1) + Font.draw(name, left + Tag.PAD, top + Tag.PAD) + Badge.drawBeside(left + Tag.PAD + Tag.nameWidth(name), top + Tag.PAD - 1, + Tag.GLYPH_H + 2, digit, Tag.HOST_GEN, 1, Tag.PAD) + else + Badge.draw(left, top, digit, Tag.HOST_GEN, 1) + end + G.pop() + G.setColor(1, 1, 1, 1) + return math.floor(x) + left * s, math.floor(y) + top * s, w * s, h * s +end + +return Tag diff --git a/src/ui/gen2/union/Talk.lua b/src/ui/gen2/union/Talk.lua new file mode 100644 index 00000000..a8b28e25 --- /dev/null +++ b/src/ui/gen2/union/Talk.lua @@ -0,0 +1,149 @@ +local Dialog = require("src.ui.gen2.union.Dialog") +local Participant = require("src.online.union.Participant") +local Strings = require("src.core.Strings") +local Text = require("src.ui.gen2.union.Text") + +local Talk = {} + +Talk.ACTIVITIES = { "xg_battle", "xg_trade" } +Talk.LINK_SECONDS = 15 + +local function now() + local t = love and love.timer and love.timer.getTime + return t and t() or os.clock() +end + +local function finish(session) + session:uiDone() +end + +local function sayThen(session, text, after) + Dialog.say(session.game, text, function() + if after then after() else finish(session) end + end) +end + +function Talk.wait(session, p, handle) + local game = session.game + Dialog.hold(game, Text.say("waiting", p.name), function(w, input) + if handle.state == "accepted" or handle.why == "crossed" then + w:close() + finish(session) + return + end + if handle.state == "closed" then + w:close() + Dialog.sfx(game, "Sfx_Wrong") + sayThen(session, Text.closed(handle.why, p.name)) + return + end + if Dialog.pressed(input, "b") then + session.abandoned[handle] = true + w:close() + Dialog.sfx(game, "Sfx_ReadText2") + sayThen(session, Text.cancelled(game)) + end + end) +end + +function Talk.invite(session, p, activity) + local handle, why = session.room:invite(p, activity) + if not handle then + sayThen(session, Text.closed(why, p.name)) + return + end + if handle.state == "closed" then + sayThen(session, Text.closed(handle.why, p.name)) + return + end + Talk.wait(session, p, handle) +end + +function Talk.menu(session, p) + local labels = { Text.MENU.battle, Text.MENU.trade, Text.MENU.cancel } + Dialog.menu(session.game, Text.say("what", p.name), labels, function(index) + local activity = Talk.ACTIVITIES[index] + if not activity then return finish(session) end + local live = session.room:member(p.id) or p + if Participant.busy(live) then + return sayThen(session, Text.say("busy", p.name)) + end + Talk.invite(session, live, activity) + end) +end + +function Talk.open(session, e) + local p = e.participant + session:uiOpen("talk") + Dialog.sfx(session.game, "Sfx_ReadText2") + local function body() + if Participant.busy(session.room:member(p.id) or p) then + return sayThen(session, Text.say("busy", p.name)) + end + Talk.menu(session, p) + end + local entry = e.avatar + if entry and (entry.standin or entry.hostStandin) then + local game = Text.needName(entry.need) + return sayThen(session, Text.say("standin", p.name, Text.gameName(p.game), game), body) + end + body() +end + +function Talk.linking(session, name, inv) + local game = session.game + local started = now() + Dialog.hold(game, Text.say("linking", name), function(w) + if session.room:xgRoom() then + w:close() + finish(session) + return + end + if now() - started > Talk.LINK_SECONDS or session.state ~= "joined" then + w:close() + sayThen(session, Text.cancelled(game)) + end + end) +end + +function Talk.prompt(session, inv) + local from = inv.from or {} + local name = from.name or "?" + session:uiOpen("prompt") + local e = from.slot and session:entity(from.slot) + if e and e.participant.id == inv.fromId then e:facePlayer(session.world and session.world.player) end + Dialog.sfx(session.game, "Sfx_Call") + local key = inv.mode == "trade" and "askTrade" or "askBattle" + Dialog.ask(session.game, Text.say(key, name), function(yes) + local sent = session.room:reply(inv.id, yes) + if not yes then return finish(session) end + if not sent then return sayThen(session, Text.say("gone", name)) end + Talk.linking(session, name, inv) + end) +end + +function Talk.notice(session, n) + local game = session.game + session:uiOpen("notice") + if n.kind == "connecting" then + Dialog.hold(game, Strings(Text.S.connecting), function(w, input) + if session.state ~= "connecting" or Dialog.pressed(input, "b") then + w:close() + finish(session) + end + end) + elseif n.kind == "lost" then + Dialog.hold(game, Strings(Text.S.lost), function(w, input) + if not session.lost or Dialog.pressed(input, "b") then + w:close() + finish(session) + end + end) + elseif n.kind == "error" then + sayThen(session, Text.error(n.code)) + else + sayThen(session, n.text or "") + end +end + +return Talk diff --git a/src/ui/gen2/union/Text.lua b/src/ui/gen2/union/Text.lua new file mode 100644 index 00000000..3e806244 --- /dev/null +++ b/src/ui/gen2/union/Text.lua @@ -0,0 +1,90 @@ +local GameVersion = require("src.core.GameVersion") +local Strings = require("src.core.Strings") + +local Text = {} + +Text.MENU = { + battle = Strings.source("BATTLE"), + trade = Strings.source("TRADE"), + cancel = Strings.source("CANCEL"), +} + +local S = { + what = Strings.source("With %s,\nwhat'll you do?"), + busy = Strings.source("%s is\nbusy right now."), + waiting = Strings.source("Waiting for\n%s…"), + declined = Strings.source("%s said no."), + timeout = Strings.source("%s didn't\nanswer."), + gone = Strings.source("%s left the\nUNION ROOM."), + refused = Strings.source("%s can't do\nthat right now."), + askBattle = Strings.source("%s wants\nto battle. OK?"), + askTrade = Strings.source("%s wants\nto trade. OK?"), + linking = Strings.source("Linking up with\n%s…"), + readyBattle = Strings.source("Getting ready to\nbattle %s…"), + readyTrade = Strings.source("Getting ready to\ntrade with %s…"), + peerCancel = Strings.source("%s cancelled."), + standin = Strings.source("%s is playing\n%s.\fImport %s\nto see them here."), + connecting = Strings.source("Connecting to the\nUNION ROOM…"), + offline = Strings.source("Couldn't connect.\fNo other trainers\ncan be seen now."), + serverOld = Strings.source("The UNION ROOM\nserver is too old.\fNo other trainers\ncan be seen now."), + clientOld = Strings.source("This game is too\nold to link up.\fUpdate it to meet\nother trainers."), + joinFailed = Strings.source("Couldn't enter\nthe UNION ROOM.\fNo other trainers\ncan be seen now."), + lost = Strings.source("The link was lost.\nReconnecting…"), + lostForGood = Strings.source("Couldn't link up\nagain.\fNo other trainers\ncan be seen now."), + mismatch = Strings.source("Your games can't\nlink up for this."), +} +Text.S = S + +local function clean(name) + name = tostring(name or "") + name = name:gsub("[{}%c]", "") + if name == "" then name = "?" end + return name +end + +Text.clean = clean + +function Text.say(key, ...) + local args = { ... } + for i = 1, select("#", ...) do args[i] = clean(args[i]) end + return Strings(S[key], unpack(args, 1, select("#", ...))) +end + +function Text.gameName(version) + local info = GameVersion.VERSIONS[version or ""] + local label = info and info.label or tostring(version or "?") + return "POKéMON " .. label:upper() +end + +function Text.needName(need) + if type(need) ~= "table" or not need[1] then return "?" end + return Text.gameName(need[1]) +end + +Text.CLOSED = { + declined = "declined", timeout = "timeout", busy = "busy", + offline = "gone", target_left = "gone", sender_left = "gone", +} + +function Text.closed(why, name) + return Text.say(Text.CLOSED[why or ""] or "refused", name) +end + +Text.ERRORS = { + server_outdated = "serverOld", client_outdated = "clientOld", + offline = "offline", lost = "lostForGood", +} + +function Text.error(code) + return Strings(S[Text.ERRORS[code or ""] or "joinFailed"]) +end + +-- data/text/common_2.asm:216 +function Text.cancelled(game) + local text = game and game.data and game.data.text + local line = text and text._MysteryGiftCanceledText + assert(type(line) == "string", "gen2 cache has no _MysteryGiftCanceledText") + return line +end + +return Text diff --git a/src/ui/union/Flow.lua b/src/ui/union/Flow.lua new file mode 100644 index 00000000..7d50ade4 --- /dev/null +++ b/src/ui/union/Flow.lua @@ -0,0 +1,86 @@ +local Flow = {} + +Flow.seams = { client = nil, launch = nil } + +local function client() + return Flow.seams.client or require("src.online.Client") +end + +function Flow.install(Activity) + require("src.ui.union.prep.Open").register(Activity) + require("src.ui.union.prep.OpenTrade").register(Activity) + return Activity +end + +function Flow.gens(list) + if type(list) ~= "table" then return nil end + local a, b = tonumber(list[1]), tonumber(list[2]) + if not (a and b) then return nil end + return { [0] = a, [1] = b } +end + +function Flow.myName(game, gen) + if gen == 3 then + local s = game and game.session + if not s then + local ok, Runtime = pcall(require, "src.core.game3.runtime") + s = ok and Runtime.getSession and Runtime.getSession() or nil + end + return s and s.name or nil + end + local player = game and game.save and game.save.player + return player and player.name or nil +end + +function Flow.session(act, gen) + local bp = act.battlePrep + if type(bp) ~= "table" then return nil, "no_prep" end + local C = client() + local room = C.room and C.room() or nil + if type(room) ~= "table" or (act.roomId ~= nil and room.room ~= act.roomId) then return nil, "no_room" end + local seat = tonumber(C.seat and C.seat()) + if seat ~= 0 and seat ~= 1 then return nil, "no_seat" end + local net = C.roomSession and C.roomSession() or nil + if not net then return nil, "no_session" end + local rules = bp.ruleset or {} + local go = (act.prep and act.prep.go) or bp.go or {} + go = { + rev = go.rev or bp.rev, seed = go.seed or bp.seed, match = go.match or bp.match, + ruleset = go.ruleset or rules.id, size = go.size or bp.size, gen = go.gen or rules.gen, + dexMax = go.dexMax or rules.dexMax, moveMax = go.moveMax or rules.moveMax, moveGen = go.moveGen or rules.moveGen, + } + local gens = Flow.gens(room.xg and room.xg.gens) or Flow.gens(rules.gens) + if not gens then + gens = { [0] = gen, [1] = gen } + end + local peer = act.peer or {} + local names = { [seat] = Flow.myName(act.game, gen), [1 - seat] = peer.name } + return { + ruleset = go.ruleset, net = net, seat = seat, go = go, gens = gens, names = names, + records = bp.records, team = bp.team, client = C, roomId = room.room, + } +end + +function Flow.launch(act, gen) + if act.launched then return true end + local session, why = Flow.session(act, gen) + local handle + if session then + local Launch = Flow.seams.launch or require("src.ui.g3u.Launch") + handle, why = Launch.start(act.game, gen, session, act) + end + if not handle then + print("[union] battle launch failed: " .. tostring(why)) + return false, why + end + act.launched = true + act.battle = handle + return true +end + +function Flow.leaveRoom(act) + local prep = act and act.prep + if prep and prep.leave then prep:leave() end +end + +return Flow diff --git a/src/ui/union/gen1/Activity.lua b/src/ui/union/gen1/Activity.lua new file mode 100644 index 00000000..080dbb6c --- /dev/null +++ b/src/ui/union/gen1/Activity.lua @@ -0,0 +1,171 @@ +local Dialog = require("src.ui.union.gen1.Dialog") +local Text = require("src.ui.union.gen1.Text") + +local Activity = {} +Activity.__index = Activity + +Activity.LINK_SECONDS = 10 +Activity.screens = { battle = nil, trade = nil } + +local MODES = { battle = true, trade = true } + +local function peerOf(room, fallback) + local xr = room:xgRoom() + local seat = room.client and room.client.seat and room.client.seat() + for _, row in ipairs(xr and xr.players or {}) do + if row.seat ~= seat then + local av = type(row.avatar) == "table" and row.avatar or {} + return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat } + end + end + if fallback then + return { id = fallback.id, name = fallback.name, gen = fallback.gen, game = fallback.game } + end + return { name = "?" } +end + +local function flow() + return require("src.ui.union.Flow") +end + +function Activity.begin(game, room, mode, opts) + opts = opts or {} + if not Activity.installed then + Activity.installed = true + flow().install(Activity) + end + assert(MODES[mode], "union activity mode must be battle or trade") + local self = setmetatable({ + game = game, room = room, mode = mode, opts = opts, session = opts.session, + prep = nil, peer = peerOf(room, opts.peer), state = "preparing", done = false, + why = nil, events = {}, started = love.timer.getTime(), linked = false, + }, Activity) + if self.session then self.session.activity = self end + self:show(Text.pleaseWait(game)) + return self +end + +function Activity:readyText() + return Text.say(self.mode == "trade" and "readyTrade" or "readyBattle", self.peer.name) +end + +function Activity:show(text) + self:closeUi() + self.waiter = Dialog.hold(self.game, text, function(_, input) + self:tick(input) + end) +end + +function Activity:closeUi() + if self.waiter then self.waiter:close() end + self.waiter = nil +end + +function Activity:finish(why, text) + if self.done or self.state == "done" then return end + if why == "go" and not self.launched then + self.state = "battle" + self:closeUi() + if flow().launch(self, 1) then return end + why, text = "error", require("src.ui.g3u.Launch").resultText(1, "error") + end + self.why = why + self.state = "done" + self:closeUi() + flow().leaveRoom(self) + self.room:dropPrep() + local session = self.session + local onDone = self.opts.onDone + local function after() + self.done = true + if session and session.activity == self then session.activity = nil end + if onDone then onDone() end + end + if text then + Dialog.say(self.game, text, after) + else + after() + end +end + +function Activity:cancel(why) + local prep = self.prep + if prep and prep:open() then + prep:cancel(why or "cancel") + prep:leave() + end + Dialog.sfx(self.game, "Press_AB") + self:finish(why or "cancel", Text.say("cancelled")) +end + +function Activity:abort(why) + if self.done then return end + local prep = self.prep + if prep and prep:open() then + prep:cancel(why or "left") + prep:leave() + end + self:closeUi() + self.room:dropPrep() + self.state = "done" + self.why = why or "left" + self.done = true + if self.session and self.session.activity == self then self.session.activity = nil end +end + +function Activity:handle(e) + self.events[#self.events + 1] = e.kind + if e.kind == "closed" then + local mine = e.seat ~= nil and e.seat == self.prep:seat() + if mine then + self:finish("closed", Text.say("cancelled")) + else + Dialog.sfx(self.game, "Denied") + self:finish("peer", Text.say("peerCancel", self.peer.name)) + end + return true + end + if e.kind == "blocked" then + self.prep:cancel("blocked") + self.prep:leave() + Dialog.sfx(self.game, "Denied") + self:finish("blocked", Text.blocked(e.why)) + return true + end + return false +end + +function Activity:tick(input) + if self.done or self.state == "done" then return end + if Dialog.pressed(input, "b") then return self:cancel("cancel") end + if not self.prep then + self.prep = self.room:prep() + if not self.prep then + if love.timer.getTime() - self.started > Activity.LINK_SECONDS then + self:finish("gone", Text.say("gone", self.peer.name)) + end + return + end + self.peer = peerOf(self.room, self.opts.peer) + self.linked = true + end + local prep = self.prep + for _, e in ipairs(prep:poll()) do + if self:handle(e) then return end + end + if prep.state == "closed" then + return self:finish("peer", Text.say("peerCancel", self.peer.name)) + end + if prep.state == "prep" and self.state == "preparing" then + self.state = "ready" + local screen = Activity.screens[self.mode] + if screen then + self:closeUi() + screen(self) + return + end + self:show(self:readyText()) + end +end + +return Activity diff --git a/src/ui/union/gen1/Dialog.lua b/src/ui/union/gen1/Dialog.lua new file mode 100644 index 00000000..93a67ad1 --- /dev/null +++ b/src/ui/union/gen1/Dialog.lua @@ -0,0 +1,95 @@ +local Menu = require("src.ui.Menu") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") + +local Dialog = {} + +Dialog.MENU_TX, Dialog.MENU_BOTTOM = 11, 12 + +local function popIf(stack, state) + if state and stack:top() == state then + stack:pop() + return true + end + return false +end + +Dialog.popIf = popIf + +function Dialog.sfx(game, name) + return Sound.play(game.data, name) +end + +function Dialog.say(game, text, onDone) + local box = TextBox.new(game, text, onDone) + game.stack:push(box) + return box +end + +function Dialog.ask(game, text, onAnswer) + local box = TextBox.new(game, text, nil, { + choice = function(yes) onAnswer(yes and true or false) end, + }) + game.stack:push(box) + return box +end + +function Dialog.menu(game, text, labels, onChoose) + local stack = game.stack + local box, menu + local function finish(index) + popIf(stack, menu) + popIf(stack, box) + onChoose(index) + end + box = TextBox.new(game, text, nil, { stay = { onShown = function() + local items = {} + for i, label in ipairs(labels) do + items[i] = { label = Strings(label), keepOpen = true, onSelect = function() finish(i) end } + end + local th = #items * 2 + 2 + menu = Menu.new(game, items, { + tx = Dialog.MENU_TX, ty = Dialog.MENU_BOTTOM - th, th = th, tw = 20 - Dialog.MENU_TX, + keepOnCancel = true, onCancel = function() finish(#items) end, + }) + stack:push(menu) + end } }) + stack:push(box) + return box +end + +local Waiter = {} +Waiter.__index = Waiter +Waiter.isOpaque = false + +function Waiter:update() + if self.closed then return end + self.tick(self, self.game and self.game.input) +end + +function Waiter:draw() end + +function Waiter:close() + if self.closed then return end + self.closed = true + local stack = self.game.stack + popIf(stack, self) + popIf(stack, self.box) +end + +function Dialog.hold(game, text, tick) + local stack = game.stack + local waiter = setmetatable({ game = game, tick = tick, closed = false, text = text }, Waiter) + waiter.box = TextBox.new(game, text, nil, { stay = { onShown = function() + if not waiter.closed then stack:push(waiter) end + end } }) + stack:push(waiter.box) + return waiter +end + +function Dialog.pressed(input, key) + return input and input.wasPressed and input:wasPressed(key) and true or false +end + +return Dialog diff --git a/src/ui/union/gen1/Look.lua b/src/ui/union/gen1/Look.lua new file mode 100644 index 00000000..1e4e0c34 --- /dev/null +++ b/src/ui/union/gen1/Look.lua @@ -0,0 +1,138 @@ +local Avatars = require("src.online.union.Avatars") +local PaletteFX = require("src.render.PaletteFX") + +local Look = {} + +Look.LEVELS = { 0.7, 0.33, 0 } + +local stats = { grays = 0 } + +function Look.stats() + return { grays = stats.grays } +end + +function Look.trueColor() + return PaletteFX.usesGbcPack() +end + +local function levelOf(r, g, b) + local l = 0.299 * r + 0.587 * g + 0.114 * b + if l > 0.78 then return Look.LEVELS[1] end + if l > 0.42 then return Look.LEVELS[2] end + return Look.LEVELS[3] +end + +function Look.grayEntry(entry) + if entry._grayEntry ~= nil then return entry._grayEntry or nil end + local base = Avatars.imageData(entry) + if not base then + entry._grayEntry = false + return nil + end + local id = base.clone and base:clone() or base + local colors = entry.palette and entry.palette.mode == "gbc" and entry.palette.colors or nil + id:mapPixel(function(_, _, r, g, b, a) + if a == 0 then return 1, 1, 1, 0 end + if colors then + if r > 0.83 then return 1, 1, 1, 0 end + local c = colors[r > 0.5 and 2 or r > 0.17 and 3 or 4] + r, g, b = c[1] / 255, c[2] / 255, c[3] / 255 + end + local v = levelOf(r, g, b) + return v, v, v, a + end) + local gray = {} + for k, v in pairs(entry) do gray[k] = v end + gray.palette = { mode = "dmg" } + gray._imageData, gray._images, gray._lookKey, gray._lookImg = id, nil, nil, nil + gray._grayEntry = nil + entry._grayEntry = gray + stats.grays = stats.grays + 1 + return gray +end + +function Look.objColors() + if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then + return PaletteFX.ogObjWorld(), true + end + return PaletteFX.dmgObj(), false +end + +function Look.gen1Colors(playerDef, seed) + if PaletteFX.usesGbcPack() then + local colors = PaletteFX.spriteObp(playerDef, seed) + if colors then return colors, false end + return PaletteFX.dmgObj(), false + end + if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then + return PaletteFX.ogObjWorld(), true + end + return PaletteFX.dmgObj(), false +end + +local function blit(img, quad, x, y, w, flip, redraw) + if flip then + love.graphics.draw(img, quad, x + w, y, 0, -1, 1) + if redraw then PaletteFX.markSpriteRedraw(img, quad, x + w, y, -1) end + else + love.graphics.draw(img, quad, x, y) + if redraw then PaletteFX.markSpriteRedraw(img, quad, x, y, 1) end + end +end + +local OWN = {} + +local function imageFor(entry, colors) + local key = colors or OWN + if entry._lookKey == key and entry._lookImg then return entry._lookImg end + local img = Avatars.image(entry, colors) + entry._lookKey, entry._lookImg = key, img + return img +end + +local function artHeight(entry) + local id = Avatars.imageData(entry) + if not (id and id.getPixel) then return entry.h end + local keyed = entry.palette.mode ~= "rgba" + for y = 0, entry.h - 1 do + for x = 0, entry.w - 1 do + local r, _, _, a = id:getPixel(x, y) + if a > 0 and not (keyed and r > 0.83) then return entry.h - y end + end + end + return entry.h +end + +function Look.height(entry) + if type(entry) ~= "table" or entry.standin then return 0 end + if not entry._artH then entry._artH = artHeight(entry) end + return entry._artH +end + +function Look.draw(entry, footX, footY, facing, opts) + love.graphics.setColor(1, 1, 1, 1) + if type(entry) ~= "table" or entry.standin then return false end + local quads = Avatars.quads(entry) + if not quads then return false end + local frame, flip = Avatars.pose(entry, facing, 0, false) + local x = footX - entry.anchor.x + local y = footY - entry.anchor.y + local img, redraw + if entry.palette.mode == "dmg" then + local colors + colors, redraw = Look.gen1Colors(opts and opts.playerDef, opts and opts.seed) + img = imageFor(entry, colors) + elseif Look.trueColor() then + img = imageFor(entry, nil) + else + local gray = Look.grayEntry(entry) + local colors + colors, redraw = Look.objColors() + img = gray and imageFor(gray, colors) + end + if not img then return false end + blit(img, quads[frame], x, y, entry.w, flip, redraw) + return true +end + +return Look diff --git a/src/ui/union/gen1/Tag.lua b/src/ui/union/gen1/Tag.lua new file mode 100644 index 00000000..96b2abfd --- /dev/null +++ b/src/ui/union/gen1/Tag.lua @@ -0,0 +1,55 @@ +local Badge = require("src.online.union.Badge") +local Font = require("src.render.Font") + +local Tag = {} + +Tag.HOST_GEN = 1 +Tag.PAD = 2 +Tag.GLYPH_H = 8 +Tag.LIFT = 2 +Tag.FILL = { 1, 1, 1 } +Tag.EDGE = { 0, 0, 0 } + +local widths = {} + +function Tag.nameWidth(name) + local hit = widths[name] + if hit then return hit end + local w = Font.width(name) + widths[name] = w + return w +end + +function Tag.size(name, withName) + local bw, bh = Badge.size(1) + if not withName then return bw, bh end + local w = Tag.nameWidth(name) + Tag.PAD * 3 + bw + local h = math.max(Tag.GLYPH_H, bh) + Tag.PAD * 2 + return w, h +end + +function Tag.rect(x, y, name, withName) + local w, h = Tag.size(name, withName) + return math.floor(x) - math.floor(w / 2), math.floor(y) - Tag.LIFT - h, w, h +end + +function Tag.draw(x, y, name, digit, withName) + local G = love.graphics + local left, top, w, h = Tag.rect(x, y, name, withName) + if withName then + G.setColor(Tag.EDGE[1], Tag.EDGE[2], Tag.EDGE[3], 1) + G.rectangle("fill", left, top, w, h) + G.setColor(Tag.FILL[1], Tag.FILL[2], Tag.FILL[3], 1) + G.rectangle("fill", left + 1, top + 1, w - 2, h - 2) + G.setColor(0, 0, 0, 1) + Font.draw(name, left + Tag.PAD, top + Tag.PAD) + Badge.drawBeside(left + Tag.PAD + Tag.nameWidth(name), top + Tag.PAD - 1, + Tag.GLYPH_H + 2, digit, Tag.HOST_GEN, 1, Tag.PAD) + else + Badge.draw(left, top, digit, Tag.HOST_GEN, 1) + end + G.setColor(1, 1, 1, 1) + return left, top, w, h +end + +return Tag diff --git a/src/ui/union/gen1/Talk.lua b/src/ui/union/gen1/Talk.lua new file mode 100644 index 00000000..a8517f4f --- /dev/null +++ b/src/ui/union/gen1/Talk.lua @@ -0,0 +1,102 @@ +local Activity = require("src.ui.union.gen1.Activity") +local Dialog = require("src.ui.union.gen1.Dialog") +local Participant = require("src.online.union.Participant") +local Protocol2 = require("src.online.Protocol2") +local Text = require("src.ui.union.gen1.Text") + +local Talk = {} + +Talk.LABELS = { Text.MENU.battle, Text.MENU.trade, Text.MENU.cancel } +Talk.ACTIVITIES = { "xg_battle", "xg_trade" } + +local function release(presence, done) + return function() + presence:setBusy(false) + if done then done() end + end +end + +function Talk.invite(game, presence, p, activity, done) + local room = presence.room + local finish = release(presence, done) + local mode = Protocol2.XG_ACTIVITIES[activity] + local h, why = room:invite(p, activity) + if not h or h.state == "closed" then + Dialog.sfx(game, "Denied") + Dialog.say(game, Text.closed(h and h.why or why, p.name), finish) + return nil + end + presence:track(h) + Dialog.hold(game, Text.say("waiting", p.name), function(w, input) + if h.state == "accepted" then + w:close() + presence:untrack(h) + Activity.begin(game, room, mode, { peer = p, onDone = finish, session = presence }) + elseif h.state == "closed" then + w:close() + presence:untrack(h) + Dialog.sfx(game, "Denied") + Dialog.say(game, Text.closed(h.why, p.name), finish) + elseif Dialog.pressed(input, "b") then + Dialog.sfx(game, "Press_AB") + w:close() + presence:abandon(h) + Dialog.say(game, Text.say("cancelled"), finish) + end + end) + return h +end + +function Talk.begin(game, presence, member, done) + local p = presence:participant(member) + if not p or presence.state ~= "joined" then + Dialog.say(game, Text.say("gone", member and member.p and member.p.name), done) + return + end + if Participant.busy(p) then + Dialog.say(game, Text.say("busy", p.name), done) + return + end + presence:setBusy(true, member) + local finish = release(presence, done) + local function menu() + Dialog.menu(game, Text.say("what", p.name), Talk.LABELS, function(i) + local activity = Talk.ACTIVITIES[i] + if activity then + Talk.invite(game, presence, p, activity, done) + else + finish() + end + end) + end + if member.entry and (member.entry.standin or member.entry.hostStandin) then + Dialog.say(game, Text.standin(p, member.entry), menu) + else + menu() + end +end + +function Talk.incoming(game, presence, inv, done) + local room = presence.room + local p = inv.from + local name = p and p.name or nil + local finish = release(presence, done) + local member = presence:memberById(inv.fromId) + presence:setBusy(true, member) + if member and game.overworld and game.overworld.player then + member:facePlayer(game.overworld.player) + end + Dialog.sfx(game, "Safari_Zone_PA") + Dialog.ask(game, Text.say(inv.mode == "trade" and "askTrade" or "askBattle", name), function(yes) + local sent = room:reply(inv.id, yes) + if not yes then + finish() + elseif not sent then + Dialog.say(game, Text.say("timeout", name), finish) + else + Activity.begin(game, room, inv.mode, { peer = p, onDone = finish, session = presence }) + end + end) +end + +return Talk diff --git a/src/ui/union/gen1/Text.lua b/src/ui/union/gen1/Text.lua new file mode 100644 index 00000000..6b48aaa1 --- /dev/null +++ b/src/ui/union/gen1/Text.lua @@ -0,0 +1,106 @@ +local GameVersion = require("src.core.GameVersion") +local Messages = require("src.online.xgen.Messages") +local Strings = require("src.core.Strings") + +local Text = {} + +Text.MENU = { + battle = Strings.source("BATTLE"), + trade = Strings.source("TRADE"), + cancel = Strings.source("CANCEL"), +} + +local S = { + what = Strings.source("Do what with\n%s?"), + busy = Strings.source("%s looks\nbusy right now."), + waiting = Strings.source("Waiting for\n%s…"), + declined = Strings.source("%s\nsaid no."), + timeout = Strings.source("%s\ndidn't answer."), + gone = Strings.source("%s\nhas left."), + refused = Strings.source("%s can't\ndo that now."), + askBattle = Strings.source("%s wants\nto BATTLE!\fWill you accept?"), + askTrade = Strings.source("%s wants\nto TRADE!\fWill you accept?"), + linking = Strings.source("Linking up with\n%s…"), + readyBattle = Strings.source("Getting ready to\nBATTLE %s…"), + readyTrade = Strings.source("Getting ready to\nTRADE: %s…"), + peerCancel = Strings.source("%s\ncanceled."), + cancelled = Strings.source("The request was\ncanceled."), + standin = Strings.source("%s\nplays %s!\fImport %s\nto see them here."), + connecting = Strings.source("Linking to the\nUNION ROOM…"), + offline = Strings.source("No link could be\nmade.\fNo other TRAINERS\nare here for now."), + serverOld = Strings.source("The UNION ROOM\nlink is too old.\fNo other TRAINERS\nare here for now."), + clientOld = Strings.source("This game is too\nold to link up.\fUpdate it to meet\nother TRAINERS."), + joinFailed = Strings.source("The UNION ROOM\ncouldn't be used.\fNo other TRAINERS\nare here for now."), + lost = Strings.source("The link was lost.\nReconnecting…"), + lostForGood = Strings.source("The link couldn't\nbe made again.\fNo other TRAINERS\nare here for now."), +} +Text.S = S + +local function clean(name) + name = tostring(name or "") + name = name:gsub("[{}%c]", "") + if name == "" then name = "?" end + return name +end + +Text.clean = clean + +function Text.say(key, ...) + local n = select("#", ...) + local args = { ... } + for i = 1, n do args[i] = clean(args[i]) end + return Strings(S[key], unpack(args, 1, n)) +end + +function Text.gameName(version) + local info = GameVersion.VERSIONS[version or ""] + local label = info and info.label or tostring(version or "?") + return label:upper() +end + +function Text.needName(need) + if type(need) ~= "table" or not need[1] then return "?" end + return Text.gameName(need[1]) +end + +function Text.standin(p, entry) + return Text.say("standin", p and p.name, Text.gameName(p and p.game), Text.needName(entry and entry.need)) +end + +Text.CLOSED = { + declined = "declined", timeout = "timeout", busy = "busy", + offline = "gone", target_left = "gone", sender_left = "gone", +} + +function Text.closed(why, name) + return Text.say(Text.CLOSED[why or ""] or "refused", name) +end + +Text.ERRORS = { + server_outdated = "serverOld", client_outdated = "clientOld", + offline = "offline", lost = "lostForGood", +} + +function Text.error(code) + return Strings(S[Text.ERRORS[code or ""] or "joinFailed"]) +end + +function Text.blocked(code, detail) + local lines = Messages.render(code, 1, detail) + local out = {} + for i, line in ipairs(lines) do + if i > 1 then out[#out + 1] = (i % 2 == 0) and "\n" or "\f" end + out[#out + 1] = line + end + return table.concat(out) +end + +-- pokered/data/text/text_4.asm:209 +function Text.pleaseWait(game) + local text = game and game.data and game.data.text + local line = text and text._CableClubNPCPleaseWaitText + assert(type(line) == "string", "gen1 cache has no _CableClubNPCPleaseWaitText") + return line +end + +return Text diff --git a/src/ui/union/prep/Gen1BattlePrep.lua b/src/ui/union/prep/Gen1BattlePrep.lua new file mode 100644 index 00000000..0bcc1f5b --- /dev/null +++ b/src/ui/union/prep/Gen1BattlePrep.lua @@ -0,0 +1,113 @@ +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +Screen.COLS, Screen.ROWS = 20, 18 +Screen.MENU_MAX = 4 +Screen.KEYS = { "up", "down", "a", "b" } + +function Screen.layout(pg, model) + local items = pg.items or {} + local visible = math.min(#items, Screen.MENU_MAX) + local menuH = visible > 0 and visible + 2 or 0 + local infoH = Screen.ROWS - menuH + local room = infoH - 3 + local body = model:formatLines(pg.lines) + local lines = {} + if pg.pager then + local first = math.max(0, math.min(pg.scroll or 0, math.max(0, #body - room))) + for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end + return { menuH = menuH, infoH = infoH, title = model:label(pg.title), lines = lines, + more = first + room < #body, less = first > 0, visible = 0, scroll = 0, items = items } + end + lines = model.fitGroups(model:formatGroups(pg.lines), model:formatGroups(pg.info), room, + { notice = pg.notice, keepBody = pg.keepBody }) + local cursor = pg.cursor or 1 + local scroll = 0 + if visible > 0 then + scroll = math.max(0, math.min(cursor - visible, #items - visible)) + if cursor - 1 < scroll then scroll = cursor - 1 end + end + return { menuH = menuH, infoH = infoH, title = model:label(pg.title), lines = lines, + visible = visible, scroll = scroll, cursor = cursor, items = items, + more = #items > scroll + visible } +end + +function Screen.label(model, it) + local text = model:label(it.label) + if it.disabled then text = "(" .. text .. ")" end + local spans = Font.split(text) + if #spans > 17 then text = text:sub(1, spans[17].to) end + return text +end + +function Screen.open(game, model, onDone) + local self = setmetatable({ game = game, model = model, onDone = onDone, closed = false }, Screen) + game.stack:push(self) + return self +end + +function Screen:press(key) + if key == "a" or key == "b" then + local data = self.game.data + if data and data.audio then require("src.core.Sound").play(data, "Press_AB") end + end + self.model:input(key) +end + +function Screen:update(_dt) + if self.closed then return end + self.model:poll() + local input = self.game.input + if input then + for _, key in ipairs(Screen.KEYS) do + if input:wasPressed(key) then + self:press(key) + break + end + end + end + if self.model.done then self:finish() end +end + +function Screen:finish() + if self.closed then return end + self.closed = true + local stack = self.game.stack + if stack:top() == self then stack:pop() end + if self.onDone then self.onDone() end +end + +function Screen:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, Screen.COLS * 8, Screen.ROWS * 8) + local pg = self.model:page() + local L = Screen.layout(pg, self.model) + Font.drawBox(0, 0, Screen.COLS, L.infoH) + G.setColor(0, 0, 0, 1) + Font.draw(L.title, 8, 8) + for i, line in ipairs(L.lines) do Font.draw(line, 8, (1 + i) * 8) end + if pg.pager and L.more then Font.drawCode(Theme.moreArrow, 18 * 8, (L.infoH - 2) * 8) end + if L.menuH > 0 then + local top = L.infoH + Font.drawBox(0, top, Screen.COLS, L.menuH) + G.setColor(0, 0, 0, 1) + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then + local y = (top + row) * 8 + if it.chosen and L.scroll + row ~= L.cursor then Font.drawCode(Theme.cursorHollow, 8, y) end + Font.draw(Screen.label(self.model, it), 16, y) + end + end + Font.drawCode(Theme.cursor, 8, (top + L.cursor - L.scroll) * 8) + if L.more then Font.drawCode(Theme.moreArrow, 18 * 8, (top + L.menuH - 1) * 8) end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Gen1TradePrep.lua b/src/ui/union/prep/Gen1TradePrep.lua new file mode 100644 index 00000000..5b74f23c --- /dev/null +++ b/src/ui/union/prep/Gen1TradePrep.lua @@ -0,0 +1,123 @@ +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +Screen.COLS, Screen.ROWS = 20, 18 +Screen.MENU_MAX = 4 +Screen.KEYS = { "up", "down", "left", "right", "a", "b" } +Screen.LABEL_MAX = 17 + +function Screen.layout(pg, ctl) + local items = pg.items or {} + local visible = math.min(#items, Screen.MENU_MAX) + local menuH = visible > 0 and visible + 2 or 0 + local infoH = Screen.ROWS - menuH + local room = infoH - 3 + local body = ctl:formatLines(pg.lines) + local info = ctl:formatLines(pg.info) + for _, l in ipairs(info) do body[#body + 1] = l end + local maxFirst = math.max(0, #body - room) + local first = math.min(pg.scroll or 0, maxFirst) + if (pg.scroll or 0) > maxFirst then ctl.scroll = maxFirst end + local lines = {} + for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end + local cursor = pg.cursor or 1 + local scroll = 0 + if visible > 0 then + scroll = math.max(0, math.min(cursor - visible, #items - visible)) + if cursor - 1 < scroll then scroll = cursor - 1 end + end + return { menuH = menuH, infoH = infoH, title = ctl:label(pg.title), lines = lines, first = first, room = room, + more = first < maxFirst, less = first > 0, visible = visible, scroll = scroll, cursor = cursor, + items = items, listMore = #items > scroll + visible } +end + +function Screen.label(ctl, it) + local text = ctl:label(it.label) + if it.disabled then text = "(" .. text .. ")" end + local spans = Font.split(text) + if #spans > Screen.LABEL_MAX then text = text:sub(1, spans[Screen.LABEL_MAX].to) end + return text +end + +function Screen.open(game, ctl, onDone) + local self = setmetatable({ game = game, ctl = ctl, onDone = onDone, closed = false }, Screen) + game.stack:push(self) + return self +end + +function Screen.pageOn(self, key) + if key ~= "a" then return false end + local L = Screen.layout(self.ctl:page(), self.ctl) + if not L.more then return false end + self.ctl.scroll = L.first + L.room + self.blink = 0 + return true +end + +function Screen.arrowOn(self, period) + return (self.blink or 0) % period < period / 2 +end + +function Screen:press(key) + if key == "a" or key == "b" then + local data = self.game.data + if data and data.audio then require("src.core.Sound").play(data, "Press_AB") end + end + if Screen.pageOn(self, key) then return end + self.ctl:input(key) +end + +function Screen:update(dt) + if self.closed then return end + self.blink = (self.blink or 0) + 1 + self.ctl:poll(dt) + local input = self.game.input + if input then + for _, key in ipairs(Screen.KEYS) do + if input:wasPressed(key) then + self:press(key) + break + end + end + end + if self.ctl.done then self:finish() end +end + +function Screen:finish() + if self.closed then return end + self.closed = true + local stack = self.game.stack + if stack:top() == self then stack:pop() end + if self.onDone then self.onDone() end +end + +function Screen:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, Screen.COLS * 8, Screen.ROWS * 8) + local pg = self.ctl:page() + local L = Screen.layout(pg, self.ctl) + Font.drawBox(0, 0, Screen.COLS, L.infoH) + G.setColor(0, 0, 0, 1) + Font.draw(L.title, 8, 8) + for i, line in ipairs(L.lines) do Font.draw(line, 8, (1 + i) * 8) end + if L.more and Screen.arrowOn(self, 60) then Font.drawCode(Theme.moreArrow, 18 * 8, (L.infoH - 2) * 8) end + if L.menuH > 0 then + local top = L.infoH + Font.drawBox(0, top, Screen.COLS, L.menuH) + G.setColor(0, 0, 0, 1) + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then Font.draw(Screen.label(self.ctl, it), 16, (top + row) * 8) end + end + Font.drawCode(Theme.cursor, 8, (top + L.cursor - L.scroll) * 8) + if L.listMore then Font.drawCode(Theme.moreArrow, 18 * 8, (top + L.menuH - 1) * 8) end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Gen2BattlePrep.lua b/src/ui/union/prep/Gen2BattlePrep.lua new file mode 100644 index 00000000..b2dfde38 --- /dev/null +++ b/src/ui/union/prep/Gen2BattlePrep.lua @@ -0,0 +1,60 @@ +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local Gen1 = require("src.ui.union.prep.Gen1BattlePrep") +local Sound = require("src.core.Sound") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +function Screen.open(game, model, onDone) + local self = setmetatable({ game = game, model = model, onDone = onDone, closed = false }, Screen) + game.stack:push(self) + return self +end + +function Screen:wantsFillScale() return true end + +function Screen:press(key) + if key == "a" or key == "b" then + local data = self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, "Sfx_ReadText2")] then Sound.play(data, "Sfx_ReadText2") end + end + self.model:input(key) +end + +function Screen.arrow(tx, ty) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, tx * 8, ty * 8) +end + +Screen.update = Gen1.update +Screen.finish = Gen1.finish + +function Screen:draw() + local G = love.graphics + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local pg = self.model:page() + local L = Gen1.layout(pg, self.model) + Chrome.box(0, 0, Chrome.SCREEN_W, L.infoH) + Chrome.print(L.title, 1, 1) + for i, line in ipairs(L.lines) do Chrome.print(line, 1, 1 + i) end + if pg.pager and L.more then Screen.arrow(18, L.infoH - 2) end + if L.menuH > 0 then + local top = L.infoH + Chrome.box(0, top, Chrome.SCREEN_W, L.menuH) + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then + if it.chosen and L.scroll + row ~= L.cursor then Chrome.cursor(1, top + row, true) end + Chrome.print(Gen1.label(self.model, it), 2, top + row) + end + end + Chrome.cursor(1, top + L.cursor - L.scroll) + if L.more then Screen.arrow(18, top + L.menuH - 1) end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Gen2TradePrep.lua b/src/ui/union/prep/Gen2TradePrep.lua new file mode 100644 index 00000000..efa81dfe --- /dev/null +++ b/src/ui/union/prep/Gen2TradePrep.lua @@ -0,0 +1,59 @@ +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local Gen1 = require("src.ui.union.prep.Gen1TradePrep") +local Sound = require("src.core.Sound") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +function Screen.open(game, ctl, onDone) + local self = setmetatable({ game = game, ctl = ctl, onDone = onDone, closed = false }, Screen) + game.stack:push(self) + return self +end + +function Screen:wantsFillScale() return true end + +function Screen:press(key) + if key == "a" or key == "b" then + local data = self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, "Sfx_ReadText2")] then Sound.play(data, "Sfx_ReadText2") end + end + if Gen1.pageOn(self, key) then return end + self.ctl:input(key) +end + +Screen.update = Gen1.update +Screen.finish = Gen1.finish + +function Screen.arrow(tx, ty) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, tx * 8, ty * 8) +end + +function Screen:draw() + local G = love.graphics + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local pg = self.ctl:page() + local L = Gen1.layout(pg, self.ctl) + Chrome.box(0, 0, Chrome.SCREEN_W, L.infoH) + Chrome.print(L.title, 1, 1) + for i, line in ipairs(L.lines) do Chrome.print(line, 1, 1 + i) end + -- pokegold/home/joypad.asm:430 + if L.more and Gen1.arrowOn(self, 32) then Screen.arrow(18, L.infoH - 2) end + if L.menuH > 0 then + local top = L.infoH + Chrome.box(0, top, Chrome.SCREEN_W, L.menuH) + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then Chrome.print(Gen1.label(self.ctl, it), 2, top + row) end + end + Chrome.cursor(1, top + L.cursor - L.scroll) + if L.listMore then Screen.arrow(18, top + L.menuH - 1) end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Gen3BattlePrep.lua b/src/ui/union/prep/Gen3BattlePrep.lua new file mode 100644 index 00000000..ec63b3ed --- /dev/null +++ b/src/ui/union/prep/Gen3BattlePrep.lua @@ -0,0 +1,141 @@ +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") + +local Screen = {} + +Screen.ID = "union_battle_prep" +Screen.W, Screen.H = 240, 160 +Screen.INFO = Window.template(1, 1, 13, 18) +Screen.MENU = Window.template(16, 1, 13, 18) +Screen.PITCH = 15 +Screen.ROWS = 9 +Screen.KEYS = { "up", "down", "a", "b" } +Screen.TITLE = { colors = FrlgFont.COLOR.BLUE } +Screen.BODY = { colors = FrlgFont.COLOR.NORMAL } +Screen.BACKGROUND = { 0.37, 0.56, 0.71 } + +local function groups(lines, width) + local out = {} + for _, line in ipairs(lines or {}) do + local g = {} + for part in (FrlgFont.wrap(line, width) .. "\n"):gmatch("(.-)\n") do g[#g + 1] = part end + out[#out + 1] = g + end + return out +end + +local function wrapped(lines, width) + local out = {} + for _, g in ipairs(groups(lines, width)) do + for _, part in ipairs(g) do out[#out + 1] = part end + end + return out +end + +function Screen.layout(pg, model) + local width = Screen.INFO.width * 8 + local room = Screen.ROWS - 1 + local body = wrapped(pg.lines, width) + local lines = {} + local more, less = false, false + if pg.pager then + local first = math.max(0, math.min(pg.scroll or 0, math.max(0, #body - room))) + for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end + more, less = first + room < #body, first > 0 + else + lines = model.fitGroups(groups(pg.lines, width), groups(pg.info, width), room, + { notice = pg.notice, keepBody = pg.keepBody }) + end + local items = pg.items or {} + local visible = math.min(#items, Screen.ROWS) + local cursor = pg.cursor or 1 + local scroll = 0 + if visible > 0 and cursor > visible then scroll = cursor - visible end + return { lines = lines, more = more, less = less, items = items, visible = visible, scroll = scroll, + cursor = cursor, listMore = #items > scroll + visible } +end + +function Screen.open(game, model, onDone) + local mod = {} + local closed = false + local function finish() + if closed then return end + closed = true + Stack.pop(Screen.ID) + if onDone then onDone() end + end + function mod.update(_dt) + if closed then return end + model:poll() + if model.done then finish() end + end + function mod.handleInput(input) + if closed or not input then return end + for _, key in ipairs(Screen.KEYS) do + if input:wasPressed(key) then + if key == "a" or key == "b" then + local okA, Audio = pcall(require, "src.core.game3.audio") + local okS, SE = pcall(require, "src.core.game3.se_ids") + if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end + end + model:input(key) + break + end + end + if model.done then finish() end + end + function mod.draw() + Screen.draw(model) + end + mod.model = model + mod.finish = finish + Stack.push(Screen.ID, mod, { hideBelow = true, fullscreen = true }) + return mod +end + +function Screen.arrow(px, py) + local c = FrlgFont.COLOR.NORMAL.fg + love.graphics.setColor(c[1] or 0, c[2] or 0, c[3] or 0, 1) + love.graphics.polygon("fill", px, py, px + 6, py, px + 3, py + 4) + love.graphics.setColor(1, 1, 1, 1) +end + +function Screen.draw(model) + local G = love.graphics + local bg = Screen.BACKGROUND + G.setColor(bg[1], bg[2], bg[3], 1) + G.rectangle("fill", 0, 0, Screen.W, Screen.H) + G.setColor(1, 1, 1, 1) + local pg = model:page() + local L = Screen.layout(pg, model) + local info, menu = Screen.INFO, Screen.MENU + Window.stdFrame(info) + local x, y = info.left * 8, info.top * 8 + Window.printPx(pg.title or "", x, y, Screen.TITLE) + for i, line in ipairs(L.lines) do + Window.printPx(line, x, y + i * Screen.PITCH, Screen.BODY) + end + if L.more then Screen.arrow(x + info.width * 8 - 8, y + Screen.ROWS * Screen.PITCH - 4) end + if L.visible > 0 then + Window.stdFrame(menu) + local mx, my = menu.left * 8, menu.top * 8 + local maxW = menu.width * 8 - Window.CURSOR_WIDTH + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then + local ry = my + (row - 1) * Screen.PITCH + local opt = it.chosen and Screen.TITLE or Screen.BODY + local label = it.disabled and ("(" .. (it.label or "") .. ")") or (it.label or "") + Window.printPx(label, mx + Window.CURSOR_WIDTH, ry, { colors = opt.colors, maxWidth = maxW }) + end + end + Window.cursorPx(mx, my + (L.cursor - L.scroll - 1) * Screen.PITCH) + if L.listMore then + Screen.arrow(mx + menu.width * 8 - 8, my + Screen.ROWS * Screen.PITCH - 4) + end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Gen3TradePrep.lua b/src/ui/union/prep/Gen3TradePrep.lua new file mode 100644 index 00000000..62334472 --- /dev/null +++ b/src/ui/union/prep/Gen3TradePrep.lua @@ -0,0 +1,144 @@ +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") + +local Screen = {} + +Screen.ID = "union_trade_prep" +Screen.W, Screen.H = 240, 160 +Screen.INFO = Window.template(1, 1, 17, 18) +Screen.MENU = Window.template(20, 1, 9, 18) +Screen.PITCH = 15 +Screen.ROWS = 9 +Screen.KEYS = { "up", "down", "left", "right", "a", "b" } +Screen.TITLE = { colors = FrlgFont.COLOR.BLUE } +Screen.BODY = { colors = FrlgFont.COLOR.NORMAL } +Screen.BACKGROUND = { 0.37, 0.56, 0.71 } + +local function wrapped(lines, width) + local out = {} + for _, line in ipairs(lines or {}) do + local text = FrlgFont.wrap(line, width) + for part in (text .. "\n"):gmatch("(.-)\n") do out[#out + 1] = part end + end + return out +end + +function Screen.layout(pg, ctl) + local width = Screen.INFO.width * 8 + local room = Screen.ROWS - 1 + local body = wrapped(pg.lines, width) + for _, l in ipairs(wrapped(pg.info, width)) do body[#body + 1] = l end + local maxFirst = math.max(0, #body - room) + local first = math.min(pg.scroll or 0, maxFirst) + if ctl and (pg.scroll or 0) > maxFirst then ctl.scroll = maxFirst end + local lines = {} + for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end + local items = pg.items or {} + local visible = math.min(#items, Screen.ROWS) + local cursor = pg.cursor or 1 + local scroll = 0 + if visible > 0 and cursor > visible then scroll = cursor - visible end + return { lines = lines, first = first, room = room, more = first < maxFirst, items = items, visible = visible, scroll = scroll, + cursor = cursor, listMore = #items > scroll + visible } +end + +function Screen.pageOn(ctl, key) + if key ~= "a" then return false end + local L = Screen.layout(ctl:page(), ctl) + if not L.more then return false end + ctl.scroll = L.first + L.room + return true +end + +function Screen.open(_game, ctl, onDone) + local mod = { ticks = 0 } + local closed = false + local function finish() + if closed then return end + closed = true + Stack.pop(Screen.ID) + if onDone then onDone() end + end + function mod.update(dt) + if closed then return end + mod.ticks = mod.ticks + 1 + ctl:poll(dt) + if ctl.done then finish() end + end + function mod.handleInput(input) + if closed or not input then return end + for _, key in ipairs(Screen.KEYS) do + if input:wasPressed(key) then + if key == "a" or key == "b" then + local okA, Audio = pcall(require, "src.core.game3.audio") + local okS, SE = pcall(require, "src.core.game3.se_ids") + if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end + end + if Screen.pageOn(ctl, key) then mod.ticks = 0 else ctl:input(key) end + break + end + end + if ctl.done then finish() end + end + function mod.draw() Screen.draw(ctl, mod.ticks) end + mod.ctl = ctl + mod.finish = finish + Stack.push(Screen.ID, mod, { hideBelow = true, fullscreen = true }) + return mod +end + +function Screen.arrow(px, py) + local c = FrlgFont.COLOR.NORMAL.fg + love.graphics.setColor(c[1] or 0, c[2] or 0, c[3] or 0, 1) + love.graphics.polygon("fill", px, py, px + 6, py, px + 3, py + 4) + love.graphics.setColor(1, 1, 1, 1) +end + +local BOUNCE = { 0, 1, 2, 3, 2, 1 } + +function Screen.moreArrow(px, py, ticks) + local Chrome = require("src.ui.game3.chrome") + local rse = Chrome.arrowSpec() + local frame + if rse then + frame = math.floor(ticks / (rse.period or ((rse.delay or 0) + 1))) + else + frame = BOUNCE[1 + math.floor(ticks / 8) % #BOUNCE] + end + Chrome.promptArrow(px, py, frame) +end + +function Screen.draw(ctl, ticks) + local G = love.graphics + local bg = Screen.BACKGROUND + G.setColor(bg[1], bg[2], bg[3], 1) + G.rectangle("fill", 0, 0, Screen.W, Screen.H) + G.setColor(1, 1, 1, 1) + local pg = ctl:page() + local L = Screen.layout(pg, ctl) + local info, menu = Screen.INFO, Screen.MENU + Window.stdFrame(info) + local x, y = info.left * 8, info.top * 8 + Window.printPx(pg.title or "", x, y, Screen.TITLE) + for i, line in ipairs(L.lines) do Window.printPx(line, x, y + i * Screen.PITCH, Screen.BODY) end + if L.more then Screen.moreArrow(x + info.width * 8 - 10, y + Screen.ROWS * Screen.PITCH - 8, ticks or 0) end + if L.visible > 0 then + Window.stdFrame(menu) + local mx, my = menu.left * 8, menu.top * 8 + local maxW = menu.width * 8 - Window.CURSOR_WIDTH + for row = 1, L.visible do + local it = L.items[L.scroll + row] + if it then + local label = it.disabled and ("(" .. (it.label or "") .. ")") or (it.label or "") + Window.printPx(label, mx + Window.CURSOR_WIDTH, my + (row - 1) * Screen.PITCH, + { colors = Screen.BODY.colors, maxWidth = maxW }) + end + end + Window.cursorPx(mx, my + (L.cursor - L.scroll - 1) * Screen.PITCH) + if L.listMore then Screen.arrow(mx + menu.width * 8 - 8, my + Screen.ROWS * Screen.PITCH - 4) end + end + G.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/union/prep/Open.lua b/src/ui/union/prep/Open.lua new file mode 100644 index 00000000..29a26c06 --- /dev/null +++ b/src/ui/union/prep/Open.lua @@ -0,0 +1,89 @@ +local Model = require("src.online.union.BattlePrepModel") + +local Open = {} + +Open.RENDERERS = { + [1] = "src.ui.union.prep.Gen1BattlePrep", + [2] = "src.ui.union.prep.Gen2BattlePrep", + [3] = "src.ui.union.prep.Gen3BattlePrep", +} + +function Open.source(game, gen) + if gen == 3 then + local session = game and game.session + if not session then + local ok, Runtime = pcall(require, "src.core.game3.runtime") + session = ok and Runtime.getSession and Runtime.getSession() or nil + end + if not session then return { party = {}, generation = 3 } end + local PartyView = require("src.core.game3.battle.party_view") + local party = PartyView.fromSession(session.party, session.move_overlay) + return { party = party, save = { storage = session.storage }, generation = 3 } + end + local save = game and game.save or {} + return { party = save.party or {}, save = save, generation = gen } +end + +function Open.opponent(room, prep) + local r = room and room.xgRoom and room:xgRoom() or nil + local mine = prep and prep.seat and prep:seat() or nil + for _, p in ipairs(r and r.players or {}) do + if mine == nil or p.seat ~= mine then + local av = type(p.avatar) == "table" and p.avatar or {} + return { name = av.name or p.name, version = av.version, gen = p.gen or av.gen } + end + end + return { name = "?" } +end + +function Open.gameplayMods(game) + return require("src.online.union.Caps").gameplayMods(game) +end + +function Open.model(game, room, prep, opts) + opts = opts or {} + local GameVersion = require("src.core.GameVersion") + local version = opts.version or GameVersion.get() + local gen = GameVersion.generation(version) + local mods = opts.gameplayMods + if mods == nil then mods = Open.gameplayMods(game) end + return Model.new({ + version = version, gen = gen, data = opts.data, prep = prep, + opponent = opts.opponent or Open.opponent(room, prep), + gameplayMods = mods, owned = opts.owned or Open.source(game, gen), rules = opts.rules, + }) +end + +function Open.battle(game, room, prep, onDone, opts) + local model = Open.model(game, room, prep, opts) + local Renderer = require(Open.RENDERERS[model.gen] or Open.RENDERERS[1]) + local screen = Renderer.open(game, model, function() + local result, outcome = model:result(), model.outcome + model:discard() + if outcome ~= "go" and prep and prep.leave then prep:leave() end + if onDone then onDone(result, outcome) end + end) + return screen, model +end + +function Open.screen(act, opts) + opts = opts or {} + local peer = act.peer or {} + opts.opponent = opts.opponent or { name = peer.name, version = peer.game, gen = peer.gen } + return Open.battle(act.game, act.room, act.prep, function(result, outcome) + act.battlePrep = result + act:finish(outcome == "go" and "go" or outcome or "cancel") + end, opts) +end + +function Open.register(Activity) + if type(Activity) == "table" and type(Activity.screens) == "table" then + Activity.screens.battle = Open.screen + end + return Activity +end + +Open.register(require("src.ui.gen2.union.Activity")) +Open.register(require("src.ui.union.gen1.Activity")) + +return Open diff --git a/src/ui/union/prep/OpenTrade.lua b/src/ui/union/prep/OpenTrade.lua new file mode 100644 index 00000000..e792bbc2 --- /dev/null +++ b/src/ui/union/prep/OpenTrade.lua @@ -0,0 +1,636 @@ +local GameVersion = require("src.core.GameVersion") +local Messages = require("src.online.xgen.Messages") +local Model = require("src.online.union.TradePrepModel") +local Project = require("src.online.xgen.Project") +local Txn = require("src.online.union.TradeTxn") + +local Open = {} + +Open.RENDERERS = { + [1] = "src.ui.union.prep.Gen1TradePrep", + [2] = "src.ui.union.prep.Gen2TradePrep", + [3] = "src.ui.union.prep.Gen3TradePrep", +} + +local TEXT = { + title_pick = "Union Trade", + title_offer = "Your Offer", + title_moves = "Moves", + title_wait = "Union Trade", + title_confirm = "Final Trade", + title_trading = "Trading", + title_done = "Trade Done", + title_closed = "Union Trade", + + with = "Trade with {name}.", + plays = "{name} is playing {game}.", + feature = "Trades between generations are a feature of this app, not of the original games.", + pick = "Choose a Pokémon to offer.", + rules_wait = "Waiting for the trade to open…", + becomes = "In {game} it becomes {species} {level}.", + no_change = "Nothing else changes.", + changes = "Permanent changes:", + blocked = "It can't be offered yet:", + pick_move = "Choose a move {species} can learn in {game}, or remove the move.", + move_row = "{type} Power {power} PP {pp}", + evolves = "{species} may evolve when it arrives.", + wait_offer = "Waiting for {name} to offer a Pokémon…", + sent = "You offered {species}.", + their_offer = "{name} offers {species} {level}.", + refused = "{name}'s offer can't be accepted here:", + refused_peer = "{name} can't accept your offer:", + refused_unknown = "Their game can't take it as it is.", + you_send = "You send {species} {level}.", + you_get = "You get {species} {level} from {name}.", + their_changes = "Changes to what you get:", + my_changes = "Changes to what you send:", + final_note = "This is final. The sent Pokémon is gone from your game for good.", + wait_ready = "Waiting for {name} to agree…", + changed = "An offer changed. Check the trade again.", + trading = "Trading… Don't turn off the power.", + save_failed = "The game couldn't be saved. Trying again…", + unresolved = "The link was lost during the trade. It will be finished when the server answers.", + done = "{name} sent {species}!", + evolved = "{species} evolved into {into}!", + again = "Trade again?", + closed_cancel = "{name} stopped trading.", + closed_self = "Trading stopped.", + closed_gone = "The link to {name} was lost.", + closed_other = "The trade was cancelled.", + aborted = "The trade didn't happen. Both Pokémon stay where they were.", + recheck = "Your Pokémon changed after it was offered, so the trade was stopped.", + + item_offer = "Offer", + item_moves = "Fix moves", + item_back = "Back", + item_trade = "Trade", + item_change = "Change", + item_stop = "Stop", + item_remove = "Remove move", + item_ok = "OK", + item_yes = "Yes", + item_no = "No", + item_details = "Details", + place_party = "party", + place_box = "Box {box}", +} + +local CODE_TEXT = { + rental = "Rental Pokémon can't be traded.", + projection = "Battle copies can't be traded.", + pending = "Its last trade isn't settled yet.", + not_owned = "That Pokémon isn't there anymore.", + preview_mismatch = "Their game and this one disagree about how it converts. Both players need the same games imported.", + wrong_destination = "It was meant for another game.", + wrong_source = "It doesn't match the other player's game.", + bad_payload = "Its data can't be read.", + unsupported_version = "Its game isn't supported.", + not_valid_here = "This game can't store it as it is.", + too_big = "It carries too much data to send.", + not_ready = "It can't be offered yet.", +} + +local function fill(template, args) + args = args or {} + return (template:gsub("{(%w+)}", function(k) + local v = args[k] + return v ~= nil and tostring(v) or "" + end)) +end + +local function say(key, args) return fill(TEXT[key] or key, args) end +Open.say = say +Open.TEXT = TEXT +Open.REFUSED = "refused:" + +local function gameName(version) + local info = GameVersion.VERSIONS[version or ""] + return "Pokémon " .. (info and info.label or tostring(version or "?")) +end + +local function item(id, label, extra) + local out = { id = id, label = label } + for k, v in pairs(extra or {}) do out[k] = v end + return out +end + +function Open.opponent(room, prep) + local r = room and room.xgRoom and room:xgRoom() or nil + local mine = prep and prep.seat and prep:seat() or nil + for _, p in ipairs(r and r.players or {}) do + if mine == nil or p.seat ~= mine then + local av = type(p.avatar) == "table" and p.avatar or {} + return { name = av.name or p.name, version = av.version, gen = p.gen or av.gen } + end + end + return { name = "?" } +end + +local Ctl = {} +Ctl.__index = Ctl +Open.Controller = Ctl + +function Ctl.new(opts) + local self = setmetatable({}, Ctl) + self.game = opts.game + self.prep = opts.prep + self.version = opts.version or GameVersion.get() + self.gen = GameVersion.generation(self.version) + self.opponent = opts.opponent or { name = "?" } + self.adapter = opts.adapter or Txn.newAdapter(self.game, self.version) + self.model = Model.new({ version = self.version, peerVersion = self.opponent.version, peerName = self.opponent.name, + owned = self.adapter:owned() }) + self.txn = Txn.new({ game = self.game, prep = self.prep, model = self.model, adapter = self.adapter, + peerName = self.opponent.name, roomId = opts.roomId }) + self.step = "pick" + self.cursor = 1 + self.scroll = 0 + self.events = {} + self.done = false + self.sentOffer = nil + self.myData = Model.datasetFor(self.version) + return self +end + +function Ctl:style() return Messages.style(self.gen) end + +function Ctl:label(text) + if self:style() == "gb" then return (text or ""):upper() end + return text or "" +end + +function Ctl:formatLines(lines) + local out = {} + for _, line in ipairs(lines or {}) do + for _, l in ipairs(Messages.format(line, self.gen)) do out[#out + 1] = l end + end + return out +end + +function Ctl:levelLabel(level) + if level == nil then return "" end + if self.gen <= 2 then return ":L" .. tostring(level) end + return "Lv" .. tostring(level) +end + +function Ctl:names(data) + data = data or self.myData + return { + species = function(n) local sp = data and data.species[tonumber(n) or -1] return sp and sp.name or ("No. " .. tostring(n)) end, + move = function(m) local mv = data and data.moves[tonumber(m) or -1] return mv and mv.name or ("move " .. tostring(m)) end, + item = function(i) return tostring(i) end, + } +end + +function Ctl:monLabel(entry) + local view = entry and entry.rec and self.myData and Project.read(entry.rec, self.myData) + local name = view and self.myData.species[view.national] and self.myData.species[view.national].name or "?" + if view and view.isEgg then name = "Egg" end + return name, view and view.level +end + +function Ctl:go(step) + self.step = step + self.cursor = 1 + self.scroll = 0 +end + +function Ctl:close(key, args, reason) + self.closedText = say(key, args) + self.closedReason = reason + self:go("closed") +end + +function Ctl:reasonText(code, detail) + if code == nil or code == "" then return say("refused_unknown") end + if CODE_TEXT[code] then return CODE_TEXT[code] end + if Messages.known(code) then return Messages.text(code, detail, self:names()) end + return say("refused_unknown") +end + +function Ctl:blockLines(report, data) + local lines = {} + local names = self:names(data) + for _, b in ipairs(report.blocks or {}) do + local text = CODE_TEXT[b.code] or Messages.text(b.code, b.detail, names) + lines[#lines + 1] = "- " .. text + end + return lines +end + +function Ctl:changeLines(changes) + local lines, seen = {}, {} + for _, c in ipairs(changes or {}) do + local text = Messages.change(c, 3)[1] + if text and not seen[text] then + seen[text] = true + lines[#lines + 1] = "- " .. text + end + end + return lines +end + +function Ctl:pagePick() + local lines = { say("with", { name = self.opponent.name }) } + if self.opponent.version then + lines[#lines + 1] = say("plays", { name = self.opponent.name, game = gameName(self.opponent.version) }) + end + local items = {} + if self.prep and self.prep.state == "rules_wait" then + lines[#lines + 1] = say("rules_wait") + items[#items + 1] = item("stop", TEXT.item_stop) + return { title = TEXT.title_pick, lines = lines, items = items } + end + lines[#lines + 1] = say("pick") + for i, entry in ipairs(self.model.owned) do + local name, level = self:monLabel(entry) + local place = entry.ref.where == "box" and say("place_box", { box = entry.ref.box }) or nil + local label = name .. " " .. self:levelLabel(level) + items[#items + 1] = item("mon", label, { arg = i, disabled = entry.locked ~= nil, + detail = { place and (place .. ".") or nil, entry.locked and (CODE_TEXT[entry.locked] or Messages.text(entry.locked)) or nil } }) + end + items[#items + 1] = item("stop", TEXT.item_stop) + return { title = TEXT.title_pick, lines = lines, items = items } +end + +function Ctl:pageOffer() + local m = self.model.mine + local r = m and m.report or {} + local lines = {} + local items = {} + if r.ok then + lines[#lines + 1] = say("becomes", { game = gameName(self.opponent.version), species = r.speciesName, + level = self:levelLabel(r.level) }) + local changes = self:changeLines(r.changes) + if #changes == 0 then lines[#lines + 1] = say("no_change") else + lines[#lines + 1] = say("changes") + for _, l in ipairs(changes) do lines[#lines + 1] = l end + end + if r.evolves then lines[#lines + 1] = say("evolves", { species = r.speciesName }) end + items[#items + 1] = item("offer", TEXT.item_offer) + else + lines[#lines + 1] = say("blocked") + for _, l in ipairs(self:blockLines(r, self.myData)) do lines[#lines + 1] = l end + if r.options and r.options.moves and next(r.options.moves) then items[#items + 1] = item("moves", TEXT.item_moves) end + end + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_offer, lines = lines, items = items, pager = self.pager } +end + +function Ctl:moveSlot() + local m = self.model.mine + local opts = m and m.report and m.report.options and m.report.options.moves + if not opts then return nil end + local slots = {} + for slot in pairs(opts) do slots[#slots + 1] = slot end + table.sort(slots) + return slots[1], opts[slots[1]] +end + +function Ctl:pageMoves() + local slot, list = self:moveSlot() + local peerData = Model.datasetFor(self.opponent.version) + local r = self.model.mine and self.model.mine.report or {} + local lines = { say("pick_move", { species = r.sourceName or "?", game = gameName(self.opponent.version) }) } + local items = {} + for _, move in ipairs(list or {}) do + local mv = peerData and peerData.moves[move] + if mv then + items[#items + 1] = item("move", mv.name, { arg = { slot = slot, move = move }, + detail = { say("move_row", { type = mv.type or "?", power = mv.power, pp = mv.pp }) } }) + end + end + if slot then items[#items + 1] = item("remove", TEXT.item_remove, { arg = { slot = slot } }) end + items[#items + 1] = item("back", TEXT.item_back) + return { title = TEXT.title_moves, lines = lines, items = items } +end + +function Ctl:peerOfferLines() + local p = self.model.peer + local lines = {} + if p then + lines[#lines + 1] = say("their_offer", { name = self.opponent.name, species = p.report.speciesName, + level = self:levelLabel(p.report.level) }) + elseif self.model.refusal then + lines[#lines + 1] = say("refused", { name = self.opponent.name }) + local code = self.model.refusal.code + lines[#lines + 1] = "- " .. self:reasonText(code, self.model.refusal.detail) + end + return lines +end + +function Ctl:pageWait() + local m = self.model.mine + local lines = {} + if m and m.report and m.report.ok then + lines[#lines + 1] = say("sent", { species = m.report.sourceName }) + end + local peer = self:peerOfferLines() + if #peer == 0 then lines[#lines + 1] = say("wait_offer", { name = self.opponent.name }) end + for _, l in ipairs(peer) do lines[#lines + 1] = l end + local items = { item("change", TEXT.item_change), item("stop", TEXT.item_stop) } + return { title = TEXT.title_wait, lines = lines, items = items } +end + +function Ctl:confirmLines() + local s = self.model:summary() + local lines = {} + local mine, theirs = s.mine, s.theirs + lines[#lines + 1] = say("you_send", { species = mine.sourceName, level = self:levelLabel(mine.level) }) + lines[#lines + 1] = say("becomes", { game = gameName(self.opponent.version), species = mine.speciesName, + level = self:levelLabel(mine.level) }) + local mc = self:changeLines(mine.changes) + if #mc > 0 then + lines[#lines + 1] = say("my_changes") + for _, l in ipairs(mc) do lines[#lines + 1] = l end + end + if mine.evolves then lines[#lines + 1] = say("evolves", { species = mine.speciesName }) end + lines[#lines + 1] = say("you_get", { species = theirs.speciesName, level = self:levelLabel(theirs.level), + name = self.opponent.name }) + local tc = self:changeLines(theirs.changes) + if #tc > 0 then + lines[#lines + 1] = say("their_changes") + for _, l in ipairs(tc) do lines[#lines + 1] = l end + end + if theirs.evolves then lines[#lines + 1] = say("evolves", { species = theirs.speciesName }) end + lines[#lines + 1] = say("final_note") + lines[#lines + 1] = say("feature") + return lines +end + +function Ctl:pageConfirm() + local lines = self:confirmLines() + if self.notice then table.insert(lines, 1, self.notice) end + local items = { item("trade", TEXT.item_trade), item("change", TEXT.item_change), item("stop", TEXT.item_stop) } + return { title = TEXT.title_confirm, lines = lines, items = items, pager = self.pager } +end + +function Ctl:pageReady() + return { title = TEXT.title_confirm, lines = { say("wait_ready", { name = self.opponent.name }) }, + items = { item("change", TEXT.item_change), item("stop", TEXT.item_stop) } } +end + +function Ctl:pageTrading() + local key = "trading" + if self.txn.state == "saving" and self.txn.saveFailed then key = "save_failed" end + if self.txn.state == "unresolved" then key = "unresolved" end + local items = {} + if self.txn.state == "unresolved" then items[1] = item("leave", TEXT.item_ok) end + return { title = TEXT.title_trading, lines = { say(key) }, items = items } +end + +function Ctl:pageDone() + local r = self.result or {} + local lines = { say("done", { name = self.opponent.name, species = r.species or "?" }) } + if r.evolvedName then lines[#lines + 1] = say("evolved", { species = r.species, into = r.evolvedName }) end + lines[#lines + 1] = say("again") + return { title = TEXT.title_done, lines = lines, items = { item("again", TEXT.item_yes), item("stop", TEXT.item_no) } } +end + +function Ctl:pageClosed() + local lines = { self.closedText or say("closed_other") } + if self.closedReason then lines[2] = "- " .. self.closedReason end + return { title = TEXT.title_closed, lines = lines, items = { item("leave", TEXT.item_ok) } } +end + +local PAGES = { pick = Ctl.pagePick, offer = Ctl.pageOffer, moves = Ctl.pageMoves, wait = Ctl.pageWait, + confirm = Ctl.pageConfirm, ready = Ctl.pageReady, trading = Ctl.pageTrading, done = Ctl.pageDone, + closed = Ctl.pageClosed } + +function Ctl:page() + local pg = PAGES[self.step](self) + pg.step = self.step + if #pg.items > 0 then + if self.cursor > #pg.items then self.cursor = #pg.items end + if self.cursor < 1 then self.cursor = 1 end + end + pg.cursor = self.cursor + pg.scroll = self.scroll + local sel = pg.items[self.cursor] + pg.info = sel and sel.detail or nil + return pg +end + +function Ctl:sendOffer() + local payload, digest = self.model:payload() + if not payload then return false end + self.sentOffer = { payload = payload, digest = digest } + self.prep:offer(payload, digest) + return true +end + +function Ctl:canConfirm() + local p = self.prep + return self.model.mine and self.model.mine.digest16 and self.model.peer ~= nil + and p.mine.offer ~= nil and p.peer.offer ~= nil and p.state == "prep" +end + +function Ctl:receivePeer() + local offer = self.prep.peer.offer + if not (offer and offer.payload) then return end + if self.seenPeer == offer then return end + self.seenPeer = offer + self.model:receive(offer.payload, function(final) return self.adapter:validate(final) end) + local r = self.model.refusal + if r and self.step ~= "trading" and self.step ~= "done" then + self.prep:cancel(Open.REFUSED .. tostring(r.code)) + self:close("refused", { name = self.opponent.name }, self:reasonText(r.code, r.detail)) + end +end + +function Ctl:resetRound() + self.model.owned = self.adapter:owned() + self.model.mine, self.model.peer, self.model.refusal = nil, nil, nil + self.sentOffer, self.seenPeer, self.notice = nil, nil, nil + self.txn:nextRound() +end + +function Ctl:handlePrep(e) + local k = e.kind + if k == "closed" then + if self.step == "trading" or self.step == "closed" then return end + local why = e.why + local refused = type(e.detail) == "string" and e.detail:sub(1, #Open.REFUSED) == Open.REFUSED + and e.detail:sub(#Open.REFUSED + 1) or nil + if why == "cancel" and refused and e.seat ~= nil and e.seat ~= self.prep:seat() then + self:close("refused_peer", { name = self.opponent.name }, self:reasonText(refused)) + elseif why == "cancel" and e.seat ~= nil and e.seat ~= self.prep:seat() then + self:close("closed_cancel", { name = self.opponent.name }) + elseif why == "cancel" then + self:close("closed_self") + elseif why == "gone" or why == "left" then + self:close("closed_gone", { name = self.opponent.name }) + else + self:close("closed_other") + end + elseif k == "nack" and e.of == "xg_offer" and self.sentOffer then + self.prep:offer(self.sentOffer.payload, self.sentOffer.digest) + elseif k == "invalidated" or (k == "nack" and e.of == "xg_ready") then + if self.step == "ready" then + self.notice = say("changed") + self:go("confirm") + end + elseif k == "go" then + if self.txn:confirm() then + self:go("trading") + else + self:close(self.txn.why and self.txn.why:find("^recheck") and "recheck" or "aborted") + end + elseif k == "trade_round" and self.step == "pick" then + self:resetRound() + end +end + +function Ctl:handleTxn(e) + if e.kind == "done" then + local final = self.model.peer and self.model.peer.report + local evolvedName + if e.evolved and self.myData then + local view = self.adapter:pack(self.adapter:at(self.model.mine.ref)) + local v = view and Project.read(view, self.myData) + evolvedName = v and self.myData.species[v.national] and self.myData.species[v.national].name + end + self.result = { species = final and final.speciesName, evolvedName = evolvedName } + self:go("done") + elseif e.kind == "aborted" then + self:close("aborted") + elseif e.kind == "failed" then + self:close("closed_other") + end +end + +function Ctl:poll(dt) + if self.done then return {} end + local p = self.prep + if p then + for _, e in ipairs(p:poll()) do self:handlePrep(e) end + end + for _, e in ipairs(self.txn:pump(dt or 0)) do self:handleTxn(e) end + if self.step ~= "closed" and self.step ~= "trading" and self.step ~= "done" and p then + self:receivePeer() + if (self.step == "wait") and self:canConfirm() then self:go("confirm") end + if self.step == "confirm" and not self:canConfirm() then self:go("wait") end + if self.step == "ready" and not p.mine.ready and p.state == "prep" then + self.notice = say("changed") + self:go(self:canConfirm() and "confirm" or "wait") + end + if p.state == "closed" and self.step ~= "closed" then self:handlePrep({ kind = "closed", why = p.closed and p.closed.why }) end + end + local out = self.events + self.events = {} + return out +end + +function Ctl:choose(it) + if not it then return end + local id = it.id + if it.disabled then return end + if id == "stop" or id == "leave" then + if self.step ~= "closed" and self.step ~= "done" then self.prep:cancel("cancel") end + self:finish() + elseif id == "mon" then + self.model:choose(it.arg) + self:go("offer") + elseif id == "back" then + if self.step == "moves" then self:go("offer") else self:go("pick") end + elseif id == "moves" then + self:go("moves") + elseif id == "move" then + self.model:stage(it.arg.slot, it.arg.move) + self:go("offer") + elseif id == "remove" then + self.model:stage(it.arg.slot, false) + self:go("offer") + elseif id == "offer" then + if self:sendOffer() then self:go("wait") end + elseif id == "change" then + self.notice = nil + self:go("pick") + elseif id == "trade" then + local digest = self.txn:readyDigest() + if digest and self.prep:ready(digest) then + self.notice = nil + self:go("ready") + end + elseif id == "again" then + self:resetRound() + self:go("pick") + end +end + +function Ctl:input(key) + if self.done then return end + local pg = self:page() + local n = #pg.items + if key == "up" then + if n > 0 then self.cursor = self.cursor > 1 and self.cursor - 1 or n end + elseif key == "down" then + if n > 0 then self.cursor = self.cursor < n and self.cursor + 1 or 1 end + elseif key == "left" then + self.scroll = math.max(0, self.scroll - 1) + elseif key == "right" then + self.scroll = self.scroll + 1 + elseif key == "a" then + self:choose(pg.items[self.cursor]) + elseif key == "b" then + if self.step == "moves" then self:go("offer") + elseif self.step == "offer" then self:go("pick") + elseif self.step == "closed" then self:finish() + end + end +end + +function Ctl:finish() + if self.done then return end + self.done = true + self.txn:close() +end + +function Open.controller(game, room, prep, opts) + opts = opts or {} + return Ctl.new({ game = game, prep = prep, version = opts.version, adapter = opts.adapter, roomId = opts.roomId, + opponent = opts.opponent or Open.opponent(room, prep) }) +end + +local function isActivity(v) + return type(v) == "table" and v.prep ~= nil and type(v.finish) == "function" and v.game ~= nil +end + +function Open.trade(game, room, prep, onDone, opts) + local act + if isActivity(game) then + act = game + opts = room or {} + game, room, prep = act.game, act.room, act.prep + local peer = act.peer or {} + opts.opponent = opts.opponent or { name = peer.name, version = peer.game, gen = peer.gen } + onDone = function(result) + if prep and prep.open and prep:open() then + if not result then prep:cancel("cancel") end + prep:leave() + end + act:finish(result and "traded" or "cancel") + end + end + local ctl = Open.controller(game, room, prep, opts) + local Renderer = require(Open.RENDERERS[ctl.gen] or Open.RENDERERS[1]) + local screen = Renderer.open(game, ctl, function() + if onDone then onDone(ctl.result) end + end) + return screen, ctl +end + +Open.ACTIVITIES = { "src.ui.gen2.union.Activity", "src.ui.union.gen1.Activity" } + +function Open.register(Activity) + if type(Activity) ~= "table" then return false end + Activity.screens = Activity.screens or {} + Activity.screens.trade = function(act) return Open.trade(act) end + return true +end + +for _, path in ipairs(Open.ACTIVITIES) do + if type(package.loaded[path]) == "table" then Open.register(package.loaded[path]) end +end + +return Open diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 65ca742b..ef96498d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3980,13 +3980,10 @@ end -- for the original serial handshake; declining prints "Please come again!" function OverworldState:cableClubReceptionist(onDone) local t = Game.data.text - if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then - Game.stack:push(TextBox.new(Game, - t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone)) - return - end local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!") - if not Game.save.flags.EVENT_GOT_POKEDEX then + -- engine/link/cable_club_npc.asm:4 + if require("src.world.PikachuFollower").isFollowingDisabled(self) + or not Game.save.flags.EVENT_GOT_POKEDEX then -- CableClubNPC .didNotConnect path before the pokedex Game.stack:push(TextBox.new(Game, welcome .. "\f" .. (t._CableClubNPCMakingPreparationsText diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 8101e7e3..3dfbfd69 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -339,6 +339,8 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad) -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil ow.pikachuFanClubScene = nil + -- home/overworld.asm:1895 + if viaMapLoad and not (opts and opts.keepPikachu) then ow.pikachuPewterSleepScene = nil end remove(ow) if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: diff --git a/src/world/gen1/UnionCenters.lua b/src/world/gen1/UnionCenters.lua new file mode 100644 index 00000000..e918cd48 --- /dev/null +++ b/src/world/gen1/UnionCenters.lua @@ -0,0 +1,398 @@ +local Assets = require("src.render.Assets") +local Logger = require("src.core.Logger") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + +local UnionCenters = {} + +UnionCenters.FLOOR_2F = "POKECENTER_2F" +UnionCenters.LABEL_2F = "Pokecenter2F" +UnionCenters.UNION_ROOM = UnionRoomMap.MAP_ID +UnionCenters.IMAGE = "assets/composed/pokecenter_union.png" +UnionCenters.STAIR_TILESET = "REDS_HOUSE_1" +UnionCenters.STAIR_SOURCE_TILES = { 10, 11, 12, 13, 26, 27, 28, 29 } +UnionCenters.TEXT_LINK = "TEXT_POKECENTER2F_LINK_RECEPTIONIST" +UnionCenters.TEXT_UNION = "TEXT_POKECENTER2F_UNION_RECEPTIONIST" +UnionCenters.LINK_RECEPTIONIST = 1 +UnionCenters.UNION_RECEPTIONIST = 2 +UnionCenters.STAIRS_2F = { x = 13, y = 1 } +UnionCenters.DOOR_2F = { x = 6, y = 1 } +UnionCenters.DOOR_WARP_2F = 2 +UnionCenters.GATE_2F = { bx = 3, by = 1 } + +UnionCenters.EXPECTED = { + "VIRIDIAN_POKECENTER", "PEWTER_POKECENTER", "CERULEAN_POKECENTER", + "MT_MOON_POKECENTER", "ROCK_TUNNEL_POKECENTER", "VERMILION_POKECENTER", + "CELADON_POKECENTER", "LAVENDER_POKECENTER", "FUCHSIA_POKECENTER", + "CINNABAR_POKECENTER", "SAFFRON_POKECENTER", "INDIGO_PLATEAU_LOBBY", +} + +local TILESETS = { POKECENTER = true, MART = true } +local DESK_LEFT, DESK_RIGHT, DESK_DOOR, COUNTER = 34, 35, 13, 7 +local SRC = { + floor = { 15, 0 }, wall = { 12, 0 }, pillar = { 1, 1 }, cap = { 5, 1 }, + counter = { 7, 0 }, gate = { 34, 0 }, pc = { 35, 3 }, +} +local COUNTER_LEFT_END = 6 + +local registry = setmetatable({}, { __mode = "k" }) + +local function copy(t) + if type(t) ~= "table" then return t end + local out = {} + for k, v in pairs(t) do out[k] = copy(v) end + return out +end + +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do out[k] = v end + return out +end + +local function inList(list, v) + for _, x in ipairs(list or {}) do + if x == v then return true end + end + return false +end + +local function blockAt(def, bx, by) + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then return nil end + return def.blocks[by * def.width + bx + 1] +end + +local function setBlock(def, bx, by, id) + def.blocks[by * def.width + bx + 1] = id +end + +local function quadOf(ts, block, cell) + local b = ts.blocks[block + 1] + if not b then return nil end + local ox, oy = (cell % 2) * 2, math.floor(cell / 2) * 2 + local function at(x, y) return b[(oy + y) * 4 + ox + x + 1] end + return { at(0, 0), at(1, 0), at(0, 1), at(1, 1) } +end + +local function assemble(tl, tr, bl, br) + return { + tl[1], tl[2], tr[1], tr[2], + tl[3], tl[4], tr[3], tr[4], + bl[1], bl[2], br[1], br[2], + bl[3], bl[4], br[3], br[4], + } +end + +function UnionCenters.findReceptionist(def, pointers) + if type(def) ~= "table" or type(def.objects) ~= "table" then return nil end + local entries = type(pointers) == "table" and pointers[def.label] or nil + for i, o in ipairs(def.objects) do + local entry = entries and entries[o.text] + if o.sprite == "SPRITE_LINK_RECEPTIONIST" and type(entry) == "table" and entry.cableClub then + return o, i, entry + end + end + return nil +end + +local function pcTileAt(data, mapId, x, y) + local extras = data.field and data.field.hiddenExtras + for _, h in ipairs(extras and extras.pcTiles and extras.pcTiles[mapId] or {}) do + if h.x == x and h.y == y then return true end + end + return false +end + +function UnionCenters.plan(data, mapId) + local def = data.maps and data.maps[mapId] + if type(def) ~= "table" then return nil, "map missing" end + if not TILESETS[def.tileset] then return nil, "tileset " .. tostring(def.tileset) end + local ts = data.tilesets and data.tilesets[def.tileset] + if not ts then return nil, "tileset data missing" end + local obj, objIndex = UnionCenters.findReceptionist(def, data.text_pointers) + if not obj then return nil, "no cable club receptionist" end + local bc, br = math.floor(obj.x / 2), math.floor(obj.y / 2) + if obj.x % 2 ~= 1 or obj.y % 2 ~= 0 then + return nil, ("receptionist at %d,%d is not the desk gap"):format(obj.x, obj.y) + end + local want = { + { bc, br, DESK_LEFT }, { bc + 1, br, DESK_RIGHT }, + { bc, br - 1, DESK_DOOR }, { bc + 1, br - 1, DESK_DOOR }, + { bc - 1, br, COUNTER }, + } + for _, w in ipairs(want) do + local got = blockAt(def, w[1], w[2]) + if got ~= w[3] then + return nil, ("block %d,%d is %s, expected %d"):format(w[1], w[2], tostring(got), w[3]) + end + end + local pcX, pcY = bc * 2 + 3, br * 2 + 1 + if not pcTileAt(data, mapId, pcX, pcY) then + return nil, ("no PC hidden tile at %d,%d"):format(pcX, pcY) + end + local ax0, ax1, ay0, ay1 = bc * 2 + 1, bc * 2 + 3, br * 2 - 1, br * 2 + 1 + for _, o in ipairs(def.objects) do + if o ~= obj and o.x >= ax0 and o.x <= ax1 and o.y >= ay0 and o.y <= ay1 then + return nil, ("object %s stands in the stairs area"):format(tostring(o.name)) + end + end + for _, w in ipairs(def.warps or {}) do + if w.x >= ax0 and w.x <= ax1 and w.y >= ay0 and w.y <= ay1 then + return nil, ("warp at %d,%d overlaps the stairs area"):format(w.x, w.y) + end + end + return { + map = mapId, label = def.label, tileset = def.tileset, + receptionist = { index = obj.index, objIndex = objIndex, x = obj.x, y = obj.y, + text = obj.text, name = obj.name }, + front = { x = obj.x, y = obj.y + 1 }, + desk = { bx = bc, by = br }, + changes = { + { bx = bc, by = br - 1, from = DESK_DOOR, to = "pillarWall" }, + { bx = bc + 1, by = br - 1, from = DESK_DOOR, to = "upStairs" }, + { bx = bc, by = br, from = DESK_LEFT, to = "capFloor" }, + { bx = bc + 1, by = br, from = DESK_RIGHT, to = "pcCorner" }, + }, + stairs = { x = bc * 2 + 3, y = br * 2 - 1 }, + pc = { x = pcX, y = pcY }, + } +end + +function UnionCenters.candidates(data) + local seen, out = {}, {} + for _, id in ipairs(UnionCenters.EXPECTED) do + if data.maps and data.maps[id] then seen[id] = true out[#out + 1] = id end + end + local extra = {} + for id, def in pairs(data.maps or {}) do + if not seen[id] and type(def) == "table" and TILESETS[def.tileset] + and UnionCenters.findReceptionist(def, data.text_pointers) then + extra[#extra + 1] = id + end + end + table.sort(extra) + for _, id in ipairs(extra) do out[#out + 1] = id end + return out +end + +local function composeImage(base, source, perRow, srcPerRow, first) + return function() + local baseData = Assets.imageData(base) + local srcData = Assets.imageData(source) + local w, h = baseData:getDimensions() + local tiles = UnionCenters.STAIR_SOURCE_TILES + local rows = math.ceil(#tiles / perRow) + local out = love.image.newImageData(w, h + rows * 8) + out:paste(baseData, 0, 0, 0, 0, w, h) + for i, t in ipairs(tiles) do + local d = first + i - 1 + out:paste(srcData, (d % perRow) * 8, math.floor(d / perRow) * 8, + (t % srcPerRow) * 8, math.floor(t / srcPerRow) * 8, 8, 8) + end + return out + end +end + +local function patchTileset(ts) + local perRow = ts.tilesPerRow + local first = (ts.imageWidth / 8) * (ts.imageHeight / 8) + local q = {} + for k, s in pairs(SRC) do + q[k] = quadOf(ts, s[1], s[2]) + if not q[k] then return nil, "block " .. s[1] .. " missing" end + end + if not (inList(ts.walkable, q.floor[3]) and not inList(ts.walkable, q.pillar[3]) + and not inList(ts.walkable, q.cap[3]) and inList(ts.counterTiles, q.counter[3])) then + return nil, "tileset collision does not match the vanilla center" + end + q.up = { first + 2, first + 3, first + 6, first + 7 } + q.down = { first + 0, first + 1, first + 4, first + 5 } + ts.image = UnionCenters.IMAGE + ts.imageHeight = ts.imageHeight + math.ceil(#UnionCenters.STAIR_SOURCE_TILES / perRow) * 8 + ts.tileSources = ts.tileSources or {} + for i, t in ipairs(UnionCenters.STAIR_SOURCE_TILES) do + ts.tileSources[first + i - 1] = { tileset = UnionCenters.STAIR_TILESET, tile = t } + end + ts.walkable = ts.walkable or {} + ts.warpTiles = ts.warpTiles or {} + for _, t in ipairs({ q.up[3], q.down[3] }) do + ts.walkable[#ts.walkable + 1] = t + ts.warpTiles[#ts.warpTiles + 1] = t + end + local ids = {} + local function add(name, b) + ts.blocks[#ts.blocks + 1] = b + ids[name] = #ts.blocks - 1 + end + add("pillarWall", assemble(q.pillar, q.wall, q.pillar, q.floor)) + add("upStairs", assemble(q.wall, q.wall, q.floor, q.up)) + add("capFloor", assemble(q.cap, q.floor, q.floor, q.floor)) + add("pcCorner", assemble(q.floor, q.floor, q.floor, q.pc)) + add("downStairs", assemble(q.pillar, q.wall, q.pillar, q.down)) + add("gateClosed", assemble(q.gate, q.counter, q.floor, q.floor)) + add("gateOpen", assemble(q.floor, q.counter, q.floor, q.floor)) + ids.counterEnd = ids.capFloor + ids.firstTile = first + ids.upTile, ids.downTile = q.up[3], q.down[3] + return ids +end + +local function build2F(ids, borderBlock, home) + local W, D, F = 12, DESK_DOOR, 15 + local function receptionist(index, x, text, name) + return { index = index, movement = "STAY", range = "DOWN", + sprite = "SPRITE_LINK_RECEPTIONIST", x = x, y = 1, + name = name, text = text } + end + return { + id = UnionCenters.FLOOR_2F, label = UnionCenters.LABEL_2F, tileset = "POKECENTER", + width = 7, height = 4, borderBlock = borderBlock, + blocks = { + W, W, W, D, W, D, ids.downStairs, + COUNTER_LEFT_END, COUNTER, COUNTER, ids.gateClosed, COUNTER, ids.gateClosed, ids.counterEnd, + F, F, F, F, F, F, F, + 14, F, F, F, F, F, 14, + }, + warps = { + { x = UnionCenters.STAIRS_2F.x, y = UnionCenters.STAIRS_2F.y, + destMap = home.map, destWarp = home.warp }, + { x = UnionCenters.DOOR_2F.x, y = UnionCenters.DOOR_2F.y, + destMap = UnionCenters.UNION_ROOM, destWarp = 1 }, + }, + objects = { + receptionist(UnionCenters.LINK_RECEPTIONIST, 11, UnionCenters.TEXT_LINK, + "POKECENTER2F_LINK_RECEPTIONIST"), + receptionist(UnionCenters.UNION_RECEPTIONIST, 7, UnionCenters.TEXT_UNION, + "POKECENTER2F_UNION_RECEPTIONIST"), + }, + signs = {}, connections = {}, + } +end + +local function verify(def, plan, ids) + for _, c in ipairs(plan.changes) do + if blockAt(def, c.bx, c.by) ~= ids[c.to] then return false end + end + local w = def.warps[plan.warp] + if not (w and w.x == plan.stairs.x and w.y == plan.stairs.y + and w.destMap == UnionCenters.FLOOR_2F) then + return false + end + for _, o in ipairs(def.objects) do + if o.sprite == "SPRITE_LINK_RECEPTIONIST" and o.text == plan.receptionist.text then + return false + end + end + return true +end + +function UnionCenters.forData(data) + return data and registry[data] or nil +end + +function UnionCenters.planFor(data, mapId) + local r = UnionCenters.forData(data) + return r and r.plans[mapId] or nil +end + +function UnionCenters.apply(data) + local maps, tilesets = data.maps, data.tilesets + if not (maps and tilesets) then return nil, "no map data" end + if maps[UnionCenters.FLOOR_2F] then return registry[data], "already applied" end + local stairs, club = tilesets[UnionCenters.STAIR_TILESET], tilesets[UnionRoomMap.TILESET] + if not (stairs and club and tilesets.POKECENTER) then return nil, "tilesets missing" end + local result = { plans = {}, order = {}, refused = {}, blocks = {} } + local ok = {} + for _, id in ipairs(UnionCenters.candidates(data)) do + local plan, why = UnionCenters.plan(data, id) + if plan then + ok[#ok + 1] = plan + else + result.refused[id] = why + end + end + if #ok == 0 then return nil, "no center matched" end + + data.maps = shallow(maps) + data.tilesets = shallow(tilesets) + maps, tilesets = data.maps, data.tilesets + local baseImage = tilesets.POKECENTER.image + local patched = {} + local function tilesetIds(name) + if patched[name] ~= nil then return patched[name] end + local ts = copy(tilesets[name]) + local ids, why = patchTileset(ts) + if ids then + tilesets[name] = ts + result.blocks[name] = ids + else + Logger.warn("union room: tileset %s not patched: %s", name, why) + end + patched[name] = ids or false + return ids + end + local pcIds = tilesetIds("POKECENTER") + if not pcIds then return nil, "POKECENTER tileset did not match" end + Assets.compose(UnionCenters.IMAGE, + composeImage(baseImage, stairs.image, tilesets.POKECENTER.tilesPerRow, + stairs.tilesPerRow, pcIds.firstTile)) + + for _, plan in ipairs(ok) do + local ids = tilesetIds(plan.tileset) + if not ids then + result.refused[plan.map] = "tileset " .. plan.tileset .. " did not match" + else + local def = copy(maps[plan.map]) + for _, c in ipairs(plan.changes) do + c.toId = ids[c.to] + setBlock(def, c.bx, c.by, c.toId) + end + table.remove(def.objects, plan.receptionist.objIndex) + def.warps[#def.warps + 1] = { x = plan.stairs.x, y = plan.stairs.y, + destMap = UnionCenters.FLOOR_2F, destWarp = 1 } + plan.warp = #def.warps + plan.verified = verify(def, plan, ids) + maps[plan.map] = def + result.plans[plan.map] = plan + result.order[#result.order + 1] = plan.map + end + end + local home = result.plans[result.order[1]] + local floor2 = build2F(pcIds, maps[home.map].borderBlock, home) + maps[UnionCenters.FLOOR_2F] = floor2 + maps[UnionCenters.UNION_ROOM] = UnionRoomMap.build( + maps.TRADE_CENTER and maps.TRADE_CENTER.borderBlock or 0, + UnionCenters.FLOOR_2F, UnionCenters.DOOR_WARP_2F) + result.gateClosed, result.gateOpen = pcIds.gateClosed, pcIds.gateOpen + + local pointers = data.text_pointers + local entry = pointers and pointers[home.label] + entry = entry and entry[home.receptionist.text] + if pointers then + data.text_pointers = shallow(pointers) + data.text_pointers[UnionCenters.LABEL_2F] = { [UnionCenters.TEXT_LINK] = copy(entry) } + end + local songs = data.audio and data.audio.mapSongs + if songs then + data.audio = shallow(data.audio) + songs = shallow(songs) + data.audio.mapSongs = songs + songs[UnionCenters.FLOOR_2F] = songs[home.map] + songs[UnionCenters.UNION_ROOM] = songs.TRADE_CENTER + end + + registry[data] = result + local refused = {} + for id, why in pairs(result.refused) do refused[#refused + 1] = id .. " (" .. why .. ")" end + table.sort(refused) + Logger.info("union room: %d centers patched%s", #result.order, + #refused > 0 and (", refused: " .. table.concat(refused, ", ")) or "") + return result +end + +function UnionCenters.seed(data, opts) + local Setting = require("src.online.union.Setting") + if not Setting.patchesOn(1, opts) then return nil, "setting off" end + return UnionCenters.apply(data) +end + +return UnionCenters diff --git a/src/world/gen1/UnionRoomMap.lua b/src/world/gen1/UnionRoomMap.lua new file mode 100644 index 00000000..2962d2ec --- /dev/null +++ b/src/world/gen1/UnionRoomMap.lua @@ -0,0 +1,62 @@ +local Plaza = require("src.core.game3.link.union_plaza_map") + +local UnionRoomMap = {} + +UnionRoomMap.MAP_ID = "UNION_ROOM" +UnionRoomMap.LABEL = "UnionRoom" +UnionRoomMap.TILESET = "CLUB" +UnionRoomMap.WIDTH = 13 +UnionRoomMap.HEIGHT = 13 +UnionRoomMap.CAP = Plaza.CAP +UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y = 12, 25 +UnionRoomMap.EXITS = { { x = 12, y = 25 }, { x = 13, y = 25 } } + +local WALL_LEFT, WALL, POSTER, WALL_RIGHT = 27, 3, 19, 23 +local FLOOR, CARPET = 10, 1 + +function UnionRoomMap.cellFor(slot) + return Plaza.cellFor(slot) +end + +function UnionRoomMap.slotAt(x, y) + return Plaza.slotAt(x, y) +end + +function UnionRoomMap.entry() + return UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y, "up" +end + +function UnionRoomMap.blocks() + local w, h = UnionRoomMap.WIDTH, UnionRoomMap.HEIGHT + local out = {} + for by = 0, h - 1 do + for bx = 0, w - 1 do + local b = FLOOR + if by == 0 then + b = (bx == 0 and WALL_LEFT) or (bx == w - 1 and WALL_RIGHT) + or (bx == 6 and POSTER) or WALL + elseif by == h - 1 and bx == 6 then + b = CARPET + end + out[by * w + bx + 1] = b + end + end + return out +end + +function UnionRoomMap.build(borderBlock, floor2Id, floor2DoorWarp) + local warps = {} + for i, e in ipairs(UnionRoomMap.EXITS) do + warps[i] = { x = e.x, y = e.y, destMap = floor2Id, destWarp = floor2DoorWarp } + end + return { + id = UnionRoomMap.MAP_ID, label = UnionRoomMap.LABEL, + tileset = UnionRoomMap.TILESET, + width = UnionRoomMap.WIDTH, height = UnionRoomMap.HEIGHT, + borderBlock = borderBlock, + blocks = UnionRoomMap.blocks(), + warps = warps, objects = {}, signs = {}, connections = {}, + } +end + +return UnionRoomMap diff --git a/src/world/gen1/UnionRoomPresence.lua b/src/world/gen1/UnionRoomPresence.lua new file mode 100644 index 00000000..80804cc5 --- /dev/null +++ b/src/world/gen1/UnionRoomPresence.lua @@ -0,0 +1,596 @@ +local Avatars = require("src.online.union.Avatars") +local FieldDefaults = require("src.world.FieldDefaults") +local GameVersion = require("src.core.GameVersion") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + +local Presence = {} +Presence.__index = Presence + +Presence.MAP_ID = UnionRoomMap.MAP_ID +Presence.TEXT = "TEXT_UNION_ROOM_MEMBER" +Presence.INDEX_BASE = 1000 +Presence.CONNECT_SECONDS = 15 +Presence.LOST_SHOW_FRAMES = 60 +Presence.LOST_GIVE_UP_SECONDS = 30 +Presence.FOCUS_RANGE = 2 + +Presence.seams = { newRoom = nil, connect = nil } + +local LIVE = { online = true } +local DOWN = { error = true, offline = true } + +local function now() + return love.timer.getTime() +end + +local function ui(name) + return require("src.ui.union.gen1." .. name) +end + +local function defaultConnect(opts) + return require("src.online.Connect").start(opts) +end + +function Presence.cellFor(slot) + local x, y, facing = UnionRoomMap.cellFor(slot) + if not x then return nil end + return { x = x, y = y, facing = facing or "down" } +end + +local Member = {} +Member.__index = Member +Presence.Member = Member + +function Member.new(presence, p, cell) + local self = setmetatable({}, Member) + self.presence = presence + self.p = p + self.unionMember = true + self.id = Presence.MAP_ID .. "_union_" .. p.slot + self.def = { index = Presence.INDEX_BASE + p.slot, text = Presence.TEXT, + name = "UNION_MEMBER_" .. p.slot, union = true } + self.cellX, self.cellY = cell.x, cell.y + self.px, self.py = cell.x * 16, cell.y * 16 + self.home = cell.facing + self.facing = self.home + self.moving = false + self.frozen = false + self:setParticipant(p) + return self +end + +function Member:setParticipant(p) + local key = Avatars.key(p) .. "#" .. Avatars.pickKey(p) + if key ~= self.avatarKey then + self.avatarKey = key + self.entry = Avatars.resolve(p, { version = GameVersion.get() }) + end + self.p = p + self.name = ui("Text").clean(p.name) + self.digit = Participant.badgeDigit(p) +end + +function Member:update() + if not self.frozen and not self.presence.busy and self.facing ~= self.home then + self.facing = self.home + end +end + +function Member:facePlayer(player) + local dx = player.cellX - self.cellX + local dy = player.cellY - self.cellY + if math.abs(dx) > math.abs(dy) then + self.facing = dx > 0 and "right" or "left" + else + self.facing = dy > 0 and "down" or "up" + end +end + +function Member:resetToSpawn() end + +function Member:walkPhase() return 0 end + +function Member:pose() + return nil, self.px, self.py, self.facing, 0, false, false +end + +function Member:foot(camX, camY) + return math.floor(self.px - camX) + 8, math.floor(self.py - camY) + 12 +end + +function Member:draw(camX, camY) + local fx, fy = self:foot(camX, camY) + local Look = ui("Look") + Look.draw(self.entry, fx, fy, self.facing, self.presence.lookOpts) + if self.presence.focus ~= self and self.digit then + ui("Tag").draw(fx, fy - Look.height(self.entry), self.name, self.digit, false) + end +end + +local Overlay = {} +Overlay.__index = Overlay +Overlay.passable = true +Overlay.unionOverlay = true + +function Overlay:draw(camX, camY) + local m = self.presence.focus + if not m then return end + local fx, fy = m:foot(camX, camY) + local top = fy - ui("Look").height(m.entry) + local ow = self.presence:ow() + local pl = ow and ow.player + if pl and pl.cellX == m.cellX and pl.cellY == m.cellY - 1 then + top = math.min(top, math.floor(pl.py - camY) + 12 - 16) + end + ui("Tag").draw(fx, top, m.name, m.digit, true) +end + +function Overlay:update() end + +function Presence.new(game, opts) + opts = opts or {} + local self = setmetatable({ + game = game, + state = "idle", + members = {}, + pending = {}, + notices = {}, + seenInvites = {}, + tracked = {}, + abandoned = {}, + frames = 0, + busy = false, + statusSent = nil, + focus = nil, + boundNpcs = nil, + boundEntities = nil, + closed = false, + }, Presence) + self.overlay = setmetatable({ presence = self, px = 0, py = -1 }, Overlay) + local walk = FieldDefaults.fieldValue(game.data, "playerSprites", "walk") + self.lookOpts = { playerDef = game.data.sprites and game.data.sprites[walk], seed = "union" } + self.room = opts.room or (Presence.seams.newRoom and Presence.seams.newRoom(game)) or Room.new() + self.client = self.room.client + self.connect = opts.connect or Presence.seams.connect or defaultConnect + self.ctxExtra = opts.ctx or Presence.seams.ctx + return self +end + +function Presence.current(game) + local p = game and game.unionPresence + if p and not p.closed then return p end + return nil +end + +function Presence:ctx() + local save = self.game.save + local player = save and save.player or {} + local ctx = { + version = GameVersion.get(), game = self.game, data = self.game.data, + name = player.name, trainerId = player.id, gender = 0, style = "player", + } + for k, v in pairs(self.ctxExtra or {}) do ctx[k] = v end + return ctx +end + +function Presence:connectOptions(profile) + local version = GameVersion.get() + local player = self.game.save and self.game.save.player or {} + return { + source = "game", version = version, trainerName = player.name, + profiles = { profile }, + presence = { where = "game", status = "idle", version = version }, + } +end + +function Presence:clientState() + return self.client and self.client.state and self.client.state() or "offline" +end + +function Presence:start() + if LIVE[self:clientState()] then + self:join() + return + end + local profile, why = Room.buildProfile(self:ctx()) + if not profile then + self:goOffline("profile", why) + return + end + local ok, err = self.connect(self:connectOptions(profile)) + if not ok then + self:goOffline("offline", err) + return + end + self.state = "connecting" + self.since = now() +end + +function Presence:join() + local ok, err = self.room:join(self:ctx()) + if not ok then + self:goOffline(err and err.error or "profile") + return + end + self.state = "joining" + self.statusSent = "idle" + self.since = now() +end + +function Presence:goOffline(code, detail) + if self.state == "offline" then return end + self.state = "offline" + self.offlineCode = code + self.offlineDetail = detail + self:clearMembers() + if self.room.state == "joining" or self.room.state == "joined" or self.room.state == "error" then + self.room:leave() + end + self:notify(ui("Text").error(code)) +end + +function Presence:notify(text) + self.notices[#self.notices + 1] = text +end + +function Presence:ow() + return self.game.overworld +end + +local function removeFrom(list, item) + if not list then return end + for i = #list, 1, -1 do + if list[i] == item then table.remove(list, i) end + end +end + +local function contains(list, item) + for i = 1, #list do + if list[i] == item then return true end + end + return false +end + +function Presence:bind(ow) + if not (ow and ow.npcs and ow.entities) then return end + if self.boundNpcs == ow.npcs and self.boundEntities == ow.entities then return end + self.boundNpcs, self.boundEntities = ow.npcs, ow.entities + for _, m in pairs(self.members) do + if not contains(ow.npcs, m) then ow.npcs[#ow.npcs + 1] = m end + if not contains(ow.entities, m) then ow.entities[#ow.entities + 1] = m end + end + if not contains(ow.entities, self.overlay) then ow.entities[#ow.entities + 1] = self.overlay end +end + +function Presence:unbind() + local npcs, entities = self.boundNpcs, self.boundEntities + for _, m in pairs(self.members) do + removeFrom(npcs, m) + removeFrom(entities, m) + end + removeFrom(entities, self.overlay) + self.boundNpcs, self.boundEntities = nil, nil +end + +local function occupies(e, x, y) + return e ~= nil and ((e.cellX == x and e.cellY == y) or (e.targetX == x and e.targetY == y)) +end + +function Presence:cellBlocked(cell) + local ow = self:ow() + if not ow then return false end + if occupies(ow.player, cell.x, cell.y) then return true end + local follower = require("src.world.PikachuFollower").current(ow) + if occupies(follower, cell.x, cell.y) then return true end + return false +end + +function Presence:spawn(p) + local cell = Presence.cellFor(p.slot) + if not cell then return nil end + if self:cellBlocked(cell) then + self.pending[p.slot] = p + return nil + end + self.pending[p.slot] = nil + local m = Member.new(self, p, cell) + self.members[p.slot] = m + if self.boundNpcs then + self.boundNpcs[#self.boundNpcs + 1] = m + self.boundEntities[#self.boundEntities + 1] = m + end + return m +end + +function Presence:despawn(slot) + self.pending[slot] = nil + local m = self.members[slot] + if not m then return end + self.members[slot] = nil + removeFrom(self.boundNpcs, m) + removeFrom(self.boundEntities, m) + if self.focus == m then self.focus = nil end +end + +function Presence:clearMembers() + for slot in pairs(self.members) do self:despawn(slot) end + self.pending = {} + self.focus = nil +end + +function Presence:apply(diff) + for _, p in ipairs(diff.left or {}) do + local m = self.members[p.slot] + if (m and m.p.id == p.id) or (self.pending[p.slot] and self.pending[p.slot].id == p.id) then + self:despawn(p.slot) + end + end + for _, p in ipairs(diff.joined or {}) do + if self.members[p.slot] then self:despawn(p.slot) end + self:spawn(p) + end + for _, p in ipairs(diff.changed or {}) do + local m = self.members[p.slot] + if m then + m:setParticipant(p) + elseif self.pending[p.slot] then + self.pending[p.slot] = p + end + end +end + +function Presence:retryPending() + for slot, p in pairs(self.pending) do + if not self.members[slot] then self:spawn(p) end + end +end + +function Presence:participant(member) + if not member then return nil end + local p = member.p + return self.room:member(p.id) or (self.members[p.slot] == member and p or nil) +end + +function Presence:memberById(id) + for _, m in pairs(self.members) do + if m.p.id == id then return m end + end + return nil +end + +function Presence:memberAt(x, y) + local slot = UnionRoomMap.slotAt(x, y) + local m = slot and self.members[slot] + if m and m.cellX == x and m.cellY == y then return m end + return nil +end + +function Presence:count() + local n = 0 + for _ in pairs(self.members) do n = n + 1 end + return n +end + +function Presence:setBusy(on, speaker) + self.busy = on and true or false + self.speaker = self.busy and speaker or nil + local status = self.busy and "busy" or "idle" + if self.state == "joined" and self.statusSent ~= status then + self.statusSent = status + self.room:setStatus(status) + end +end + +function Presence:track(h) + self.tracked[h] = true +end + +function Presence:untrack(h) + self.tracked[h] = nil +end + +function Presence:abandon(h) + self.tracked[h] = nil + self.abandoned[h] = true +end + +function Presence:sweepAbandoned() + for h in pairs(self.abandoned) do + if h.state == "accepted" then + local prep = self.room:prep() + if prep then + prep:cancel("cancel") + prep:leave() + self.room:dropPrep() + self.abandoned[h] = nil + end + elseif h.state == "closed" then + self.abandoned[h] = nil + end + end +end + +function Presence:updateFocus() + local ow = self:ow() + local player = ow and ow.player + if not player or not player.cellX then + self.focus = nil + return + end + local Collision = require("src.world.Collision") + local fx, fy = Collision.target(player.cellX, player.cellY, player.facing or "down") + local speaker = self.busy and self.speaker + local best = (speaker and self.members[speaker.p.slot] == speaker and speaker) or self:memberAt(fx, fy) + if not best then + local bestD = Presence.FOCUS_RANGE + 1 + for _, m in pairs(self.members) do + local d = math.abs(m.cellX - player.cellX) + math.abs(m.cellY - player.cellY) + if d < bestD or (d == bestD and best and m.p.slot < best.p.slot) then + best, bestD = m, d + end + end + end + self.focus = best + if best then + self.overlay.px, self.overlay.py = best.px, best.py + 0.5 + end +end + +function Presence:idle() + local ow = self:ow() + if not ow or self.game.stack:top() ~= ow then return false end + if ow.transitioning or (ow.runner and ow.runner:isRunning()) then return false end + if ow.scriptMoves and #ow.scriptMoves > 0 then return false end + if ow.pendingScripts and ow.pendingScripts[1] then return false end + local p = ow.player + return p ~= nil and not p.moving +end + +function Presence:connection() + local cs = self:clientState() + if self.state == "connecting" then + if LIVE[cs] then + self:join() + elseif DOWN[cs] or now() - (self.since or now()) > Presence.CONNECT_SECONDS then + self:goOffline("offline") + end + elseif self.state == "joining" or self.state == "joined" then + if cs == "reconnecting" then + self.state = "reconnecting" + self.resumeState = self.room.state == "joined" and "joined" or "joining" + self.lostAt = self.frames + self.lostSince = now() + elseif DOWN[cs] then + self:goOffline("lost") + elseif self.state == "joining" and self.room.state == "joined" then + self.state = "joined" + self:setBusy(self.busy, self.speaker) + end + elseif self.state == "reconnecting" then + if LIVE[cs] then + self.state = self.room.state == "joined" and "joined" or self.resumeState or "joining" + self.lostAt = nil + self.statusSent = nil + self:setBusy(self.busy, self.speaker) + elseif DOWN[cs] or now() - (self.lostSince or now()) > Presence.LOST_GIVE_UP_SECONDS then + self:goOffline("lost") + end + end +end + +function Presence:showLost() + if self.lostBox and self.state ~= "reconnecting" then + self.lostBox:close() + self.lostBox = nil + end + if self.lostBox or self.state ~= "reconnecting" then return end + if self.frames - (self.lostAt or 0) < Presence.LOST_SHOW_FRAMES or not self:idle() then return end + local Dialog = ui("Dialog") + self.lostBox = Dialog.hold(self.game, ui("Text").say("lost"), function(w) + if self.state ~= "reconnecting" then + w:close() + self.lostBox = nil + end + end) +end + +function Presence:offerInvite() + if self.busy or self.state ~= "joined" or not self:idle() then return end + for _, inv in ipairs(self.room:incoming()) do + if not self.seenInvites[inv.id] then + self.seenInvites[inv.id] = true + ui("Talk").incoming(self.game, self, inv) + return + end + end +end + +function Presence:showNotice() + if not self.notices[1] or self.busy or not self:idle() then return end + local text = table.remove(self.notices, 1) + ui("Dialog").say(self.game, text) +end + +function Presence:tick() + if self.closed then return end + local ow = self:ow() + if not (ow and ow.map and ow.map.id == Presence.MAP_ID) then + self:leave() + return + end + self.frames = self.frames + 1 + self:bind(ow) + self:connection() + if self.state == "joining" or self.state == "joined" or self.state == "reconnecting" then + local diff = self.room:poll() + if diff.error then + self:goOffline(diff.error.error, diff.error.detail) + else + self:apply(diff) + end + end + self:retryPending() + self:sweepAbandoned() + self:updateFocus() + self:showLost() + self:offerInvite() + self:showNotice() +end + +function Presence:entities() + local out = {} + for _, m in pairs(self.members) do out[#out + 1] = m end + table.sort(out, function(a, b) return a.p.slot < b.p.slot end) + return out +end + +function Presence:entity(slot) + return self.members[slot] +end + +function Presence:leave() + if self.closed then return end + self.closed = true + if self.activity then self.activity:abort("left") end + self:unbind() + self:clearMembers() + if self.room.state ~= "idle" and self.room.state ~= "left" then + self.room:setStatus("busy") + self.room:leave() + end + self.state = "left" + if self.game.unionPresence == self then self.game.unionPresence = nil end +end + +function Presence.enter(game, ow) + local self = Presence.current(game) + if not self then + self = Presence.new(game) + game.unionPresence = self + self:start() + end + self:bind(ow or game.overworld) + return self +end + +function Presence.active() + return Presence.current(require("src.core.Game")) +end + +function Presence.tickGame(game) + local self = game and game.unionPresence + if self then self:tick() end +end + +function Presence.talk(game, ow, npc, done) + local self = Presence.current(game) + if not (self and npc and npc.unionMember) then + if done then done() end + return + end + ui("Talk").begin(game, self, npc, done) +end + +return Presence diff --git a/src/world/gen1/UnionSafety.lua b/src/world/gen1/UnionSafety.lua new file mode 100644 index 00000000..11a94098 --- /dev/null +++ b/src/world/gen1/UnionSafety.lua @@ -0,0 +1,124 @@ +local UnionCenters = require("src.world.gen1.UnionCenters") +local Origin = require("src.online.union.Origin") + +local UnionSafety = {} + +UnionSafety.ADDED = { + [UnionCenters.FLOOR_2F] = true, + [UnionCenters.UNION_ROOM] = true, +} + +local function known(data, id) + return type(id) == "string" and data.maps and data.maps[id] ~= nil +end + +local function centerPlan(data, mapId) + return UnionCenters.planFor(data, mapId) or (UnionCenters.plan(data, mapId)) +end + +function UnionSafety.nurseFront(data, mapId) + local def = known(data, mapId) and data.maps[mapId] + if not def then return nil end + local entries = data.text_pointers and data.text_pointers[def.label] + for _, o in ipairs(def.objects or {}) do + local entry = entries and entries[o.text] + if o.sprite == "SPRITE_NURSE" and (entry == nil or entry.nurse) then + return { map = mapId, x = o.x, y = o.y + 2, facing = "up" } + end + end + return nil +end + +function UnionSafety.townOf(data, centerId) + for id, def in pairs(data.maps or {}) do + if type(def) == "table" and id ~= centerId then + for _, w in ipairs(def.warps or {}) do + if w.destMap == centerId then return id, w end + end + end + end + return nil +end + +function UnionSafety.centerOfTown(data, town) + local def = known(data, town) and data.maps[town] + for _, w in ipairs(def and def.warps or {}) do + if centerPlan(data, w.destMap) then return w.destMap end + end + return nil +end + +function UnionSafety.offVanilla(save, data) + local p = type(save) == "table" and save.player + if type(p) ~= "table" then return false end + if UnionSafety.ADDED[p.map] then return true end + local plan = type(p.map) == "string" and centerPlan(data, p.map) + if not (plan and p.x and p.y) then return false end + local bx, by = plan.desk.bx, plan.desk.by + return p.x >= bx * 2 and p.x <= bx * 2 + 3 and p.y >= by * 2 - 1 and p.y <= by * 2 +end + +function UnionSafety.target(save, data) + local o = Origin.get(save) + local center = o and o.gen == 1 and known(data, o.map) and centerPlan(data, o.map) and o.map + local here = save.player and save.player.map + if not center and type(here) == "string" and centerPlan(data, here) then center = here end + local heal = save.lastHeal + if not (type(heal) == "table" and known(data, heal.map)) then + heal = require("src.core.SaveData").defaultHeal(data.field and data.field.boot) + end + center = center or UnionSafety.centerOfTown(data, heal.map) + local spot = center and UnionSafety.nurseFront(data, center) + if spot then + local town, w = UnionSafety.townOf(data, center) + spot.town = town and { id = town, x = w.x, y = w.y } or nil + return spot + end + return { map = heal.map, x = heal.x, y = heal.y, facing = "down", heal = true } +end + +local function relocate(save, t) + local p = {} + for k, v in pairs(save.player) do p[k] = v end + p.map, p.x, p.y, p.facing = t.map, t.x, t.y, t.facing + save.player = p + local last = save.lastOutdoor + if t.town and not (type(last) == "table" and last.id == t.town.id) then + save.lastOutdoor = { id = t.town.id, x = t.town.x, y = t.town.y } + end + Origin.clear(save) +end + +function UnionSafety.settle(save, data) + if type(save) ~= "table" or type(data) ~= "table" or not UnionSafety.offVanilla(save, data) then + return nil + end + local t = UnionSafety.target(save, data) + relocate(save, t) + return t +end + +function UnionSafety.forWrite(save, data) + if type(save) ~= "table" or type(data) ~= "table" or not UnionSafety.offVanilla(save, data) then + return save + end + local out = {} + for k, v in pairs(save) do out[k] = v end + relocate(out, UnionSafety.target(save, data)) + return out +end + +local REWRITTEN = { player = true, lastOutdoor = true, [Origin.KEY] = true } + +function UnionSafety.write(save, data, writer) + local out = UnionSafety.forWrite(save, data) + local ok = writer(out) + if out ~= save then + for k, v in pairs(out) do + if not REWRITTEN[k] then save[k] = v end + end + end + return ok +end + +return UnionSafety diff --git a/src/world/gen2/UnionCenter2F.lua b/src/world/gen2/UnionCenter2F.lua new file mode 100644 index 00000000..40e817b4 --- /dev/null +++ b/src/world/gen2/UnionCenter2F.lua @@ -0,0 +1,270 @@ +local Strings = require("src.core.Strings") +local Setting = require("src.online.union.Setting") +local Origin = require("src.online.union.Origin") +local RoomMap = require("src.world.gen2.UnionRoomMap") + +local M = {} + +M.MAP_ID = "POKECENTER_2F" +M.ROOM_ID = RoomMap.ID +M.RECEPTIONIST_KEY = "union:2f_receptionist" +M.LEFT_KEY = "union:2f_left_room" + +local KEY = { + closed = "union:2f_closed", + noMon = "union:2f_no_mon", + decline = "union:2f_decline", +} + +local MOVE = { + aside = "union:mv_receptionist_aside", + playerIn = "union:mv_player_in", + makeWay = "union:mv_receptionist_make_way", + playerOut = "union:mv_player_out", + back = "union:mv_receptionist_back", +} + +local TEXT = { + closed = Strings.source("I'm sorry, the\nUNION ROOM isn't\vopen to you yet."), + noMon = Strings.source("You'll need at\nleast one POKéMON\vto go in."), + intro = Strings.source( + "Welcome to the\nUNION ROOM.\fTrainers from\nevery generation\vgather here.\f" + .. "Would you like\nto go in?"), + save = Strings.source("Your game will be\nsaved before you\vgo in. OK?"), + decline = Strings.source("Please come\nback anytime."), + comeIn = Strings.source("Your POKéMON are\nall rested.\fRight this way."), +} +M.TEXT = TEXT + +-- macros/scripts/movement.asm:6 +local TURN_HEAD = 0x00 +-- macros/scripts/movement.asm:16 +local SLOW_STEP = 0x08 +-- macros/scripts/movement.asm:21 +local STEP = 0x0c +-- macros/scripts/movement.asm:122 +local STEP_END = 0x47 +-- constants/ram_constants.asm:82 +local DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3 +-- constants/script_constants.asm:49 +local VAR_PARTYCOUNT = 0x01 + +local function moves(...) + local out = {} + for i, b in ipairs({ ... }) do out[i] = b end + out[#out + 1] = STEP_END + return out +end + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end + +local function blockAt(def, bx, by) + return def.blocks[by * def.width + bx + 1] +end + +local function warpTo(def, dest) + for i, w in ipairs(def.warps or {}) do + if w.destMap == dest then return i, w end + end + return nil +end + +function M.layout(def) + local _, door = warpTo(def, "COLOSSEUM") + assert(door, "POKECENTER_2F has no COLOSSEUM warp") + local col = math.floor(door.x / 2) + local lx = door.x % 2 + local receptionist + for _, obj in ipairs(def.objects or {}) do + if obj.x == door.x and obj.y > door.y and obj.scriptKey then + if not receptionist or obj.y < receptionist.y then receptionist = obj end + end + end + assert(receptionist, "POKECENTER_2F has no COLOSSEUM receptionist") + local x = def.width * 2 + (door.x - col * 2) + return { + col = col, lx = lx, source = door, receptionist = receptionist, + width = def.width + 2, + doorX = x, doorY = door.y, + deskX = x, deskY = receptionist.y, + frontX = x, frontY = receptionist.y + 1, + } +end + +local function composeDoor(def, tileset, plan) + local src = blockAt(def, plan.col, 0) + local wall = tileset.blocks[blockAt(def, 0, 0) + 1] + local tiles = copy(tileset.blocks[src + 1]) + local ex = (1 - plan.lx) * 2 + for _, i in ipairs({ ex + 1, ex + 2, ex + 5, ex + 6 }) do + tiles[i] = wall[i - ex] + end + return RoomMap.block(tileset, tiles, copy(tileset.collision[src + 1])) +end + +local function widen(def, tileset, plan) + local w, h = def.width, def.height + local nw = plan.width + local door = composeDoor(def, tileset, plan) + local floor = blockAt(def, plan.col, 2) + local out = {} + for by = 0, h - 1 do + for bx = 0, w - 1 do out[by * nw + bx + 1] = blockAt(def, bx, by) end + local a, b + if by == 0 then + a, b = door, blockAt(def, plan.col + 1, 0) + elseif by == 1 then + a, b = blockAt(def, plan.col, 1), blockAt(def, plan.col + 1, 1) + else + a, b = floor, floor + end + out[by * nw + w + 1] = a + out[by * nw + w + 2] = b + end + def.blocks = out + def.width = nw +end + +local function specialId(constants, name) + for i, n in ipairs(constants and constants.specialOrder or {}) do + if n == name then return i - 1 end + end + error("gen2 cache special order has no " .. name) +end + +local function gateEvent(scripts, receptionist) + for _, cmd in ipairs(scripts[receptionist.scriptKey] or {}) do + if cmd.op == "checkevent" then return cmd.event end + end + error("gen2 cable club receptionist has no gate event") +end + +local function says(text, ...) + local list = { { op = "rawtext", text = text }, { op = "waitbutton" }, + { op = "closetext" } } + for _, cmd in ipairs({ ... }) do list[#list + 1] = cmd end + list[#list + 1] = { op = "end" } + return list +end + +local function addScripts(data, def, room, plan, rcptConst, leaveScene) + local scripts = data.gen2Scripts + scripts.movements = scripts.movements or {} + local mv = scripts.movements + mv[MOVE.aside] = moves(SLOW_STEP + UP, SLOW_STEP + LEFT, TURN_HEAD + DOWN) + mv[MOVE.playerIn] = moves(STEP + UP, STEP + UP, STEP + UP) + mv[MOVE.makeWay] = moves(SLOW_STEP + UP, SLOW_STEP + LEFT, TURN_HEAD + RIGHT) + mv[MOVE.playerOut] = moves(STEP + DOWN, STEP + DOWN, STEP + DOWN) + mv[MOVE.back] = moves(SLOW_STEP + RIGHT, SLOW_STEP + DOWN) + scripts[KEY.closed] = says(TEXT.closed) + scripts[KEY.noMon] = says(TEXT.noMon) + scripts[KEY.decline] = says(TEXT.decline) + scripts[M.RECEPTIONIST_KEY] = { + { op = "faceplayer" }, + { op = "opentext" }, + { op = "checkevent", event = gateEvent(scripts, plan.receptionist) }, + { op = "iffalse", script = KEY.closed }, + { op = "readvar", var = VAR_PARTYCOUNT }, + { op = "iffalse", script = KEY.noMon }, + { op = "rawtext", text = TEXT.intro }, + { op = "yesorno" }, + { op = "iffalse", script = KEY.decline }, + { op = "rawtext", text = TEXT.save }, + { op = "yesorno" }, + { op = "iffalse", script = KEY.decline }, + { op = "special", id = specialId(data.gen2Constants, "TryQuickSave") }, + { op = "iffalse", script = KEY.decline }, + { op = "special", id = specialId(data.gen2Constants, "HealParty") }, + { op = "rawtext", text = TEXT.comeIn }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "applymovementlasttalked", movement = MOVE.aside }, + { op = "applymovement", object = 0, movement = MOVE.playerIn }, + { op = "warpcheck" }, + { op = "end" }, + } + scripts[M.LEFT_KEY] = { + { op = "applymovement", object = rcptConst, movement = MOVE.makeWay }, + { op = "applymovement", object = 0, movement = MOVE.playerOut }, + { op = "applymovement", object = rcptConst, movement = MOVE.back }, + { op = "setscene", scene = 0 }, + { op = "setmapscene", group = room.group, map = room.map, scene = 0 }, + { op = "end" }, + } + scripts[RoomMap.SETUP_KEY] = { + { op = "setscene", scene = 1 }, + { op = "setmapscene", group = def.group, map = def.map, scene = leaveScene }, + { op = "end" }, + } +end + +local applied = setmetatable({}, { __mode = "k" }) + +function M.isApplied(maps) + return maps ~= nil and applied[maps] == true +end + +function M.apply(data, opts) + if not Setting.patchesOn(2, opts) then return false end + local maps, tilesets = data.gen2Maps, data.gen2Tilesets + local def = maps and maps[M.MAP_ID] + if not (def and tilesets and data.gen2Scripts) then return false end + if applied[maps] or maps[M.ROOM_ID] then + applied[maps] = true + return true + end + local tileset = assert(tilesets[def.tileset], "gen2 cache has no " .. tostring(def.tileset)) + local plan = M.layout(def) + widen(def, tileset, plan) + local index = 0 + for _, obj in ipairs(def.objects) do + if (obj.index or 0) > index then index = obj.index end + end + local rcpt = copy(plan.receptionist) + rcpt.index = index + 1 + rcpt.x, rcpt.y = plan.deskX, plan.deskY + rcpt.script = nil + rcpt.scriptKey = M.RECEPTIONIST_KEY + rcpt.eventFlag = 65535 + def.objects[#def.objects + 1] = rcpt + local warpIndex = #def.warps + 1 + local room = RoomMap.build(maps, tilesets, { + map = M.MAP_ID, warp = warpIndex, group = def.group, mapNum = def.map, + }) + def.warps[warpIndex] = { + x = plan.doorX, y = plan.doorY, destMap = room.id, destWarp = 1, + destGroup = room.group, destMapNum = room.map, + } + local leaveScene = 0 + for id, row in pairs(def.sceneScripts or {}) do + local sid = type(row) == "table" and row.sceneId or id + if type(sid) == "number" and sid >= leaveScene then leaveScene = sid + 1 end + end + def.sceneScripts = def.sceneScripts or {} + def.sceneScripts[leaveScene] = { sceneId = leaveScene, scriptKey = M.LEFT_KEY } + maps[room.id] = room + addScripts(data, def, room, plan, rcpt.index + 1, leaveScene) + applied[maps] = true + return true +end + +function M.noteWarp(world, prevMapId, prevWarpIndex, warpDef, destMapId, arrival) + if destMapId ~= M.MAP_ID or not (arrival and arrival.destWarp == 0xff) then return end + local maps = world and world.maps + if not (maps and maps[M.ROOM_ID]) or prevMapId == M.MAP_ID then return end + local game = world.game + local save = game and game.save + if not (save and warpDef) then return end + Origin.record(save, { + gen = 2, version = save.version or require("src.core.GameVersion").current, + map = prevMapId, warp = prevWarpIndex, x = warpDef.x, y = warpDef.y, + facing = world.player and world.player.facing, + }) +end + +return M diff --git a/src/world/gen2/UnionRoomMap.lua b/src/world/gen2/UnionRoomMap.lua new file mode 100644 index 00000000..7438a317 --- /dev/null +++ b/src/world/gen2/UnionRoomMap.lua @@ -0,0 +1,153 @@ +local UnionRoomMap = {} + +UnionRoomMap.ID = "UNION_ROOM" +UnionRoomMap.TEMPLATE = "TRADE_CENTER" +UnionRoomMap.CAP = 40 +UnionRoomMap.WIDTH_BLOCKS = 13 +UnionRoomMap.HEIGHT_BLOCKS = 13 +UnionRoomMap.WIDTH = UnionRoomMap.WIDTH_BLOCKS * 2 +UnionRoomMap.HEIGHT = UnionRoomMap.HEIGHT_BLOCKS * 2 +UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y = 12, 25 +UnionRoomMap.EXITS = { { x = 12, y = 25 }, { x = 13, y = 25 } } +UnionRoomMap.SETUP_KEY = "union:room_setup" + +local CELL_XS = { 3, 6, 9, 12, 15, 18, 21 } +local CELL_YS = { 5, 8, 11, 14, 17, 20 } +local DOOR_X, DOOR_Y = 12, 23 + +local cells, bySlot, byKey = {}, {}, {} +for _, y in ipairs(CELL_YS) do + for _, x in ipairs(CELL_XS) do + if not (x == DOOR_X and y == 20) then + cells[#cells + 1] = { x = x, y = y } + end + end +end +table.sort(cells, function(p, q) + local dp = math.abs(p.x - DOOR_X) + math.abs(p.y - DOOR_Y) + local dq = math.abs(q.x - DOOR_X) + math.abs(q.y - DOOR_Y) + if dp ~= dq then return dp < dq end + local ap, aq = math.abs(p.x - DOOR_X), math.abs(q.x - DOOR_X) + if ap ~= aq then return ap < aq end + if p.x ~= q.x then return p.x < q.x end + return p.y > q.y +end) +for i, c in ipairs(cells) do + if i <= UnionRoomMap.CAP then + bySlot[i] = c + byKey[c.y * 64 + c.x] = i + end +end +UnionRoomMap.CELLS = cells + +function UnionRoomMap.cellFor(slot) + local c = bySlot[tonumber(slot) or -1] + if not c then return nil end + return c.x, c.y, "down" +end + +function UnionRoomMap.slotAt(x, y) + x, y = tonumber(x), tonumber(y) + if not (x and y) then return nil end + return byKey[y * 64 + x] +end + +function UnionRoomMap.entry() + return UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y, "up" +end + +local function same(a, b) + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true +end + +function UnionRoomMap.block(tileset, tiles, coll) + for id = 1, #tileset.blocks do + if same(tileset.blocks[id], tiles) and tileset.collision[id] + and same(tileset.collision[id], coll) then + return id - 1 + end + end + local id = #tileset.blocks + 1 + tileset.blocks[id] = tiles + tileset.collision[id] = coll + return id - 1 +end + +function UnionRoomMap.compose(tileset, top, bottom) + local tt, bt = tileset.blocks[top + 1], tileset.blocks[bottom + 1] + local tc, bc = tileset.collision[top + 1], tileset.collision[bottom + 1] + local tiles = {} + for i = 1, 8 do tiles[i] = tt[i] end + for i = 9, 16 do tiles[i] = bt[i] end + return UnionRoomMap.block(tileset, tiles, { tc[1], tc[2], bc[3], bc[4] }) +end + +local function blockAt(def, bx, by) + return def.blocks[by * def.width + bx + 1] +end + +local function nextMapNumber(maps, group) + local top = 0 + for _, def in pairs(maps) do + if type(def) == "table" and def.group == group and (def.map or 0) > top then + top = def.map + end + end + return top + 1 +end + +local COPIED = { + "borderBlock", "environment", "environmentId", "fishGroup", "generation", + "group", "landmark", "music", "palette", "phoneService", "tileset", + "tilesetId", +} + +function UnionRoomMap.build(maps, tilesets, link) + local src = assert(maps[UnionRoomMap.TEMPLATE], "gen2 cache has no TRADE_CENTER") + local tileset = assert(tilesets[src.tileset], "gen2 cache has no " .. tostring(src.tileset)) + local wall = blockAt(src, 0, 0) + local floor = blockAt(src, 0, 1) + local exit = src.warps[1] + local mat = blockAt(src, math.floor(exit.x / 2), math.floor(exit.y / 2)) + local exitBlock = (exit.y % 2 == 1) and UnionRoomMap.compose(tileset, floor, mat) + or UnionRoomMap.compose(tileset, mat, floor) + local w, h = UnionRoomMap.WIDTH_BLOCKS, UnionRoomMap.HEIGHT_BLOCKS + local blocks = {} + for by = 0, h - 1 do + for bx = 0, w - 1 do + blocks[by * w + bx + 1] = (by == 0) and wall or floor + end + end + blocks[math.floor(UnionRoomMap.EXIT_Y / 2) * w + + math.floor(UnionRoomMap.EXIT_X / 2) + 1] = exitBlock + local def = {} + for _, key in ipairs(COPIED) do def[key] = src[key] end + def.id = UnionRoomMap.ID + def.map = nextMapNumber(maps, src.group) + def.source = "union" + def.width, def.height = w, h + def.blocks = blocks + def.warps = {} + for _, cell in ipairs(UnionRoomMap.EXITS) do + def.warps[#def.warps + 1] = { + x = cell.x, y = cell.y, destMap = link.map, destWarp = link.warp, + destGroup = link.group, destMapNum = link.mapNum, + } + end + def.objects = {} + def.bgEvents = {} + def.coordEvents = {} + def.callbacks = {} + def.connections = {} + def.sceneScripts = { + [0] = { sceneId = 0, scriptKey = UnionRoomMap.SETUP_KEY }, + [1] = { sceneId = 1 }, + } + return def +end + +return UnionRoomMap diff --git a/src/world/gen2/UnionRoomPresence.lua b/src/world/gen2/UnionRoomPresence.lua new file mode 100644 index 00000000..1392d830 --- /dev/null +++ b/src/world/gen2/UnionRoomPresence.lua @@ -0,0 +1,475 @@ +local Avatars = require("src.online.union.Avatars") +local GameVersion = require("src.core.GameVersion") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local RoomMap = require("src.world.gen2.UnionRoomMap") +local Tag = require("src.ui.gen2.union.Tag") + +local P = {} + +P.MAP_ID = RoomMap.ID +P.INDEX_BASE = 300 +P.CONNECT_SECONDS = 20 +P.FADE = 0.35 +P.FADE_STEP = 1 / 16 +P.TAG_RANGE = 2 +P.FOOT = 12 + +local function now() + local t = love and love.timer and love.timer.getTime + return t and t() or os.clock() +end + +local function lazy(name) + return require(name) +end + +local Entity = {} +Entity.__index = Entity +P.Entity = Entity + +function Entity.new(session, p) + local x, y, facing = RoomMap.cellFor(p.slot) + local e = setmetatable({ + session = session, participant = p, unionSlot = p.slot, + def = { index = P.INDEX_BASE + p.slot, x = x, y = y, unionSlot = p.slot }, + id = ("%s_union_%d"):format(P.MAP_ID, p.slot), mapId = P.MAP_ID, + cellX = x, cellY = y, homeX = x, homeY = y, px = x * 16, py = y * 16, + facing = facing, homeFacing = facing, moving = false, progress = 0, + stepFlip = false, inGrass = false, spawnLatched = true, frozen = false, + kind = "stand", alpha = session.lost and P.FADE or 0, radiusX = 0, radiusY = 0, + }, Entity) + e.avatar = Avatars.resolve(p, { version = GameVersion.get() }) + return e +end + +function Entity:setParticipant(p) + local old = self.participant + self.participant = p + if Avatars.key(old) ~= Avatars.key(p) or Avatars.pickKey(old) ~= Avatars.pickKey(p) then + self.avatar = Avatars.resolve(p, { version = GameVersion.get() }) + end +end + +function Entity:covers(cx, cy) + return self.cellX == cx and self.cellY == cy +end + +function Entity:walkPhase() return 0 end + +function Entity:inRadius() return false end + +function Entity:scriptFace(dir) + if dir then self.facing = dir end +end + +function Entity:facePlayer(player) + if not player then return end + local dx, dy = player.cellX - self.cellX, player.cellY - self.cellY + if math.abs(dx) > math.abs(dy) then + self.facing = dx > 0 and "right" or "left" + else + self.facing = dy > 0 and "down" or "up" + end +end + +function Entity:update() end + +function Entity:fade() + local target = self.session.lost and P.FADE or 1 + if self.alpha < target then + self.alpha = math.min(target, self.alpha + P.FADE_STEP) + elseif self.alpha > target then + self.alpha = math.max(target, self.alpha - P.FADE_STEP) + end +end + +local heights = setmetatable({}, { __mode = "k" }) + +function P.artHeight(entry) + if not entry or entry.standin then return Avatars.STANDIN_H end + local hit = heights[entry] + if hit then return hit end + local h = entry.h + local id = Avatars.imageData(entry) + local rect = entry.rects and entry.rects[0] + if id and id.getPixel and rect then + local gb = entry.layout ~= "gba" + for y = 0, rect.h - 1 do + local opaque = false + for x = 0, rect.w - 1 do + local r, _, _, a = id:getPixel(rect.x + x, rect.y + y) + if (gb and r <= 0.83) or (not gb and a > 0) then opaque = true break end + end + if opaque then + h = rect.h - y + break + end + end + end + heights[entry] = h + return h +end + +function Entity:height() + return P.artHeight(self.avatar) +end + +function Entity:draw(ox, oy, scale) + local G = love.graphics + local s = scale or 1 + local fx = (ox or 0) + (self.px + 8) * s + local fy = (oy or 0) + (self.py + P.FOOT) * s + G.setColor(1, 1, 1, self.alpha) + Avatars.draw(self.avatar, fx, fy, self.facing, 0, false, s) + G.setColor(1, 1, 1, 1) + local p = self.participant + Tag.draw(fx, fy - self:height() * s, p.name, Participant.badgeDigit(p), + self.session.tagged == self, s) +end + +function Entity:onTalk(world) + return self.session:talk(world, self) +end + +local Session = {} +Session.__index = Session +P.Session = Session + +function P.new(game, world, opts) + opts = opts or {} + local client = opts.client or lazy("src.online.Client") + return setmetatable({ + game = game, world = world, opts = opts, + client = client, + connect = opts.connect, + room = opts.room or Room.new({ client = client }), + state = "idle", since = nil, lost = false, + bySlot = {}, pending = {}, notices = {}, seen = {}, abandoned = {}, + ui = nil, activity = nil, tagged = nil, err = nil, + spawned = 0, despawned = 0, + }, Session) +end + +function Session:connectModule() + return self.connect or lazy("src.online.Connect") +end + +function Session:ctx() + local o = self.opts.ctx + if o then return o end + local game, world = self.game, self.world + local save = game and game.save or {} + local player = save.player or {} + local version = GameVersion.get() + local gender = 0 + if version == "crystal" and world and world.playerGender then + gender = lazy("src.world.gen2.FieldMoves").isFemale(world:playerGender()) and 1 or 0 + end + return { version = version, game = game, name = player.name, trainerId = player.id, + gender = gender, style = Participant.DEFAULT_STYLE } +end + +function Session:start() + if self.state ~= "idle" then return end + local cs = self.client.state() + if cs == "online" then return self:join() end + self.state = "connecting" + self.since = now() + if cs == "connecting" or cs == "reconnecting" then return end + local ctx = self:ctx() + local profile = Room.buildProfile(ctx) + if not profile then return self:fail("profile") end + local Connect = self:connectModule() + local okCall, ok = pcall(Connect.start, { + source = "game", version = ctx.version, + trainerName = ctx.name, profiles = { profile }, + presence = { where = "game", status = "idle", version = ctx.version }, + }) + if not (okCall and ok) then return self:fail("offline") end + self:notice({ kind = "connecting" }) +end + +function Session:join() + local ok, err = self.room:join(self:ctx()) + if not ok then return self:fail(err and err.error or "profile") end + self.state = "joined" +end + +function Session:fail(code) + self:clear() + if self.room.state ~= "idle" and self.room.state ~= "left" then self.room:leave() end + self.state = "offline" + self.err = code + self.lost = false + self:notice({ kind = "error", code = code }) +end + +function Session:notice(n) + for i = #self.notices, 1, -1 do + local k = self.notices[i].kind + if k == "connecting" or k == "lost" then table.remove(self.notices, i) end + end + self.notices[#self.notices + 1] = n +end + +function Session:idle() + local game, world = self.game, self.world + if self.ui or self.activity then return false end + if game and game.stack and game.stack:top() then return false end + if world and world.busy and world:busy() then return false end + local p = world and world.player + if p and p.moving then return false end + return true +end + +local function removeFrom(list, item) + if type(list) ~= "table" then return end + for i = #list, 1, -1 do + if list[i] == item then table.remove(list, i) end + end +end + +function Session:blocked(x, y) + local p = self.world and self.world.player + if not p then return false end + if p.cellX == x and p.cellY == y then return true end + return p.moving and p.targetX == x and p.targetY == y +end + +function Session:spawn(p) + local x, y = RoomMap.cellFor(p.slot) + if not x then return nil end + self:despawn(p.slot) + if self:blocked(x, y) then + self.pending[p.slot] = p + return nil + end + self.pending[p.slot] = nil + local e = Entity.new(self, p) + local world = self.world + world.npcs = world.npcs or {} + world.entities = world.entities or {} + table.insert(world.npcs, e) + table.insert(world.entities, e) + self.bySlot[p.slot] = e + self.spawned = self.spawned + 1 + return e +end + +function Session:despawn(slot) + self.pending[slot] = nil + local e = self.bySlot[slot] + if not e then return end + removeFrom(self.world.npcs, e) + removeFrom(self.world.entities, e) + if self.tagged == e then self.tagged = nil end + self.bySlot[slot] = nil + self.despawned = self.despawned + 1 +end + +function Session:clear() + for slot in pairs(self.bySlot) do self:despawn(slot) end + self.pending = {} +end + +function Session:apply(diff) + for _, p in ipairs(diff.left or {}) do + local e = self.bySlot[p.slot] + if (e and e.participant.id == p.id) or (self.pending[p.slot] and self.pending[p.slot].id == p.id) then + self:despawn(p.slot) + end + end + for _, p in ipairs(diff.joined or {}) do self:spawn(p) end + for _, p in ipairs(diff.changed or {}) do + local e = self.bySlot[p.slot] + if e then + e:setParticipant(p) + elseif self.pending[p.slot] then + self.pending[p.slot] = p + end + end +end + +function Session:retryPending() + for slot, p in pairs(self.pending) do + local x, y = RoomMap.cellFor(slot) + if not self:blocked(x, y) then self:spawn(p) end + end +end + +function Session:entity(slot) + return self.bySlot[slot] +end + +function Session:entities() + local out = {} + for _, e in pairs(self.bySlot) do out[#out + 1] = e end + table.sort(out, function(a, b) return a.unionSlot < b.unionSlot end) + return out +end + +function Session:pickTagged() + local p = self.world and self.world.player + if not p then self.tagged = nil return end + local best, bestD + local d = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[p.facing or "down"] + local faced = d and self.world.npcAt and self.world:npcAt(p.cellX + d[1], p.cellY + d[2]) + if faced and faced.session == self then self.tagged = faced return end + for _, e in pairs(self.bySlot) do + local dist = math.abs(e.cellX - p.cellX) + math.abs(e.cellY - p.cellY) + if dist <= P.TAG_RANGE and (not bestD or dist < bestD + or (dist == bestD and e.unionSlot < best.unionSlot)) then + best, bestD = e, dist + end + end + self.tagged = best +end + +function Session:watchLink() + local cs = self.client.state() + if cs == "online" then + if self.lost then self.lost = false end + elseif cs == "reconnecting" or cs == "connecting" then + if not self.lost then + self.lost = true + self:notice({ kind = "lost" }) + end + else + self:fail("lost") + end +end + +function Session:watchActivity() + local xr = self.room:xgRoom() + if self.activity then + if self.activity.done then + self.activity = nil + self.room:dropPrep() + end + return + end + if not xr or self.ui then return end + for handle in pairs(self.abandoned) do + if handle.room and handle.room == xr.room then + self.abandoned[handle] = nil + local prep = self.room:prep() + if prep then + prep:cancel("cancel") + prep:leave() + end + self.room:dropPrep() + return + end + end + if self.closedRoom == xr.room then return end + local Activity = lazy("src.ui.gen2.union.Activity") + self.activity = Activity.begin(self.game, self.room, xr.mode or (xr.xg and xr.xg.mode), { session = self }) + self.closedRoom = xr.room +end + +function Session:watchInvites() + if not self:idle() then return end + for _, inv in ipairs(self.room:incoming()) do + if not self.seen[inv.id] then + self.seen[inv.id] = true + lazy("src.ui.gen2.union.Talk").prompt(self, inv) + return + end + end +end + +function Session:pumpNotices() + while #self.notices > 0 do + local n = self.notices[1] + if n.kind == "lost" and not self.lost then + table.remove(self.notices, 1) + elseif n.kind == "connecting" and self.state ~= "connecting" then + table.remove(self.notices, 1) + else + break + end + end + local n = self.notices[1] + if not n or not self:idle() then return end + table.remove(self.notices, 1) + lazy("src.ui.gen2.union.Talk").notice(self, n) +end + +function Session:update() + if self.state == "idle" then self:start() end + if self.state == "connecting" then + local cs = self.client.state() + if cs == "online" then + self:join() + elseif cs == "error" or now() - (self.since or now()) > P.CONNECT_SECONDS then + self:fail("offline") + end + end + if self.state == "joined" then + self:watchLink() + end + if self.state == "joined" then + local diff = self.room:poll() + self:apply(diff) + if diff.error then + self:fail(diff.error.error) + else + self:retryPending() + self:watchActivity() + self:watchInvites() + end + end + for _, e in pairs(self.bySlot) do e:fade() end + self:pickTagged() + self:pumpNotices() +end + +function Session:talk(world, e) + if self.ui or self.activity then return true end + e:facePlayer(world and world.player) + lazy("src.ui.gen2.union.Talk").open(self, e) + return true +end + +function Session:uiOpen(kind) + self.ui = kind or true +end + +function Session:uiDone() + self.ui = nil + for _, e in pairs(self.bySlot) do e.facing = e.homeFacing end +end + +function Session:close() + if self.activity and self.activity.abort then self.activity:abort("left") end + self.activity = nil + self:clear() + if self.room.state ~= "idle" and self.room.state ~= "left" then self.room:leave() end + self.state = "closed" +end + +local current = nil + +function P.tick(game) + local world = game and game.world + local onRoom = world and world.map and world.map.id == P.MAP_ID + if current and (not onRoom or current.world ~= world) then + current:close() + current = nil + end + if onRoom and not current then current = P.new(game, world, P.defaults) end + if current then current:update() end + return current +end + +function P.active() + return current +end + +function P.reset() + if current then current:close() end + current = nil +end + +return P diff --git a/src/world/gen2/UnionSafety.lua b/src/world/gen2/UnionSafety.lua new file mode 100644 index 00000000..74069307 --- /dev/null +++ b/src/world/gen2/UnionSafety.lua @@ -0,0 +1,135 @@ +local Origin = require("src.online.union.Origin") +local Map = require("src.world.gen2.Map") +local Permissions = require("src.world.gen2.Permissions") +local Center = require("src.world.gen2.UnionCenter2F") + +local Safety = {} + +local function tilesetOf(data, def) + return data.gen2Tilesets and data.gen2Tilesets[def.tileset] +end + +function Safety.vanillaWidthCells(maps) + local def = maps[Center.MAP_ID] + if not def then return 0 end + return (def.width - (maps[Center.ROOM_ID] and 2 or 0)) * 2 +end + +function Safety.isAdded(data, pos) + if type(pos) ~= "table" then return false end + local maps = data.gen2Maps + if pos.map == Center.ROOM_ID then return true end + if pos.map ~= Center.MAP_ID then return false end + local def = maps[Center.MAP_ID] + if not def then return true end + local x, y = pos.x or -1, pos.y or -1 + return x < 0 or y < 0 or x >= Safety.vanillaWidthCells(maps) or y >= def.height * 2 +end + +local function stairsWarp(def) + for i, w in ipairs(def.warps or {}) do + if w.destMap == Center.MAP_ID then return i end + end + return nil +end + +function Safety.nurseFront(data, mapId) + local def = mapId and data.gen2Maps[mapId] + if not (def and stairsWarp(def)) then return nil end + local nurse + for _, o in ipairs(def.objects or {}) do + if o.sprite == "SPRITE_NURSE" then nurse = o end + end + local tileset = nurse and tilesetOf(data, def) + if not tileset then return nil end + local map = Map.new(def, tileset) + local y = nurse.y + 1 + while map:inBounds(nurse.x, y) and Permissions.isCounter(map:cellCollision(nurse.x, y)) do + y = y + 1 + end + if y == nurse.y + 1 or not map:isWalkable(nurse.x, y) then return nil end + return nurse.x, y, "up" +end + +local function healCenter(save, data) + local spawns = data.gen2Landmarks and data.gen2Landmarks.spawns + local s = spawns and spawns[save.spawn] + if not (s and s.map) then return nil, s end + local ids = {} + for id, def in pairs(data.gen2Maps) do + if type(def) == "table" and stairsWarp(def) then + for _, w in ipairs(def.warps) do + if w.destMap == s.map then ids[#ids + 1] = id break end + end + end + end + table.sort(ids) + return ids[1], s +end + +function Safety.target(save, data) + local origin = Origin.get(save) + local candidates = {} + if origin and origin.gen == 2 then candidates[#candidates + 1] = origin.map end + if type(save.backupWarp) == "table" then candidates[#candidates + 1] = save.backupWarp.map end + local heal, spawn = healCenter(save, data) + candidates[#candidates + 1] = heal + for _, id in ipairs(candidates) do + local x, y, facing = Safety.nurseFront(data, id) + if x then return id, x, y, facing, id end + end + if spawn and spawn.map and data.gen2Maps[spawn.map] then + return spawn.map, spawn.x, spawn.y, "down", nil + end + return nil +end + +local function cleanScenes(scenes, maps) + if type(scenes) ~= "table" then return scenes end + local out = {} + for k, v in pairs(scenes) do out[k] = v end + out[Center.ROOM_ID] = nil + local def = maps[Center.MAP_ID] + local scene = out[Center.MAP_ID] + local row = def and scene and def.sceneScripts and def.sceneScripts[scene] + if scene and (not row or row.scriptKey == Center.LEFT_KEY) then + out[Center.MAP_ID] = 0 + end + return out +end + +local function relocate(save, data) + local mapId, x, y, facing, center = Safety.target(save, data) + save.mapScenes = cleanScenes(save.mapScenes, data.gen2Maps) + if not mapId then + save.position = nil + return "spawn" + end + save.position = { map = mapId, x = x, y = y, facing = facing } + if center then + save.backupWarp = { map = center, warp = stairsWarp(data.gen2Maps[center]) } + end + if type(save.mapObjectMasks) == "table" and save.mapObjectMasks.map ~= mapId then + save.mapObjectMasks = nil + end + return mapId +end + +function Safety.seal(save, data) + if not (type(save) == "table" and data and data.gen2Maps) then return nil end + if not Safety.isAdded(data, save.position) then return nil end + return relocate(save, data) +end + +function Safety.settle(save, data) + if not (type(save) == "table" and data and data.gen2Maps) then return nil end + if not data.gen2Maps[Center.ROOM_ID] then + save.mapScenes = cleanScenes(save.mapScenes, data.gen2Maps) + end + if not Safety.isAdded(data, save.position) then return nil end + local where = relocate(save, data) + Origin.clear(save) + return where +end + +return Safety diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index d962cdad..643b004a 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -8641,6 +8641,7 @@ function World:interactBody() local d = Map.DELTA[p.facing] local fx, fy = p.cellX + d[1], p.cellY + d[2] local npc = self:npcAt(self:facingObjectCell()) + if npc and npc.onTalk then return npc:onTalk(self) end -- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts -- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any -- setlasttalked of their own. The port only wrote it from the explicit @@ -10462,6 +10463,8 @@ function World:takeWarp(warpDef) if ok then self:spawnFacing() self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) + require("src.world.gen2.UnionCenter2F").noteWarp(self, prevMapId, + prevWarpIndex, warpDef, destMapId, destWarp) end return ok end) diff --git a/tests/drivers/em_link_union_plaza.lua b/tests/drivers/em_link_union_plaza.lua index 01507f7d..9421a349 100644 --- a/tests/drivers/em_link_union_plaza.lua +++ b/tests/drivers/em_link_union_plaza.lua @@ -17,11 +17,15 @@ return function(game) local SaveData = require("src.core.SaveData") local Schema = require("src.core.game3.save_schema_firered") local raw = love.filesystem.read("saves/emerald/slot1.lua") - if not check(type(raw) == "string", "identity has a save") then return d.finish() end - local session = Schema.fromSaveTable(SaveData.decode(raw)) - require("src.core.game3.options").bind(session, game.options) - game:adoptSave(session, true) - game:_enterField(session, "continue") + local session + if type(raw) == "string" then + session = Schema.fromSaveTable(SaveData.decode(raw)) + require("src.core.game3.options").bind(session, game.options) + game:adoptSave(session, true) + game:_enterField(session, "continue") + else + game:_handleBootAction({ action = "new_game", name = "MAY", gender = 1 }) + end U.wait(30) S.settle(game) @@ -126,7 +130,7 @@ return function(game) relay:handle(s, { type = "lobby_hello", protocol = 3, name = "P" .. i, profiles = { live }, presence = { where = "launcher", status = "idle", version = v } }) s.avatar = { name = "P" .. i, trainerId = 0x1000 + i, gender = i % 2, version = v } - relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = s.avatar }) peers[#peers + 1] = s end check(waitFor(function() return Union.playerCount() == 3 end, 8, 120), "three FR/LG/EM plaza members appear") diff --git a/tests/drivers/g3link_room_presence.lua b/tests/drivers/g3link_room_presence.lua index beb68031..ff41bc15 100644 --- a/tests/drivers/g3link_room_presence.lua +++ b/tests/drivers/g3link_room_presence.lua @@ -105,10 +105,9 @@ return function(game) 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 } }) + presence = { where = "launcher", status = status or "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 }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = s.avatar }) return s end diff --git a/tests/drivers/g3link_union_plaza.lua b/tests/drivers/g3link_union_plaza.lua index 499aeaff..084163b0 100644 --- a/tests/drivers/g3link_union_plaza.lua +++ b/tests/drivers/g3link_union_plaza.lua @@ -137,11 +137,11 @@ return function(game) me.presence.where = "union" local blue = peer("b0000002", "BLUE", 0x2222, 0, "leafgreen") - relay:handle(blue, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = blue.avatar }) + relay:handle(blue, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = blue.avatar }) local crowd = { blue } for i = 2, OTHERS do local s = peer(string.format("c%07x", i), "T" .. i, 0x100 + i * 13, i % 2, i % 3 == 0 and "leafgreen" or "firered") - relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = s.avatar }) crowd[#crowd + 1] = s end result(waitFor(function() return Union.playerCount() == OTHERS end, 10, 120), diff --git a/tests/drivers/g3link_union_plaza_perf.lua b/tests/drivers/g3link_union_plaza_perf.lua index c4ea43e9..75aa8c20 100644 --- a/tests/drivers/g3link_union_plaza_perf.lua +++ b/tests/drivers/g3link_union_plaza_perf.lua @@ -111,7 +111,7 @@ return function(game) relay:handle(s, { type = "lobby_hello", protocol = 3, name = "P" .. i, profiles = { live }, presence = { where = "launcher", status = "idle", version = "firered" } }) s.avatar = { name = "P" .. i, trainerId = i * 37, gender = i % 2, version = "firered" } - relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = s.avatar }) peers[i] = s end end diff --git a/tests/drivers/g3link_union_room.lua b/tests/drivers/g3link_union_room.lua index 86901a31..1da2a63b 100644 --- a/tests/drivers/g3link_union_room.lua +++ b/tests/drivers/g3link_union_room.lua @@ -151,7 +151,7 @@ return function(game) local yellow = peer("d0000004", "YELLOW", 6, 0, "firered") for _, s in ipairs({ green, pink, yellow }) do s.presence.where = "union" - relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = live, avatar = s.avatar }) end local group = { leader = pink.id, members = { pink.id, yellow.id }, activity = "chat" } pink.group, pink.status = group, "chatting" diff --git a/tests/drivers/g3u_gen1_battle.lua b/tests/drivers/g3u_gen1_battle.lua new file mode 100644 index 00000000..c7cc676f --- /dev/null +++ b/tests/drivers/g3u_gen1_battle.lua @@ -0,0 +1,215 @@ +local U = require("tests.drivers.util") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Project = require("src.online.xgen.Project") +local Datasets = require("src.online.xgen.Datasets") +local BattleSession = require("src.online.union.BattleSession") +local L = require("tests.support.g3u_loopback") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +local function ser(v) + if type(v) ~= "table" then return type(v) .. ":" .. tostring(v) end + local keys = {} + for k in pairs(v) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local out = {} + for _, k in ipairs(keys) do out[#out + 1] = tostring(k) .. "=" .. ser(v[k]) end + return "{" .. table.concat(out, ",") .. "}" +end + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local v = GameVersion.get() + U.wait(10) + + local function mon(species, level, moves) + local m = Pokemon.new(game.data, species, level) + m.moves = {} + for i, id in ipairs(moves) do m.moves[i] = { id = id, pp = game.data.moves[id].pp } end + return m + end + game.save.party = { mon("CHANSEY", 50, { "POUND" }), mon("MEWTWO", 70, { "PSYCHIC_M", "SWIFT" }) } + local room = game.data.maps.UNION_ROOM and "UNION_ROOM" or "VIRIDIAN_POKECENTER" + game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 10, y = 10 } + if room == "UNION_ROOM" then + U.teleport(game, room, 12, 20, "up") + else + U.teleport(game, room, 3, 4, "up") + end + game.overworld.lastOutdoor = game.save.lastOutdoor + U.wait(10) + ok(game.overworld.map.id == room, "standing in " .. room) + SaveData.save(game.save) + U.wait(2) + + local saveName = SaveData.saveFilename(v) + local saveBefore = love.filesystem.read(saveName) + local partyBefore = ser(game.save.party) + local p = game.overworld.player + local mapBefore, xBefore, yBefore = game.overworld.map.id, p.cellX, p.cellY + + local ds = assert(Datasets.get(v)) + local mine = {} + for i, m in ipairs(game.save.party) do mine[i] = assert(Project.mon(m, ds)) end + local theirs = { + assert(Project.mon(mon("GEODUDE", 45, { "ROCK_THROW" }), ds)), + assert(Project.mon(mon("MAGIKARP", 10, { "SPLASH" }), ds)), + } + + local netA, netB = L.pair() + local go = { seed = 4242, size = 3 } + local gens = { [0] = 1, [1] = 3 } + local names = { [0] = game.save.player.name, [1] = "BOT" } + local bot = BattleSession.new({ net = netB, seat = 1, go = go, gens = gens, records = theirs, names = names }) + local allow = false + local function stepBot() + bot:update() + if not allow then return end + if bot.phase == "choose" then + for _, act in ipairs(bot:legal()) do + if act.kind == "move" then bot:choose(act) break end + end + elseif bot.phase == "replace" then + local list = bot:legal() + if list[1] then bot:pickReplacement(list[1].index) end + end + end + + local doneCount, doneResult = 0, nil + local launched, how + local okLaunch, Launch = pcall(require, "src.ui.g3u.Launch") + if okLaunch then + launched = Launch.start(game, 1, { ruleset = "g3u", net = netA, seat = 0, go = go, gens = gens, names = names, + records = mine, onDone = function(r) doneCount = doneCount + 1 doneResult = r end }) + how = "Launch" + end + if not launched then + local G = require("src.ui.g3u.Gen1Screen") + local bs = BattleSession.new({ net = netA, seat = 0, go = go, gens = gens, data = ds, records = mine, + names = names }) + launched = G.start(game, bs, { names = { me = names[0], foe = names[1] }, + onDone = function(r) doneCount = doneCount + 1 doneResult = r end }) + how = "Gen1Screen" + end + ok(launched ~= nil, "battle started via " .. tostring(how)) + + local G = require("src.ui.g3u.Gen1Screen") + local PartyMenu = require("src.ui.PartyMenu") + local shots = {} + local function shot(name) + if shots[name] then return end + shots[name] = true + U.still(game, ("%s/%s_g3u_%s.png"):format(SHOT_DIR, v, name)) + end + local function battle() + for _, s in ipairs(game.stack.states) do + if s.g3u then return s end + end + return nil + end + local function curText(b) + return b and b.current and b.current.text or "" + end + + local seenMenu, seenFaint, seenReplace, seenHit, seenBanner, seenEnd, seenWaiting = false, false, false, false, + false, false, false + local turns, myFaint = 0, false + local lastPhase + local held = 0 + for f = 1, 60000 do + local bw = battle() + if bw and bw.phase == "g3u" and bw.g3uWaiting then held = held + 1 else held = 0 end + allow = held > 40 + stepBot() + U.wait(1) + if doneCount > 0 and game.stack:top() == game.overworld then break end + local b = battle() + local top = game.stack:top() + if b and top == b then + local t = curText(b) + if t:find("UNION RULES", 1, true) and b.charIndex and b.total and b.charIndex >= b.total then + seenBanner = true + shot("01_banner") + end + if b.phase == "g3u" and b.g3uWaiting and not b.g3uEnding then + seenWaiting = true + shot("02b_waiting") + end + if b.phase == "moveSelect" and not shots["02_move_menu"] then + seenMenu = true + shot("02_move_menu") + end + if b.fx and b.fx.blink and b.enemy and b:fxHidden(b.enemy) and not shots["03_hit"] then + seenHit = true + shot("03_hit") + end + if b.fx and b.fx.faint and not shots["04_faint"] then + seenFaint = true + if b.fx.faint.battler and b.fx.faint.battler.isPlayer then myFaint = true end + shot("04_faint") + end + if (t:find("defeated", 1, true) or t:find("lost to", 1, true)) and b.charIndex >= b.total then + seenEnd = true + shot("06_end_text") + end + if b.phase ~= lastPhase and b.phase == "menu" then turns = turns + 1 end + lastPhase = b.phase + if b.phase == "menu" then + if b.menuIndex ~= 1 then U.tap(game, "up") U.tap(game, "left") else U.tap(game, "a") end + elseif b.phase == "moveSelect" then + if b.moveIndex ~= 1 then U.tap(game, "up") else U.tap(game, "a") end + elseif b.phase == "messages" and f % 6 == 0 then + U.tap(game, "a") + end + elseif getmetatable(top) == PartyMenu and b then + seenReplace = true + local party = top.party or {} + if not top.submenu then + local m = party[top.index] + if m and m.hp <= 0 then + U.tap(game, "down") + else + shot("05_replacement") + U.tap(game, "a") + end + else + U.tap(game, "a") + end + U.wait(4) + elseif f % 6 == 0 then + U.tap(game, "a") + end + end + + U.wait(30) + shot("07_back_in_room") + local after = game.overworld + ok(doneCount == 1, "onDone called once (" .. doneCount .. ")") + ok(doneResult ~= nil, "result " .. tostring(doneResult and doneResult.outcome) .. "/" .. + tostring(doneResult and doneResult.why)) + ok(doneResult and doneResult.outcome == "win" and doneResult.why == "faint", "the strong side wins by faint") + ok(seenBanner, "union rules banner shown") + ok(seenMenu, "move menu opened") + ok(seenHit, "a hit animation played") + ok(seenFaint and myFaint, "a faint played (mine " .. tostring(myFaint) .. ")") + ok(seenReplace, "replacement party menu opened") + ok(seenEnd, "winner line shown") + ok(seenWaiting, "waiting box shown between turns") + ok(turns >= 3, "played several turns (" .. turns .. ")") + ok(game.stack:top() == after, "back on the overworld") + ok(after.map.id == mapBefore and after.player.cellX == xBefore and after.player.cellY == yBefore, + ("same cell %s %d,%d -> %s %d,%d"):format(mapBefore, xBefore, yBefore, after.map.id, after.player.cellX, + after.player.cellY)) + ok(ser(game.save.party) == partyBefore, "save party untouched") + ok(love.filesystem.read(saveName) == saveBefore, "save file bytes unchanged") + ok(battle() == nil, "battle state gone from the stack") + ok(G ~= nil, "presenter module loaded") + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/g3u_gen2_battle.lua b/tests/drivers/g3u_gen2_battle.lua new file mode 100644 index 00000000..de802c28 --- /dev/null +++ b/tests/drivers/g3u_gen2_battle.lua @@ -0,0 +1,226 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local Datasets = require("src.online.xgen.Datasets") +local Project = require("src.online.xgen.Project") +local Table = require("src.battle.g3u.Table") +local BattleSession = require("src.online.union.BattleSession") +local L = require("tests.support.g3u_loopback") +local GameVersion = require("src.core.GameVersion") + +local function deepCopy(v, seen) + if type(v) ~= "table" then return v end + seen = seen or {} + if seen[v] then return seen[v] end + local out = {} + seen[v] = out + for k, x in pairs(v) do out[deepCopy(k, seen)] = deepCopy(x, seen) end + return out +end + +local function deepEq(a, b, seen) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + seen = seen or {} + if seen[a] == b then return true end + seen[a] = b + for k, v in pairs(a) do + if not deepEq(v, b[k], seen) then return false end + end + for k in pairs(b) do + if a[k] == nil then return false end + end + return true +end + +local function snapshotFiles(dir, out) + out = out or {} + local fs = love.filesystem + for _, name in ipairs(fs.getDirectoryItems(dir)) do + local path = dir == "" and name or (dir .. "/" .. name) + local info = fs.getInfo(path) + if info and info.type == "directory" and (dir ~= "" or name:find("save")) then + snapshotFiles(path, out) + elseif info and info.type == "file" then + out[path] = fs.read(path) + end + end + return out +end + +local function botRecord(t, n, level, moveId) + local b = Table.baseStats(t, n) + local function st(base) return math.floor(2 * base * level / 100) + 5 end + local hp = math.floor(2 * b.hp * level / 100) + level + 10 + return { + species = n, level = level, hp = hp, maxHp = hp, atk = st(b.atk), def = st(b.def), speed = st(b.spe), + spAtk = st(b.spa), spDef = st(b.spd), moves = { { id = moveId, pp = t.moves[moveId][4], ppUps = 0 } }, + gender = 2, friendship = 70, + } +end + +return function(game) + local fails = 0 + local version = GameVersion.current + local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3u-gen2" + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + return cond + end + local function quit() + print(("RESULT %s fails=%d"):format(fails == 0 and "PASS" or "FAIL", fails)) + love.event.quit(fails == 0 and 0 or 1) + end + + U.wait(60) + local world = game.world + if not ok(world and world.map, "world booted") then return quit() end + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 30), Mon.new(game.data, "TOTODILE", 30), + Mon.new(game.data, "PIDGEY", 25) } + local mapId = "CHERRYGROVE_POKECENTER_1F" + local nurse + for _, o in ipairs(world.maps[mapId].objects or {}) do + if o.sprite == "SPRITE_NURSE" then nurse = o end + end + world:warpToMapId(mapId, nurse.x, nurse.y + 2, "up") + U.wait(100) + local base = game.stack:top() + local at0 = { world.map.id, world.player.cellX, world.player.cellY } + ok(at0[1] == mapId, "standing in " .. tostring(mapId)) + game:writeSave() + U.wait(2) + local files0 = snapshotFiles("") + local party0 = deepCopy(save.party) + + local data = Datasets.get(version) + if not ok(data ~= nil, "gold dataset loads") then return quit() end + local records = {} + for i, mon in ipairs(save.party) do + local r, why = Project.mon(mon, data) + ok(r ~= nil, "record " .. i .. " projects " .. tostring(why or "")) + records[#records + 1] = r + end + local t = assert(Table.build(data, 2)) + local botParty = { botRecord(t, 143, 100, 153), botRecord(t, 19, 5, 33), botRecord(t, 19, 5, 33) } + + local netA, netB = L.pair() + local go = { seed = 4242, size = 3 } + local gens = { [0] = 2, [1] = 3 } + local names = { [0] = save.player.name or "GOLD", [1] = "MAY" } + local bot = BattleSession.new({ net = netB, seat = 1, go = go, gens = gens, records = botParty, names = names }) + local bstep = L.bot(bot, { seed = 9 }) + + local result, doneCount = nil, 0 + local Launch = require("src.ui.g3u.Launch") + local handle, why = Launch.start(game, 2, { ruleset = "g3u", net = netA, seat = 0, go = go, gens = gens, + names = names, records = records, onDone = function(r) result = r doneCount = doneCount + 1 end }) + if not ok(handle ~= nil, "Launch.start returns a handle " .. tostring(why or "")) then return quit() end + local host = handle.screen + + local shots = {} + local function still(name) + if shots[name] then return end + shots[name] = true + ok(U.still(game, dir .. "/" .. name), "shot " .. name) + end + + local seen = { menu = 0, banner = false, hit = false, faint = false, replace = false, endText = false } + local turnsChosen = 0 + for n = 1, 20000 do + bstep() + if result then break end + if n % 300 == 1 then + local bs = handle.bs + U.log("tick", n, host.stage, bs.phase, bs.result and bs.result.why, bs.result and bs.result.detail, + bot.phase, host.screen and host.screen.phase, tostring(game.stack:top())) + end + local top = game.stack:top() + local screen = host and host.screen + if screen and top == screen then + local msg = tostring(screen.message or "") + if msg:find("UNION RULES") and not seen.banner then + seen.banner = true + U.wait(100) + still("g3u2_01_banner.png") + end + if msg:find("GEN 3") and not seen.rules then + seen.rules = true + U.wait(100) + still("g3u2_01b_banner_rules.png") + end + if screen.hpAnim and not seen.hit then + seen.hit = true + still("g3u2_03_hit.png") + end + if msg:find("fainted") and not seen.faint then + seen.faint = true + U.wait(100) + still("g3u2_04_faint.png") + end + if msg:find("defeated") or msg:find("no more") or msg:find("draw") then + if not seen.endText then + seen.endText = true + U.wait(100) + still("g3u2_06_end.png") + end + end + if screen.phase == "menu" then + seen.menu = seen.menu + 1 + U.tap(game, "a") + elseif screen.phase == "moves" then + U.wait(2) + still("g3u2_02_move_menu.png") + turnsChosen = turnsChosen + 1 + U.tap(game, "a") + elseif screen.phase == "intro" or screen.phase == "resolving" or screen.phase:find("^refuse") + or screen.phase == "stats-box" then + U.tap(game, "a") + else + U.wait(1) + end + elseif top and top.party and top.onChoose and screen then + seen.replace = true + local pick + for i, m in ipairs(top.party) do + if not pick and (m.hp or 0) > 0 then pick = i end + end + top.index = pick or 1 + U.wait(2) + still("g3u2_05_replacement.png") + U.tap(game, "a") + else + U.wait(1) + end + end + for _ = 1, 600 do + if game.stack:top() == base and not world.mapSetup then break end + U.wait(1) + end + U.wait(30) + still("g3u2_07_back_in_room.png") + + ok(result ~= nil, "onDone fired with " .. tostring(result and result.outcome) .. "/" .. tostring(result and result.why)) + ok(doneCount == 1, "onDone fired exactly once") + ok(result and result.outcome == "win" and result.why == "faint", "battle ended in a win by faint") + ok(bot.result and bot.result.outcome == "lose", "bot saw the loss") + ok(seen.banner, "union rules banner shown") + ok(turnsChosen >= 2, ("several turns chosen (%d)"):format(turnsChosen)) + ok(seen.hit and seen.faint, "a hit and a faint played") + ok(seen.replace, "my replacement went through the party menu") + ok(seen.endText, "end line shown") + ok(game.stack:top() == base, "overworld is back on top") + ok(world.map.id == at0[1] and world.player.cellX == at0[2] and world.player.cellY == at0[3], + ("same cell %s %d,%d"):format(tostring(world.map.id), world.player.cellX, world.player.cellY)) + ok(deepEq(party0, save.party), "save party untouched") + local files1 = snapshotFiles("") + local same, count = true, 0 + for path, bytes in pairs(files0) do + count = count + 1 + if files1[path] ~= bytes then same = false print("changed " .. path) end + end + for path in pairs(files1) do if files0[path] == nil then same = false print("new " .. path) end end + ok(same and count > 0, ("save files unchanged (%d)"):format(count)) + ok(next(host.wrapped) == nil, "state update wrappers removed") + quit() +end diff --git a/tests/drivers/g3u_gen3_battle.lua b/tests/drivers/g3u_gen3_battle.lua new file mode 100644 index 00000000..35e33a6e --- /dev/null +++ b/tests/drivers/g3u_gen3_battle.lua @@ -0,0 +1,232 @@ +local U = require("tests.drivers.util") +local Project = require("src.online.xgen.Project") +local Datasets = require("src.online.xgen.Datasets") +local BattleSession = require("src.online.union.BattleSession") +local Table = require("src.battle.g3u.Table") +local F = require("tests.engine._g3u_fixture") +local L = require("tests.support.g3u_loopback") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +local CENTER = { + firered = { map = "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F", x = 7, y = 8 }, + leafgreen = { map = "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F", x = 7, y = 8 }, + emerald = { map = "EM_OLDALE_TOWN_POKEMON_CENTER_1F", x = 7, y = 8 }, +} + +local function ser(v, seen) + if type(v) ~= "table" then return type(v) .. ":" .. tostring(v) end + seen = seen or {} + if seen[v] then return "" end + seen[v] = true + local keys = {} + for k in pairs(v) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local out = {} + for _, k in ipairs(keys) do out[#out + 1] = tostring(k) .. "=" .. ser(v[k], seen) end + seen[v] = nil + return "{" .. table.concat(out, ",") .. "}" +end + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + return cond + end + local v = GameVersion.get() + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + local spot = CENTER[v] or CENTER.firered + local okBoot, bootErr = pcall(function() + game:_handleBootAction({ action = "new_game", name = "LEAF", gender = 0, + start = { map = spot.map, x = spot.x, y = spot.y, facing = "down" } }) + end) + if not okBoot then print("[driver] center start failed: " .. tostring(bootErr)) end + U.wait(90) + local Runtime = require("src.core.game3.runtime") + local Player = require("src.core.game3.player") + local Map = require("src.core.game3.map") + local Party = require("src.core.game3.party") + local Stack = require("src.ui.game3.stack") + local Message = require("src.ui.game3.message") + local PartyMenu = require("src.ui.game3.party_menu") + local AnimSeq = require("src.core.game3.battle.anim_seq") + local Ui = require("src.core.game3.battle.ui") + local SaveData = require("src.core.SaveData") + local session = Runtime.getSession() + if not ok(session ~= nil, "a " .. v .. " session is on the field") then + love.event.quit(1) + return + end + for _ = 1, 600 do + if not Message.isOpen() and not Stack.busy() then break end + if Message.isOpen() then U.tap(game, "a") end + U.wait(2) + end + ok(Map.current ~= nil, "standing in " .. tostring(Map.current)) + local C = require("src.core.game3.constants").of(v) + Party.giveMonToPlayer(session, C:require("species", "SPECIES_SQUIRTLE"), 14) + Party.giveMonToPlayer(session, C:require("species", "SPECIES_RATTATA"), 10) + Party.giveMonToPlayer(session, C:require("species", "SPECIES_PIDGEY"), 9) + game:saveGame() + U.wait(2) + local saveName = SaveData.saveFilename(v) + local saveBefore = love.filesystem.read(saveName) + ok(saveBefore ~= nil, "the save is on disk before the battle") + local partyBefore = ser(session.party) + local mapBefore, xBefore, yBefore, faceBefore = Map.current, Player.cellX, Player.cellY, Player.facing + + local ds = assert(Datasets.get(v)) + local mine = {} + for _, m in ipairs(session.party) do + local r, why = Project.mon(m, ds) + if r then mine[#mine + 1] = r else print("[driver] skip mon: " .. tostring(why)) end + end + ok(#mine == 3, "three projected records from the save party (" .. #mine .. ")") + + local red = F.real("red", "g1r-red") + ok(red ~= nil, "real red data for the bot (else fixture)") + red = red or F.gen1() + local t = assert(Table.build(red, 1)) + local theirs = { F.record(t, 53, { 10, 45 }, 30), F.record(t, 129, { 150 }, 5) } + + local Scope = require("src.battle.g3u.Scope") + local Mods = Scope.modules() + local function live() + local P, Mv = Mods["src.core.game3.pokemon"], Mods["src.core.game3.battle.moves"] + return { rawget(P, "_names"), rawget(P, "_types"), rawget(P, "_moveNames"), rawget(Mv, "_rom"), + rawget(Mv, "BY_NUM"), math.random } + end + local liveBefore = live() + + local netA, netB = L.pair() + local go = { seed = 777, size = 3 } + local gens = { [0] = 1, [1] = 3 } + local names = { [0] = "BOT", [1] = session.name or "LEAF" } + local bot = BattleSession.new({ net = netA, seat = 0, go = go, gens = gens, data = red, records = theirs, + names = names }) + local stepBot = L.bot(bot, { seed = 5, pick = function(_, list) + for _, a in ipairs(list) do if a.kind == "move" and a.slot == 1 then return a end end + return list[1] + end }) + + local doneCount, doneResult = 0, nil + local function onDone(r) doneCount = doneCount + 1 doneResult = r end + local handle, how + local okL, Launch = pcall(require, "src.ui.g3u.Launch") + if okL then + local h, why = Launch.start(game, 3, { ruleset = "g3u", net = netB, seat = 1, go = go, gens = gens, + names = names, records = mine, onDone = onDone }) + if h then handle, how = h.screen, "Launch" else print("[driver] Launch.start failed: " .. tostring(why)) end + end + if not handle then + local G = require("src.ui.g3u.Gen3Presenter") + local bs = BattleSession.new({ net = netB, seat = 1, go = go, gens = gens, records = mine, names = names }) + handle = G.start(game, bs, { names = { me = names[1], foe = names[0] }, onDone = onDone }) + how = "Gen3Presenter.start" + end + ok(handle ~= nil and handle.run ~= nil, "battle started via " .. tostring(how)) + local run = handle and handle.run + + local shots = {} + local function shot(name) + if shots[name] then return end + shots[name] = true + U.still(game, ("%s/%s_%s.png"):format(SHOT_DIR, v, name)) + end + local function waitingText() + return Message.isOpen() and Message.isWaiting() and (Message.currentPage() or "") or nil + end + local function step() + local s = AnimSeq._steps and AnimSeq._steps[AnimSeq._i] + return s and s.kind + end + + local seen = {} + local menus, moveMenus = 0, 0 + local lastPhase + for f = 1, 80000 do + stepBot() + U.wait(1) + if doneCount > 0 and run.phase == "done" and not Stack.has("g3u_battle") then break end + local phase = run and run.phase + local text = waitingText() + if text and text:find("Union rules", 1, true) then + seen.banner = true + shot("01_union_rules") + end + if step() == "hitfx" and not shots["04_hit"] then + seen.hit = true + U.wait(3) + shot("04_hit") + end + if step() == "faint" then seen.faint = true end + if seen.faint and text and text:find("fainted", 1, true) then shot("05_faint") end + if phase == "ending" and text and (text:find("against", 1, true) or text:find("defeated", 1, true) + or text:find("draw", 1, true)) then + seen.endText = text + shot("07_end") + end + if phase == "menu" then + if lastPhase ~= "menu" then menus = menus + 1 end + if menus == 1 then shot("02_action_menu") end + if Ui._menuIndex ~= 1 then U.tap(game, "up") U.tap(game, "left") else U.tap(game, "a") end + U.wait(2) + elseif phase == "moves" then + if lastPhase ~= "moves" then moveMenus = moveMenus + 1 end + if moveMenus == 1 then shot("03_move_menu") end + if Ui._moveIndex ~= 1 then U.tap(game, "up") U.tap(game, "left") else U.tap(game, "a") end + U.wait(2) + elseif phase == "party" and PartyMenu.isOpen() then + seen.replace = seen.replace or run.partyForced + local party = PartyMenu._party or {} + if PartyMenu.mode == "battle_faint" or PartyMenu.mode == "battle_switch" then + local m = party[PartyMenu.cursor] + if not m or (tonumber(m.hp) or 0) <= 0 then + U.tap(game, "down") + else + shot("06_replacement") + U.tap(game, "a") + end + else + U.tap(game, "a") + end + U.wait(6) + end + lastPhase = phase + end + U.wait(60) + shot("08_back_on_field") + + ok(doneCount == 1, "onDone called once (" .. doneCount .. ")") + ok(doneResult ~= nil, "result " .. tostring(doneResult and doneResult.outcome) .. "/" .. + tostring(doneResult and doneResult.why)) + ok(doneResult and doneResult.why == "faint", "the battle ended by faint") + ok(seen.banner, "union rules line shown") + ok(menus >= 2, "several turns played (" .. menus .. " action menus)") + ok(moveMenus >= 1, "move menu opened") + ok(seen.hit, "a hit animation played") + ok(seen.faint, "a faint played") + ok(seen.replace, "replacement party menu opened") + ok(seen.endText ~= nil, "end line shown: " .. tostring(seen.endText)) + ok(not Stack.has("g3u_battle"), "battle layer popped") + ok(Map.current == mapBefore and Player.cellX == xBefore and Player.cellY == yBefore and Player.facing == faceBefore, + ("same cell %s %s,%s -> %s %s,%s"):format(tostring(mapBefore), tostring(xBefore), tostring(yBefore), + tostring(Map.current), tostring(Player.cellX), tostring(Player.cellY))) + ok(ser(session.party) == partyBefore, "session party untouched") + ok(love.filesystem.read(saveName) == saveBefore, "save file bytes unchanged") + local liveAfter = live() + local same = true + for i = 1, #liveBefore do if liveBefore[i] ~= liveAfter[i] then same = false end end + ok(same, "live Gen 3 data intact after the battle") + ok(#Scope.trips == 0, "no scope guard trips (" .. #Scope.trips .. ")") + local Battle = require("src.core.game3.battle") + ok(Battle._st == nil and not Battle.isActive(), "battle singleton left clean") + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/g3u_relay_xgen.lua b/tests/drivers/g3u_relay_xgen.lua new file mode 100644 index 00000000..20eccfc9 --- /dev/null +++ b/tests/drivers/g3u_relay_xgen.lua @@ -0,0 +1,206 @@ +local U = require("tests.drivers.util") +local GameVersion = require("src.core.GameVersion") + +local ROLE = os.getenv("G3U_ROLE") or "low" +local NAME = os.getenv("G3U_NAME") or (ROLE == "low" and "LOWBOY" or "HIGHGAL") +local PEER = os.getenv("G3U_PEER") or (ROLE == "low" and "HIGHGAL" or "LOWBOY") +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3u-relay" +local LIMIT = tonumber(os.getenv("G3U_SECONDS") or "") or 240 + +local SPECS = { + { n = 25, moves = { 85, 87 } }, + { n = 65, moves = { 94, 85 } }, +} + +local function now() return love.timer.getTime() end + +return function(game) + local version = GameVersion.get() + local gen = GameVersion.generation(version) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. "[" .. ROLE .. "/" .. version .. "] " .. line) + return cond + end + local Client = require("src.online.Client") + local function finish() + pcall(Client.leaveRoom) + pcall(Client.disconnect) + print((fails == 0 and "PASS" or "FAIL") .. " g3u_relay_xgen role=" .. ROLE .. " fails=" .. fails) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + local function waitFor(cond, seconds, onFrame) + local t0 = now() + while not cond() do + if onFrame then onFrame() end + U.wait(1) + if now() - t0 > seconds then return false end + end + return true + end + local function shot(name) + U.still(game, ("%s/%s_%s_%s.png"):format(SHOT_DIR, ROLE, version, name)) + end + + local playerName, trainerId = NAME, 4321 + if gen == 3 then + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = NAME }) + U.wait(240) + local session = require("src.core.game3.runtime").getSession() + session.name = NAME + else + if not ok(U.newGame(game), "reached the overworld") then return finish() end + game.save.player.name = NAME + end + + local Datasets = require("src.online.xgen.Datasets") + local Project = require("src.online.xgen.Project") + local Policy = require("src.online.xgen.Policy") + local data = Datasets.get(version) + if not ok(data ~= nil, "own dataset loaded") then return finish() end + local records = {} + for _, spec in ipairs(SPECS) do + local base = data.species[spec.n].base + local iv = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 } + local ev = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } + local st = Project.stats3(base, 50, iv, ev, 0, spec.n) + local moves = {} + for i, id in ipairs(spec.moves) do moves[i] = { id = id, pp = Policy.maxPp(data.moves[id].pp, 0), ppUps = 0 } end + records[#records + 1] = { species = spec.n, level = 50, hp = st.hp, maxHp = st.hp, atk = st.atk, def = st.def, + spAtk = st.spa, spDef = st.spd, speed = st.spe, moves = moves, ivs = iv, gender = 2, friendship = 70 } + end + + Client.connect({ name = NAME }) + if not ok(waitFor(function() return Client.state() == "online" end, 20), "online on the local relay") then + print("[driver] state=" .. tostring(Client.state()) .. " err=" .. tostring(Client.error())) + return finish() + end + local Room = require("src.online.union.Room") + local room = Room.new({ client = Client }) + local joined, jerr = room:join({ version = version, game = game, name = playerName, trainerId = trainerId, + gender = 0, style = gen == 3 and "g3:0" or "player" }) + ok(joined, "plaza join sent " .. tostring(jerr and jerr.error)) + local function pump() Client.update(0) room:poll() end + if not ok(waitFor(function() pump() return room.state == "joined" end, 20), "joined the union plaza") then + print("[driver] room err=" .. tostring(room.err and room.err.error) .. " " .. tostring(room.err and room.err.detail)) + return finish() + end + + local peer + ok(waitFor(function() + pump() + for _, p in ipairs(room:members()) do + if p.name == PEER then peer = p end + end + return peer ~= nil + end, 60), "peer " .. PEER .. " is in the room") + if not peer then return finish() end + + Client.on("invite_closed", function(m) + print("[driver] invite_closed why=" .. tostring(m and m.why) .. " detail=" .. tostring(m and m.detail)) + end) + if ROLE == "low" then + local tries, last = 0, -10 + ok(waitFor(function() + pump() + if room:xgRoom() then return true end + local busy = false + for _, o in ipairs(Client.outgoing() or {}) do + if o.state == "pending" or o.state == "sent" then busy = true end + end + if not busy and now() - last > 3 and tries < 8 then + tries, last = tries + 1, now() + local h, w = room:invite(peer, "xg_battle") + print("[driver] invite try " .. tries .. " -> " .. tostring(h ~= nil) .. " " .. tostring(w)) + end + return false + end, 60), "peer accepted the invite") + else + local inv + ok(waitFor(function() + pump() + for _, i in ipairs(room:incoming()) do if i.mode == "battle" then inv = i end end + return inv ~= nil + end, 60), "xg_battle invite arrived") + if not inv then return finish() end + room:reply(inv.id, true) + end + if not ok(waitFor(function() pump() return room:xgRoom() ~= nil end, 30), "entered the xg prep room") then + return finish() + end + local prep = room:prep() + prep:sendCaps(room.caps) + local digest = (ROLE == "low") and "0123456789abcdef" or "fedcba9876543210" + local sentRoster, sentReady = false, false + local go + ok(waitFor(function() + pump() + for _, e in ipairs(prep:poll()) do + if e.kind == "go" then go = e.go end + if e.kind == "invalidated" then sentReady = false end + if e.kind == "nack" then + print("[driver] nack " .. tostring(e.of) .. " " .. tostring(e.why)) + if e.of == "xg_roster" then sentRoster = false end + if e.of == "xg_ready" then sentReady = false end + end + end + if prep.state == "prep" and not sentRoster then + sentRoster = prep:roster(#records, digest) + end + if prep:canReady() and not sentReady and not prep.mine.ready then + sentReady = prep:ready(digest) + end + return go ~= nil or prep.state == "closed" or prep.state == "blocked" + end, 60), "prep reached xg_go") + print("[driver] prep state=" .. tostring(prep.state) .. " blocked=" .. tostring(prep.blocked) + .. " ruleset=" .. tostring(go and go.ruleset)) + if not go then return finish() end + ok(go.ruleset == "g3u", "cross-gen battle resolves to g3u") + + local xg = Client.room() and Client.room().xg or {} + local gl = xg.gens or {} + local gens = { [0] = tonumber(gl[1]), [1] = tonumber(gl[2]) } + local seat = Client.seat() + local names = { [seat] = NAME, [1 - seat] = PEER } + local result + local Launch = require("src.ui.g3u.Launch") + local handle, why = Launch.start(game, gen, { + ruleset = "g3u", net = Client.roomSession(), seat = seat, go = go, gens = gens, names = names, + records = records, client = Client, roomId = Client.room() and Client.room().room, + onDone = function(r) result = r end, + }) + if not ok(handle ~= nil, "Launch.start " .. tostring(why)) then return finish() end + local bs = handle.bs + local shots = {} + local t0 = now() + local k = 0 + while not result and now() - t0 < LIMIT do + k = k + 1 + Client.update(0) + if bs.match and not shots.start then shots.start = true U.wait(30) shot("01_start") end + if bs.match and bs.match.turn >= 2 and not shots.mid then shots.mid = true shot("02_turn2") end + if bs.phase == "replace" and k % 18 == 6 then + U.tap(game, gen == 3 and "right" or "down") + elseif k % 6 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + if bs.phase == "replace" and not shots.replace then shots.replace = true U.wait(20) shot("02b_replace") end + end + ok(result ~= nil, "battle ended inside " .. LIMIT .. "s") + local turn = bs.match and bs.match.turn or -1 + print(("[driver] RESULT role=%s seat=%d outcome=%s why=%s turns=%d hash=%s"):format(ROLE, seat, + tostring(result and result.outcome), tostring(result and result.why), turn, + tostring(bs.myHashes[turn]))) + ok(result and (result.why == "faint" or result.why == "forfeit"), "battle decided by the game, not the link") + U.wait(60) + shot("03_after") + return finish() +end diff --git a/tests/drivers/rs_link_records_machine.lua b/tests/drivers/rs_link_records_machine.lua new file mode 100644 index 00000000..ae1b7e7e --- /dev/null +++ b/tests/drivers/rs_link_records_machine.lua @@ -0,0 +1,26 @@ +local U = require("tests.drivers.util") +local G = require("tests.drivers.union_gen3_util") + +return function(game) + local version = G.version() + local d = G.start("rs_link_records_machine_" .. version) + local session = G.boot(d, game, 0) + if not session then return d.finish() end + local Map = require("src.core.game3.map") + local twoF = G.prefix() .. "OLDALE_TOWN_POKEMON_CENTER_2F" + G.loadMap(game, twoF, 7, 4, "up") + U.wait(30) + d.check(Map.current == twoF, "standing on the Oldale 2F") + local Records = require("src.ui.game3.rs.link_records") + -- pokeruby/data/scripts/cable_club.inc:597 + require("src.core.game3.scripting.natives_link_rs").BY_NAME.ShowLinkBattleRecords() + local Space = require("src.core.game3.scripting.space") + Records.owner = Space.vm + local ok = pcall(function() U.wait(20) end) + d.check(ok and Records.isVisible(), "the link battle records window draws over the field") + d.still(game, "rs_link_records_window.png") + Records.eraseBox(0, 0, 29, 19) + U.wait(10) + d.check(not Records.isVisible(), "erasebox closes the records window") + return d.finish() +end diff --git a/tests/drivers/rs_small_font.lua b/tests/drivers/rs_small_font.lua new file mode 100644 index 00000000..08dae425 --- /dev/null +++ b/tests/drivers/rs_small_font.lua @@ -0,0 +1,59 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local W, H, SCALE = 120, 64, 4 + +local function render(lines) + local FrlgFont = require("src.ui.game3.frlg_font") + local canvas = love.graphics.newCanvas(W, H) + canvas:setFilter("nearest", "nearest") + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.clear(1, 1, 1, 1) + for _, l in ipairs(lines) do + l.opts.colors = FrlgFont.COLOR.NORMAL + FrlgFont.draw(l.text, l.x, l.y, l.opts) + end + love.graphics.setCanvas() + love.graphics.pop() + return canvas:newImageData() +end + +local function ink(img, x0, y0, x1, y1) + local n = 0 + for y = y0, y1 do + for x = x0, x1 do + local r, g, b = img:getPixel(x, y) + if r + g + b < 2.5 then n = n + 1 end + end + end + return n +end + +local function save(img, path) + local big = love.image.newImageData(W * SCALE, H * SCALE) + big:mapPixel(function(x, y) return img:getPixel(math.floor(x / SCALE), math.floor(y / SCALE)) end) + os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null') + local f = io.open(path, "wb") + f:write(big:encode("png"):getString()) + f:close() +end + +return function(game) + local d = X.new("rs_small_font", "/tmp/rs_small_font") + local session = X.newGame(d, game, 0) + if not session then return d.finish() end + X.settle(game) + U.wait(10) + local img = render({ + { text = "aaaa", x = 2, y = 0, opts = { small = true } }, + { text = "RED KRIS MAY", x = 2, y = 16, opts = { small = true } }, + { text = "RED KRIS MAY", x = 2, y = 32, opts = { font = "narrow" } }, + { text = "RED KRIS MAY", x = 2, y = 48, opts = {} }, + }) + save(img, d.dir .. "/rs_small_font_" .. (os.getenv("POKEPORT_VERSION") or "ruby") .. ".png") + d.check(ink(img, 2, 0, 40, 6) == 0, "small lowercase draws nothing in its cell's top rows (" .. ink(img, 2, 0, 40, 6) .. " px)") + d.check(ink(img, 2, 8, 40, 15) > 0, "small lowercase still draws") + return d.finish() +end diff --git a/tests/drivers/union_e2e_relay.lua b/tests/drivers/union_e2e_relay.lua new file mode 100644 index 00000000..6caa45c2 --- /dev/null +++ b/tests/drivers/union_e2e_relay.lua @@ -0,0 +1,754 @@ +local U = require("tests.drivers.util") +local GameVersion = require("src.core.GameVersion") + +local ROLE = os.getenv("UE_ROLE") or "host" +local MODE = os.getenv("UE_MODE") or "battle" +local CASE = os.getenv("UE_CASE") or "normal" +local NAME = os.getenv("UE_NAME") or (ROLE == "host" and "HOST" or "GUEST") +local PEER = os.getenv("UE_PEER") or (ROLE == "host" and "GUEST" or "HOST") +local STATE = os.getenv("UE_STATE") or "/tmp/ue" +local LIMIT = tonumber(os.getenv("UE_SECONDS") or "") or 300 +local PARTY = os.getenv("UE_PARTY") or "" +local WANT = tonumber(os.getenv("UE_WANT") or "") +local TAG = os.getenv("UE_TAG") or "run" + +local function now() return love.timer.getTime() end + +return function(game) + io.stdout:setvbuf("line") + local version = GameVersion.get() + local gen = GameVersion.generation(version) + local SHOTS = os.getenv("POKEPORT_SHOT_DIR") or STATE + local fails = 0 + local t0 = now() + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. "[" .. ROLE .. "/" .. version .. "] " .. line) + return cond + end + local function note(line) print("[ue] [" .. ROLE .. "/" .. version .. "] " .. line) end + local function mark(name) + print("[ue] MARK " .. ROLE .. " " .. name) + local f = io.open(STATE .. "/" .. ROLE .. ".marks", "a") + if f then f:write(name .. "\n") f:close() end + end + local function exists(path) + local f = io.open(path, "rb") + if f then f:close() return true end + return false + end + local function shot(name) + U.still(game, ("%s/%s_%s_%s_%s.png"):format(SHOTS, TAG, ROLE, version, name)) + end + local Client = require("src.online.Client") + local function finish() + print((fails == 0 and "PASS" or "FAIL") .. " union_e2e role=" .. ROLE .. " case=" .. CASE .. " mode=" .. MODE + .. " fails=" .. fails) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + local function waitFor(cond, seconds, onFrame) + local stop = now() + seconds + while now() < stop do + if cond() then return true end + if onFrame then onFrame() else U.wait(1) end + end + return cond() + end + + do + local okF, ffi = pcall(require, "ffi") + if okF then + pcall(ffi.cdef, "int getpid(void);") + local f = io.open(STATE .. "/" .. ROLE .. ".pid", "w") + if f then f:write(tostring(ffi.C.getpid())) f:close() end + end + end + + local Datasets = require("src.online.xgen.Datasets") + local Project = require("src.online.xgen.Project") + local Txn = require("src.online.union.TradeTxn") + + local G = {} + + if gen == 1 then + local Presence = require("src.world.gen1.UnionRoomPresence") + local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + local Pokemon = require("src.pokemon.Pokemon") + local SaveData = require("src.core.SaveData") + function G.boot() + U.wait(10) + if CASE == "resume" then + local loaded, recovered = SaveData.load() + if not loaded then return false end + G.preDisk = SaveData.load().party + game:restoreSave(loaded, recovered, { freshBoot = true, continued = true }) + U.wait(30) + return true + end + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.player.name = NAME + local list = {} + for sp, lv in PARTY:gmatch("(%u[%u_]*):(%d+)") do list[#list + 1] = Pokemon.new(game.data, sp, tonumber(lv)) end + game.save.party = list + local stamp = require("src.battle.BattleState").stampOT + for _, mon in ipairs(list) do stamp(game.save, mon) end + local cx, cy = UnionRoomMap.cellFor(ROLE == "host" and 20 or 21) + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + return true + end + function G.save() return game:writeSave() ~= false end + function G.presence() return Presence.active() end + function G.joined() local s = G.presence() return s and s.state == "joined" end + function G.peer() + local s = G.presence() + for _, m in ipairs(s and s:entities() or {}) do + if m.p.name == PEER then return m end + end + return nil + end + function G.player() return game.overworld and game.overworld.player end + function G.peerCell(m) return m.cellX, m.cellY end + function G.activity() local s = G.presence() return s and s.activity end + function G.idle() + local s = G.presence() + return s and s.state == "joined" and not s.busy and s.activity == nil and game.stack:top() == game.overworld + end + function G.party() return game.save.party end + function G.diskParty() + local loaded = SaveData.load() + return loaded and loaded.party + end + elseif gen == 2 then + local Presence = require("src.world.gen2.UnionRoomPresence") + local RoomMap = require("src.world.gen2.UnionRoomMap") + local Mon = require("src.battle.gen2.Mon") + local Save2 = require("src.core.gen2.Save") + function G.boot() + U.wait(60) + if CASE == "resume" then + local loaded = Save2.load(version) + if not loaded then return false end + G.preDisk = Save2.load(version).party + game:continueGame(loaded) + U.wait(60) + return true + end + local save = game.save + save.player.name = NAME + local list = {} + for sp, lv in PARTY:gmatch("(%u[%u_]*):(%d+)") do + list[#list + 1] = Mon.stampOT(save, Mon.new(game.data, sp, tonumber(lv))) + end + save.party = list + game.world:warpToMapId(RoomMap.ID, 12, 22, "up") + return true + end + function G.save() return game:writeSave() ~= false end + function G.presence() return Presence.active() end + function G.joined() local s = G.presence() return s and s.state == "joined" end + function G.peer() + local s = G.presence() + for _, e in ipairs(s and s:entities() or {}) do + if e.participant.name == PEER then return e end + end + return nil + end + function G.player() return game.world and game.world.player end + function G.peerCell(e) return e.cellX, e.cellY end + function G.activity() local s = G.presence() return s and s.activity end + function G.idle() + local s = G.presence() + return s and s.state == "joined" and s.activity == nil and s.ui == nil and game.stack:top() == nil + end + function G.party() return game.save.party end + function G.diskParty() + local loaded = Save2.load(version) + return loaded and loaded.party + end + else + local GU = require("tests.drivers.union_gen3_util") + local Map = require("src.core.game3.map") + local Union = require("src.core.game3.link.union_room") + local Plaza = require("src.core.game3.link.union_plaza_map") + local Family = require("src.core.game3.link.family") + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local Warp = require("src.core.game3.warp") + 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 Link = require("src.core.game3.link") + local CENTER = { + firered = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", leafgreen = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", + emerald = "EM_OLDALE_TOWN_POKEMON_CENTER_2F", ruby = "RU_OLDALE_TOWN_POKEMON_CENTER_2F", + sapphire = "SA_OLDALE_TOWN_POKEMON_CENTER_2F", + } + local function session() return require("src.core.game3.runtime").getSession() end + G.Union = Union + function G.boot() + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if CASE == "resume" then + game:_handleBootAction({ action = "continue" }) + waitFor(function() return session() ~= nil and game.phase ~= "quest_log" end, 60, function() + if game.phase == "quest_log" then U.tap(game, "b") end + U.wait(10) + end) + U.wait(60) + require("src.core.game3.link.trade").resumePending() + Txn.resumePending(nil) + U.wait(30) + return session() ~= nil + end + game:_handleBootAction({ action = "new_game", name = NAME }) + U.wait(240) + local s = session() + if not s then return false end + s.name = NAME + local Party = require("src.core.game3.party") + local C = require("src.core.game3.constants").of(version) + s.party = {} + for sp, lv in PARTY:gmatch("(%u[%u_]*):(%d+)") do + Party.giveMon(s, C.species.byName["SPECIES_" .. sp], tonumber(lv), "") + end + local flags = require("src.ui.game3.screens").flags(s).IDS + if flags.SYS_POKEDEX_GET then Flags.setFlag(Space.store, nil, flags.SYS_POKEDEX_GET, true) end + if flags.SYS_POKEMON_GET then Flags.setFlag(Space.store, nil, flags.SYS_POKEMON_GET, true) end + Link.connect() + if not waitFor(function() return Client.state() == "online" end, 20) then return false end + if version == "emerald" then + -- pokeemerald/data/scripts/cable_club.inc:103 + Flags.setVar(Space.store, nil, Family.var("emerald", "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2) + end + local rs = Family.isRubySapphire(version) + GU.loadMap(game, CENTER[version], rs and 1 or 6, rs and 3 or 4, "up") + U.wait(30) + U.tap(game, "a") + return waitFor(function() + return Map.current == Plaza.MAP_ID and Union.state == "main" and not Warp.isBusy() + end, 60, function() + if Choice.active or SaveMenu.isOpen() or Message.isOpen() then U.tap(game, "a") end + U.wait(6) + end) + end + function G.save() + local ok, wrote = pcall(game.saveGame, game) + return ok and wrote ~= false + end + function G.joined() return Map.current == Plaza.MAP_ID and Union.state == "main" and Client.plaza() ~= nil end + function G.peer() + for slot = 1, Plaza.CAP do + local p = Union.players[slot] + if p and not p.gone and p.name == PEER then return { slot = slot, p = p } end + end + return nil + end + function G.peerCell(m) return Plaza.cellFor(m.slot) end + function G.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 + function G.activity() return Union._xgSession end + function G.idle() + return Union.state == "main" and Union._xgSession == nil and not Message.isOpen() and not Choice.active + end + function G.busyUi() return Message.isOpen() or Choice.active end + function G.party() + local s = session() + local PartyView = require("src.core.game3.battle.party_view") + return PartyView.fromSession(s.party, s.move_overlay) + end + end + + local data = Datasets.get(version) + local function nationals(list) + local out = {} + for i, rec in ipairs(list or {}) do + local v = data and Project.read(rec, data) + out[i] = v and v.national or -1 + end + return out + end + local function natList(list) return table.concat(nationals(list), ",") end + + if not ok(G.boot(), "booted into the game") then return finish() end + if CASE == "resume" then + if G.preDisk then note("disk party before resume " .. natList(G.preDisk)) end + local before = natList(G.party()) + note("resumed party " .. before) + ok(waitFor(function() return #Txn.pending(version) == 0 end, 30), "journal settled after resume") + local after = nationals(G.party()) + note("party after resume " .. table.concat(after, ",")) + if WANT then ok(after[1] == WANT, "slot 1 holds the received mon " .. WANT .. " (" .. tostring(after[1]) .. ")") end + local again = Txn.resumePending(game) + ok(again == nil, "a second resume finds nothing to apply") + ok(natList(G.party()) == table.concat(after, ","), "the party is unchanged by the second resume") + if G.diskParty then + local disk = nationals(G.diskParty()) + note("disk party " .. table.concat(disk, ",")) + if WANT then ok(disk[1] == WANT, "the saved slot 1 holds the received mon") end + end + return finish() + end + + ok(G.save(), "saved before entering the activity") + + local Flow = require("src.ui.union.Flow") + local Launch = require("src.ui.g3u.Launch") + local launchResult + Flow.seams.launch = { + start = function(g, gn, session, a) + session.onDone = function(r) launchResult = r end + return Launch.start(g, gn, session, a) + end, + } + local Open = require("src.ui.union.prep.Open") + local OpenTrade = require("src.ui.union.prep.OpenTrade") + local E = {} + local origBattle = Open.battle + Open.battle = function(...) + local screen, model = origBattle(...) + E.model = model + return screen, model + end + local origCtl = OpenTrade.controller + OpenTrade.controller = function(...) + local ctl = origCtl(...) + E.ctl = ctl + return ctl + end + + if gen ~= 3 then + if not ok(waitFor(G.joined, 30), "joined the union room on the live relay") then return finish() end + else + if not ok(waitFor(G.joined, 20), "in the Gen 3 union room plaza") then return finish() end + end + local peer + if not ok(waitFor(function() peer = G.peer() return peer ~= nil end, 90), "peer " .. PEER .. " shows in the room") then + return finish() + end + U.wait(30) + local px, py = G.peerCell(peer) + if gen == 3 then + G.place(px, py + 1, "up") + else + local pl = G.player() + pl.cellX, pl.cellY = px, py + 1 + pl.px, pl.py = pl.cellX * 16, pl.cellY * 16 + pl.targetX, pl.targetY = pl.cellX, pl.cellY + pl.facing = "up" + end + U.wait(20) + shot("01_room") + mark("in_room") + + local function top() return game.stack and game.stack:top() end + local MenuClass = gen == 2 and require("src.ui.gen2.ScriptMenu") or require("src.ui.Menu") + local ChoiceBox = require("src.ui.ChoiceBox") + local function gbMenuUp() + local t = top() + if gen == 2 then return t ~= nil and getmetatable(t) == MenuClass end + return t ~= nil and t ~= game.overworld and t.index ~= nil and getmetatable(t) ~= ChoiceBox + end + + local shots = {} + local index = MODE == "trade" and 2 or 1 + if ROLE == "host" then + if gen == 3 then + local Union = G.Union + U.tap(game, "a") + ok(waitFor(function() return Union.state == "handle_do_something_prompt_input" end, 20, function() + if Union.state ~= shots.ustate then + shots.ustate = Union.state + note("talk union state=" .. tostring(Union.state)) + end + shots.utap = (shots.utap or 0) + 1 + if shots.utap % 30 == 0 then U.tap(game, "a") else U.wait(1) end + end), "the talk menu opens") + U.wait(20) + shot("02_talk_menu") + for _ = 2, index do U.tap(game, "down") U.wait(6) end + U.tap(game, "a") + else + U.tap(game, "a") + local n = 0 + ok(waitFor(gbMenuUp, 20, function() + n = n + 1 + if n % 30 == 0 then + local t = top() + note("talk top=" .. tostring(t and (t.screenId or t.kind or t.phase)) .. " items=" .. tostring(t and t.items ~= nil) + .. " index=" .. tostring(t and t.index) .. " mt=" .. tostring(t and getmetatable(t) == MenuClass)) + U.tap(game, "a") + else U.wait(1) end + end), "the talk menu opens") + U.wait(10) + shot("02_talk_menu") + for _ = 2, index do U.tap(game, "down") U.wait(4) end + U.tap(game, "a") + end + else + if gen == 3 then + local Union = G.Union + ok(waitFor(function() return Union._xgSession ~= nil end, 90, function() + if G.busyUi() then U.tap(game, "a") end + U.wait(10) + end), "accepted the request") + else + ok(waitFor(function() local t = top() return t and getmetatable(t) == ChoiceBox end, 90, function() + local t = top() + if t and t ~= game.overworld and getmetatable(t) ~= ChoiceBox then + shots.ask = (shots.ask or 0) + 1 + if shots.ask % 30 == 0 then U.tap(game, "a") end + end + U.wait(1) + end), "the request asks yes or no") + U.wait(10) + shot("02_prompt") + U.tap(game, "a") + end + end + local act + if not ok(waitFor(function() act = G.activity() return act ~= nil end, 40), "the activity opened") then + return finish() + end + + local function pick(items, id, pred) + for i, it in ipairs(items) do + if it.id == id and not it.disabled and (pred == nil or pred(it)) then return i end + end + return nil + end + local function moveTo(holder, idx) + for _ = 1, 40 do + if holder.cursor == idx then break end + U.tap(game, "down") + U.wait(3) + end + U.tap(game, "a") + U.wait(6) + end + + if MODE == "battle" then + local isCancel = CASE == "cancel_host" or CASE == "cancel_guest" + local opened = waitFor(function() return E.model ~= nil or act.done or act.state == "done" end, 40) + if isCancel and (act.done or act.state == "done") and not E.model then + note("the peer cancelled before this side's prep screen opened (" .. tostring(act.why) .. ")") + elseif not ok(opened and E.model ~= nil, "battle prep screen opened") then + return finish() + end + local m = E.model + local seen = {} + local cancelled = false + local ok2 = waitFor(function() return m == nil or m.done or act.launched or act.done end, 120, function() + local step = m.step + if not seen[step] then + seen[step] = true + U.wait(10) + shot("03_prep_" .. step) + note("prep step " .. step) + end + if m.view then U.tap(game, "b") U.wait(4) return end + local pg = m:page() + local items = pg.items + local idx + if (CASE == "cancel_host" and ROLE == "host") or (CASE == "cancel_guest" and ROLE == "guest") then + if step == "rules" and not cancelled and m.prep and m.prep.rules then + cancelled = true + idx = pick(items, "cancel") + elseif step == "closed" then + idx = pick(items, "ok") + end + elseif step == "rules" or step == "problems" then + if step == "rules" and not (m.prep and m.prep.rules) then U.wait(4) return end + idx = pick(items, "continue") + elseif step == "substitute" then + idx = pick(items, "swap_owned") or pick(items, "swap_rental") + elseif step == "moves" then + idx = pick(items, "move") or pick(items, "empty") + elseif step == "size" then + idx = pick(items, "continue") + if not idx and m:needSitOut() > m:sitOutCount() then + for i = #items, 1, -1 do + if items[i].id == "toggle" and not items[i].chosen then idx = i break end + end + end + elseif step == "confirm" then + idx = pick(items, "ready") + elseif step == "closed" then + idx = pick(items, "ok") + end + if idx then moveTo(m, idx) else U.wait(4) end + end) + ok(ok2, "prep finished (step " .. tostring(m and m.step) .. ")") + if isCancel then + ok(not act.launched, "no battle after a cancel") + waitFor(function() return act.done and G.idle() end, 30, function() + if (gen == 3 and G.busyUi()) or (gen ~= 3 and top() and top() ~= game.overworld) then U.tap(game, "a") end + U.wait(8) + end) + U.wait(30) + shot("07_back_in_room") + ok(G.idle(), "back in the room after the cancel (" .. tostring(act.why) .. ")") + ok(Client.room() == nil, "the xg room was left") + return finish() + end + if not ok(waitFor(function() return act.launched or act.done end, 30), "the battle launched (" .. tostring(act.why) .. ")") then + return finish() + end + local bp = act.battlePrep or {} + note(("battle ruleset=%s size=%s team=%s records=%d"):format(tostring(bp.ruleset and bp.ruleset.id), + tostring(bp.size), table.concat(bp.team or {}, ","), #(bp.records or {}))) + for i, rec in ipairs(bp.records or {}) do + local mv = {} + for j, m in ipairs(rec.moves or {}) do + if type(m) == "table" then mv[#mv + 1] = tostring(m.id) .. "/" .. tostring(m.pp) + else mv[#mv + 1] = tostring(m) .. "/" .. tostring(rec.pp and rec.pp[j]) end + end + note(("record %d species=%s level=%s moves=%s"):format(i, tostring(rec.species), tostring(rec.level), + table.concat(mv, " "))) + end + local h = act.battle or {} + local bs = h.bs + local marked, k = false, 0 + local stop = now() + LIMIT + local LB = gen == 3 and require("src.core.game3.link.battle") or nil + local nextLog = now() + 10 + while not act.done and now() < stop do + k = k + 1 + if now() > nextLog then + nextLog = now() + 10 + note(("battle phase=%s turn=%s state=%s top=%s"):format(tostring(bs and bs.phase), + tostring(bs and bs.match and bs.match.turn), tostring(act.state), + tostring(gen ~= 3 and top() and (top().screenId or top().phase) or ""))) + if gen == 3 and not bs then + local L3 = require("src.core.game3.link") + local lk = L3.link + note(("native3 stage=%s LB=%s why=%s link=%s ready=%s"):format(tostring(h.stage), tostring(LB.state), + tostring(h.why), tostring(lk and lk:isOpen()), tostring(lk and lk:isReady()))) + end + shots.slow = (shots.slow or 0) + 1 + if shots.slow <= 3 then shot("08_battle_t" .. shots.slow) end + end + if not shots.start and k == 90 then shots.start = true shot("05_battle_start") end + if not shots.mid and ((bs and bs.match and bs.match.turn >= 2) or k == 600) then + shots.mid = true + shot("06_battle_mid") + end + if CASE == "kill_battle" and ROLE == "guest" and not marked and ((bs and bs.match and bs.match.turn >= 2) or k > 20000) then + marked = true + mark("in_battle") + end + if h.state and h.state.packed and not shots.native then + shots.native = true + note("native party sent " .. #h.state.packed .. " mons") + end + if gen == 3 and LB and LB._myPacked and not shots.lb then + shots.lb = true + note("gen3 native party sent " .. #LB._myPacked .. " mons") + end + local turn = bs and bs.match and bs.match.turn + if turn ~= shots.turn or (bs and bs.phase ~= "choose") then + shots.turn, shots.turnAt = turn, now() + end + if gen == 3 and bs and not shots.menu and require("src.core.game3.battle.ui")._mode == "menu" then + U.tap(game, "a") + U.wait(20) + if require("src.core.game3.battle.ui")._mode == "moves" then + shots.menu = true + U.tap(game, "down") + U.wait(10) + shot("05b_move_menu") + end + end + if bs and bs.phase == "choose" and now() - (shots.turnAt or now()) > 3 then + shots.unstick = (shots.unstick or 0) + 1 + local seqs = gen == 3 and { { "right" }, { "down" }, { "right", "down" } } + or { { "down" }, { "down", "down" }, { "down", "down", "down" } } + local seq = seqs[(shots.unstick - 1) % 3 + 1] + for _ = 1, 3 do U.tap(game, "b") U.wait(8) end + U.tap(game, "a") + U.wait(12) + for _, b in ipairs(seq) do U.tap(game, b) U.wait(6) end + U.tap(game, "a") + U.wait(6) + shots.turnAt = now() + elseif bs and bs.phase == "replace" and k % 18 == 6 then + U.tap(game, gen == 3 and "right" or "down") + elseif not bs and gen == 3 then + local mode = require("src.core.game3.battle.ui")._mode + if mode ~= shots.nphase then shots.nphase, shots.nphaseAt = mode, now() end + local partyOpen = require("src.ui.game3.party_menu").isOpen() + if mode == "none" or partyOpen or not shots.g3progress then shots.g3progress = now() end + if (mode == "party" or partyOpen) and k % 24 == 6 then + U.tap(game, (math.floor(k / 24) % 2 == 0) and "right" or "down") + elseif mode == "bag" and k % 12 == 6 then + U.tap(game, "b") + elseif (mode == "moves" or mode == "selmsg" or mode == "menu") and now() - (shots.g3progress or now()) > 4 then + shots.unstick = (shots.unstick or 0) + 1 + local seqs = { { "right" }, { "down" }, { "right", "down" } } + for _ = 1, 3 do U.tap(game, "b") U.wait(8) end + U.tap(game, "a") + U.wait(12) + for _, b in ipairs(seqs[(shots.unstick - 1) % 3 + 1]) do U.tap(game, b) U.wait(6) end + U.tap(game, "a") + shots.g3progress = now() + elseif k % 6 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + elseif not bs and gen ~= 3 then + local t = top() + local phase = t and t.phase + if phase ~= shots.nphase then shots.nphase, shots.nphaseAt = phase, now() end + if (phase == "moveSelect" or phase == "moves") and now() - shots.nphaseAt > 3 then + U.tap(game, "down") + U.wait(4) + U.tap(game, "a") + shots.nphaseAt = now() + elseif type(phase) == "string" and phase:find("^refuse") and k % 12 == 6 then + U.tap(game, "b") + elseif (phase == nil or phase == "forced-switch") and t and k % 18 == 6 then + U.tap(game, "down") + elseif k % 6 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + elseif k % 6 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + end + ok(act.done or act.state == "done", "the battle ended inside the limit") + local r = bs and bs.result + if not r and launchResult ~= nil then + r = type(launchResult) == "table" and launchResult or { outcome = launchResult, why = "native" } + end + note(("RESULT outcome=%s why=%s why_act=%s"):format(tostring(r and r.outcome), tostring(r and r.why), tostring(act.why))) + if CASE == "kill_battle" and ROLE == "host" then + ok(r == nil or r.why == "disconnect", "the survivor ends as a disconnect") + end + else + if not ok(waitFor(function() return E.ctl ~= nil or act.done end, 40), "trade prep screen opened") then + return finish() + end + local c = E.ctl + local mine0 = nationals(G.party()) + note("party before " .. table.concat(mine0, ",")) + if CASE == "kill_confirm" and ROLE == "host" then + local orig = Txn.send + Txn.send = function(self, msg) + if type(msg) == "table" and msg.type == "trade_confirm" then + mark("confirm_held") + return true + end + return orig(self, msg) + end + end + if CASE == "crash_commit" and ROLE == "guest" then + Txn.Adapter.write = function() + mark("commit_logged") + while true do love.timer.sleep(0.2) end + end + end + local seen, sentMark = {}, false + local done = waitFor(function() return c.done or c.step == "done" or act.done end, LIMIT, function() + local step = c.step + if not seen[step] then + seen[step] = true + U.wait(10) + shot("04_trade_" .. step) + note("trade step " .. step) + end + if CASE == "kill_confirm" and ROLE == "guest" and not sentMark and c.txn and c.txn.state == "commit_wait" then + sentMark = true + mark("confirm_sent") + end + local pg = c:page() + local items = pg.items + local idx + if step == "pick" then + idx = pick(items, "mon") + elseif step == "offer" then + idx = pick(items, "offer") or pick(items, "moves") + elseif step == "moves" then + idx = pick(items, "remove") or pick(items, "move") + elseif step == "confirm" then + idx = pick(items, "trade") + elseif step == "closed" or (step == "trading" and c.txn and c.txn.state == "unresolved") then + if not seen["closed_hold"] then + seen["closed_hold"] = true + U.wait(20) + shot("04_trade_closed") + note("closed: " .. tostring(c.closedText) .. " txn=" .. tostring(c.txn and c.txn.state)) + end + if c.txn and c.txn.state == "unresolved" and #Txn.pending(version) > 0 then U.wait(10) return end + idx = pick(items, "leave") + end + if idx then moveTo(c, idx) else U.wait(4) end + end) + note("trade end step=" .. tostring(c.step) .. " txn=" .. tostring(c.txn and c.txn.state) .. " closed=" .. tostring(c.closedText)) + local after = nationals(G.party()) + note("party after " .. table.concat(after, ",")) + if CASE == "kill_confirm" then + ok(waitFor(function() return #Txn.pending(version) == 0 end, 60), "the open journal resolves") + local now2 = nationals(G.party()) + local same = table.concat(now2, ",") == table.concat(mine0, ",") + note("party after resolve " .. table.concat(now2, ",") .. (same and " (unchanged)" or " (changed)")) + if WANT and not same then ok(now2[1] == WANT, "a committed trade applied the received mon") end + ok(same or (WANT and now2[1] == WANT), "the party is consistent with the relay outcome") + else + ok(done and c.step == "done", "the trade completed (" .. tostring(c.step) .. ")") + if WANT then ok(after[1] == WANT, "slot 1 now holds the received mon " .. WANT .. " (" .. tostring(after[1]) .. ")") end + ok(after[1] ~= mine0[1], "the offered mon is gone from slot 1") + ok(#Txn.pending(version) == 0, "the journal is dropped") + if G.diskParty then + local disk = nationals(G.diskParty()) + note("disk party " .. table.concat(disk, ",")) + if WANT then ok(disk[1] == WANT, "the save on disk holds the received mon") end + end + shot("06_trade_done") + local stopIdx = pick(c:page().items, "stop") + if stopIdx then moveTo(c, stopIdx) end + end + end + + waitFor(function() return (act.done or act.state == "done") and G.idle() end, 40, function() + if gen == 3 then + if G.busyUi() then U.tap(game, "a") end + elseif top() and top() ~= game.overworld then + U.tap(game, "a") + end + U.wait(8) + end) + U.wait(60) + shot("07_back_in_room") + ok(G.idle(), "back in the room with the presence restored (" .. tostring(act.why) .. ")") + ok(Client.room() == nil, "the xg room was left") + local plaza = Client.plaza() + local me = Client.you() and Client.you().id + local status + for _, row in ipairs(plaza and plaza.members or {}) do + if row.id == me then status = row.status end + end + ok(waitFor(function() + for _, row in ipairs((Client.plaza() or {}).members or {}) do + if row.id == me then status = row.status end + end + return status == "idle" + end, 10), "my room status is idle (" .. tostring(status) .. ")") + if CASE ~= "kill_battle" and CASE ~= "kill_confirm" then + ok(waitFor(function() return G.peer() ~= nil end, 15), "the peer is still in the member list") + end + note("elapsed " .. math.floor(now() - t0) .. "s") + return finish() +end diff --git a/tests/drivers/union_gen1_flow.lua b/tests/drivers/union_gen1_flow.lua new file mode 100644 index 00000000..e455fd1b --- /dev/null +++ b/tests/drivers/union_gen1_flow.lua @@ -0,0 +1,308 @@ +local U = require("tests.drivers.util") +local TextBox = require("src.render.TextBox") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local UnionCenters = require("src.world.gen1.UnionCenters") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") +local Origin = require("src.online.union.Origin") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local v = GameVersion.get() + local function shot(name) + for _ = 1, 240 do + local top = game.stack:top() + if not (top and top.pages and not top.done) then break end + U.wait(1) + end + U.wait(20) + U.still(game, ("%s/%s_%s.png"):format(SHOT_DIR, v, name)) + end + local function ow() return game.overworld end + local function idle() + local o = ow() + return o and game.stack:top() == o and not o.transitioning + and not o.runner:isRunning() and #o.scriptMoves == 0 + and not (o.pendingScripts and o.pendingScripts[1]) + and not o.healAnim and not o.emote + end + local function waitFor(cond, frames) + for _ = 1, frames do + if cond() then return true end + U.wait(1) + end + return cond() + end + local function at() + local p = ow().player + return ow().map.id, p.cellX, p.cellY + end + local function boxText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local out = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = type(line) == "table" and table.concat(line) or tostring(line) end + end + return table.concat(out, " ") + end + local function mash(btn, cond, frames) + for _ = 1, frames do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return cond() + end + local function settle() + for _ = 1, 300 do + if idle() then return true end + local top = game.stack:top() + U.tap(game, (top and top.choice) and "b" or "a") + U.wait(3) + if not idle() then U.tap(game, "b") U.wait(3) end + end + U.wait(30) + return idle() + end + local function home(town) + game.save.lastOutdoor = { id = town, x = 10, y = 10 } + end + + U.wait(10) + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.party = { Pokemon.new(game.data, "PIDGEY", 5), Pokemon.new(game.data, "RATTATA", 5) } + + home("VIRIDIAN_CITY") + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + U.tap(game, "a") + ok(waitFor(function() return not idle() end, 120), "PC at 13,3 opens") + shot("flow_01_pc_open") + mash("b", idle, 400) + + local m1 = ow().map + ok(not m1:isWalkableCell(10, 1) and not m1:isWalkableCell(10, 2), "pillar and cap seal the nurse side") + ok(m1:isWalkableCell(11, 1) and m1:isWalkableCell(11, 2) and m1:isWalkableCell(12, 1), "stairs alcove is walkable") + ok(not m1:isWalkableCell(13, 3), "PC cell stays solid") + U.teleport(game, "VIRIDIAN_POKECENTER", 11, 1, "left") + U.hold(game, "left", 30) + waitFor(idle, 60) + local _, cx, cy = at() + ok(cx == 11 and cy == 1, ("walking left from 11,1 is blocked (%d,%d)"):format(cx, cy)) + + home("VIRIDIAN_CITY") + U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up") + game.save.party[1].hp = 1 + U.tap(game, "a") + ok(waitFor(function() return not idle() end, 120), "nurse responds on the patched 1F") + mash("a", idle, 600) + ok(game.save.party[1].hp == game.save.party[1].stats.hp, "nurse healed the party") + U.wait(30) + + local function talkAll(mapId, town) + home(town) + U.teleport(game, mapId, 4, 4, "down") + local list = {} + for _, n in ipairs(ow().npcs) do list[#list + 1] = n.def end + table.sort(list, function(p, q) + local pn, qn = p.sprite == "SPRITE_NURSE", q.sprite == "SPRITE_NURSE" + if pn ~= qn then return qn end + return p.index < q.index + end) + for _, d in ipairs(list) do + if d.sprite == "SPRITE_CHANSEY" then + print(("SKIP %s: %s is silent on the vanilla layout too"):format(mapId, d.name)) + goto continue + end + U.teleport(game, mapId, 4, 4, "down") + local npc = ow():npcByIndex(d.index) + local spot + if npc then + npc.frozen = true + local map = ow().map + for pass = 1, 2 do + for _, dir in ipairs({ { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } }) do + for dist = 1, 2 do + local x, y = npc.cellX + dir[1] * dist, npc.cellY + dir[2] * dist + if dist == 2 and not map:isCounterCell(npc.cellX + dir[1], npc.cellY + dir[2]) then break end + if map:inBounds(x, y) and map:isWalkableCell(x, y) + and (pass == 2 or not ow():npcAtCell(x, y)) then + spot = { x, y, dir[3] } + break + end + end + if spot then break end + end + if spot then break end + end + end + if spot then + U.teleport(game, mapId, spot[1], spot[2], spot[3]) + npc = ow():npcByIndex(d.index) + if npc then npc.frozen = true end + U.tap(game, "a") + local talked = waitFor(function() return not idle() end, 120) + ok(talked, ("%s: %s answers from %d,%d"):format(mapId, d.name, spot[1], spot[2])) + settle() + else + ok(false, ("%s: %s has no reachable talk cell"):format(mapId, d.name)) + end + ::continue:: + end + end + talkAll("VIRIDIAN_POKECENTER", "VIRIDIAN_CITY") + talkAll("MT_MOON_POKECENTER", "ROUTE_4") + talkAll("INDIGO_PLATEAU_LOBBY", "INDIGO_PLATEAU") + for _, id in ipairs({ "VIRIDIAN_POKECENTER", "MT_MOON_POKECENTER", "INDIGO_PLATEAU_LOBBY" }) do + local has = false + for _, o in ipairs(game.data.maps[id].objects) do + if o.sprite == "SPRITE_LINK_RECEPTIONIST" then has = true end + end + ok(not has, id .. ": cable club receptionist moved off 1F") + end + + home("ROUTE_4") + U.teleport(game, "MT_MOON_POKECENTER", 13, 2, "up") + U.hold(game, "up", 20) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900), "Mt Moon stairs reach 2F") + ok((Origin.get(game.save) or {}).map == "MT_MOON_POKECENTER", "origin is Mt Moon") + shot("flow_02_2f_arrival") + + game.save.flags.EVENT_GOT_POKEDEX = nil + U.teleport(game, UnionCenters.FLOOR_2F, 11, 3, "up") + U.tap(game, "a") + ok(waitFor(function() return (boxText() or ""):find("preparations", 1, true) end, 300), + "cable club without POKeDEX: " .. tostring(boxText())) + settle() + game.save.flags.EVENT_GOT_POKEDEX = true + U.teleport(game, UnionCenters.FLOOR_2F, 11, 3, "up") + U.tap(game, "a") + ok(waitFor(function() return (boxText() or ""):find("Cable Club", 1, true) end, 300), + "cable club receptionist greets on 2F: " .. tostring(boxText())) + shot("flow_03_cable_club") + local LinkState = require("src.link.LinkState") + local linked = mash("a", function() + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == LinkState then return true end + end + return false + end, 300) + ok(linked, "cable club still opens LinkState") + shot("flow_04_linkstate") + while game.stack:top() and game.stack:top() ~= ow() do game.stack:pop() end + game.linkSession = nil + ok(waitFor(idle, 60), "back on the 2F after the link menu") + + game.save.flags.EVENT_GOT_POKEDEX = nil + U.teleport(game, UnionCenters.FLOOR_2F, 7, 3, "up") + U.tap(game, "a") + ok(waitFor(function() return (boxText() or ""):find("preparations", 1, true) end, 300), + "union receptionist without POKeDEX: " .. tostring(boxText())) + settle() + game.save.flags.EVENT_GOT_POKEDEX = true + + U.teleport(game, UnionCenters.FLOOR_2F, 7, 3, "up") + U.tap(game, "a") + ok(waitFor(function() return (boxText() or ""):find("UNION", 1, true) end, 300), + "union receptionist greets: " .. tostring(boxText())) + local ChoiceBox = require("src.ui.ChoiceBox") + ok(mash("a", function() return getmetatable(game.stack:top()) == ChoiceBox end, 200), "YES/NO opens") + shot("flow_05_union_welcome") + U.tap(game, "b") + ok(waitFor(function() return (boxText() or ""):find("come again", 1, true) end, 300), + "declining says please come again: " .. tostring(boxText())) + settle() + U.teleport(game, UnionCenters.FLOOR_2F, 7, 3, "up") + U.tap(game, "a") + ok(waitFor(function() return (boxText() or ""):find("UNION", 1, true) end, 300), "greeting again") + ok(mash("a", function() return (boxText() or ""):find("SAVE", 1, true) end, 300), "asks to save: " .. tostring(boxText())) + ok(mash("a", function() return ow().map.id == UnionCenters.UNION_ROOM end, 900), "walked into the union room") + ok(waitFor(idle, 600), "union room idle after the warp") + local m, x, y = at() + local ex, ey = UnionRoomMap.entry() + ok(m == UnionCenters.UNION_ROOM and x == ex and y == ey, ("entered at the exit carpet (%d,%d)"):format(x, y)) + ok((Origin.get(game.save) or {}).map == "MT_MOON_POKECENTER", "origin kept inside the room") + shot("flow_06_union_room") + local slotsOk = true + for slot = 1, UnionRoomMap.CAP do + local sx, sy = UnionRoomMap.cellFor(slot) + if not (sx and ow().map:isWalkableCell(sx, sy)) then slotsOk = false end + end + ok(slotsOk, "all 40 participant cells are walkable in the room") + + local function nurseFront(raw, label) + local rp = raw and raw.player or {} + ok(rp.map == "MT_MOON_POKECENTER" and rp.x == 3 and rp.y == 3 and rp.facing == "up", + ("%s: written save stands in front of the Mt Moon nurse (%s %s,%s %s)"):format(label, + tostring(rp.map), tostring(rp.x), tostring(rp.y), tostring(rp.facing))) + ok(raw and Origin.get(raw) == nil, label .. ": written save carries no origin") + ok(raw and raw.lastOutdoor and raw.lastOutdoor.id == "ROUTE_4", label .. ": written lastOutdoor is the center's town") + end + U.hold(game, "up", 20) + waitFor(idle, 120) + local _, rx, ry = at() + ok(game:writeSave(), "save written inside the room") + m, x, y = at() + ok(m == UnionCenters.UNION_ROOM and x == rx and y == ry, "saving does not move the live player") + ok((Origin.get(game.save) or {}).map == "MT_MOON_POKECENTER", "live origin kept after saving") + nurseFront(SaveData.load(), "room save") + + U.teleport(game, UnionCenters.UNION_ROOM, ex, ey, "down") + U.hold(game, "down", 30) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F end, 600), "exit carpet leads to 2F") + ok(waitFor(function() return (boxText() or ""):find("come again", 1, true) end, 600), + "receptionist sees the player off: " .. tostring(boxText())) + shot("flow_07_seen_off") + mash("a", idle, 400) + m, x, y = at() + ok(m == UnionCenters.FLOOR_2F and x == 6 and y == 4, ("walked out to %s %d,%d"):format(m, x, y)) + local closed = UnionCenters.forData(game.data).gateClosed + ok(ow().map:blockAt(UnionCenters.GATE_2F.bx, UnionCenters.GATE_2F.by) == closed, "gate closed again") + ok(not ow().map:isWalkableCell(6, 2), "gate cell is solid after closing") + U.teleport(game, UnionCenters.FLOOR_2F, 6, 3, "up") + U.hold(game, "up", 30) + waitFor(idle, 60) + _, x, y = at() + ok(x == 6 and y == 3, ("walking up into the closed gate is blocked (%d,%d)"):format(x, y)) + + ok(game:writeSave(), "save written on 2F") + ok(ow().map.id == UnionCenters.FLOOR_2F, "live player still on 2F after saving") + local raw2 = SaveData.load() + nurseFront(raw2, "2F save") + game:restoreSave(raw2, nil, { freshBoot = true, continued = true }) + U.wait(10) + m, x, y = at() + ok(m == "MT_MOON_POKECENTER" and x == 3 and y == 3, ("reloaded in front of the nurse (%s %d,%d)"):format(m, x, y)) + shot("flow_08_reload_nurse_front") + + home("ROUTE_4") + U.teleport(game, "MT_MOON_POKECENTER", 13, 2, "up") + U.hold(game, "up", 20) + waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900) + ow():captureSave(game.save) + SaveData.save(game.save) + local legacy = SaveData.load() + ok(legacy.player.map == UnionCenters.FLOOR_2F, "raw 2F save on disk") + game:restoreSave(legacy, nil, { freshBoot = true, continued = true }) + U.wait(10) + m, x, y = at() + ok(m == "MT_MOON_POKECENTER" and x == 3 and y == 3, ("load-time net moves a raw 2F save to the nurse (%s %d,%d)"):format(m, x, y)) + ok(Origin.get(game.save) == nil, "load-time net clears the origin") + + U.teleport(game, "MT_MOON_POKECENTER", 13, 2, "up") + U.hold(game, "up", 20) + waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900) + ow():captureSave(game.save) + SaveData.save(game.save) + ok((Origin.get(SaveData.load()) or {}).map == "MT_MOON_POKECENTER", "raw save left on 2F with origin Mt Moon") + + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen1_layout.lua b/tests/drivers/union_gen1_layout.lua new file mode 100644 index 00000000..9afbd6aa --- /dev/null +++ b/tests/drivers/union_gen1_layout.lua @@ -0,0 +1,44 @@ +local U = require("tests.drivers.util") +local UnionCenters = require("src.world.gen1.UnionCenters") +local PaletteFX = require("src.render.PaletteFX") +local Zoom = require("src.render.Zoom") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local v = GameVersion.get() + U.wait(10) + local r = UnionCenters.forData(game.data) + ok(r ~= nil and #r.order == 12, "12 centers patched") + Zoom.offset = -3 + local modes = { "gbc", "redpp", "ogred" } + local spots = { + { "VIRIDIAN_POKECENTER", 11, 3, "1f_viridian", "VIRIDIAN_CITY" }, + { UnionCenters.FLOOR_2F, 11, 3, "2f", "VIRIDIAN_CITY" }, + { "INDIGO_PLATEAU_LOBBY", 13, 7, "1f_indigo", "INDIGO_PLATEAU" }, + { UnionCenters.FLOOR_2F, 11, 3, "2f_indigo", "INDIGO_PLATEAU" }, + { UnionCenters.UNION_ROOM, 12, 12, "room_top", "VIRIDIAN_CITY" }, + { UnionCenters.UNION_ROOM, 12, 20, "room_bottom", "VIRIDIAN_CITY" }, + } + for _, mode in ipairs(modes) do + PaletteFX.setMode(mode) + for _, s in ipairs(spots) do + game.save.lastOutdoor = { id = s[5], x = 10, y = 10 } + U.teleport(game, s[1], s[2], s[3], "up") + game.overworld.lastOutdoor = game.save.lastOutdoor + U.wait(4) + ok(game.overworld.map.id == s[1], ("%s %s loaded"):format(mode, s[1])) + U.still(game, ("%s/%s_%s_%s.png"):format(SHOT_DIR, v, s[4], mode)) + end + end + Zoom.offset = 0 + PaletteFX.setMode("gbc") + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen1_off.lua b/tests/drivers/union_gen1_off.lua new file mode 100644 index 00000000..75a01c73 --- /dev/null +++ b/tests/drivers/union_gen1_off.lua @@ -0,0 +1,68 @@ +local U = require("tests.drivers.util") +local SaveData = require("src.core.SaveData") +local TextBox = require("src.render.TextBox") +local UnionCenters = require("src.world.gen1.UnionCenters") +local Origin = require("src.online.union.Origin") +local Setting = require("src.online.union.Setting") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local v = GameVersion.get() + U.wait(10) + ok(not Setting.patchesOn(1), "options.lua has the Union Room setting off") + ok(game.data.maps[UnionCenters.FLOOR_2F] == nil and game.data.maps[UnionCenters.UNION_ROOM] == nil, + "no added maps with the setting off") + ok(UnionCenters.forData(game.data) == nil, "no union center registry with the setting off") + local raw = SaveData.load() + ok(raw and raw.player.map == UnionCenters.FLOOR_2F, "the save on disk stands on the 2F: " .. tostring(raw and raw.player.map)) + ok(raw and (Origin.get(raw) or {}).map == "MT_MOON_POKECENTER", "the save on disk carries the Mt Moon origin") + game:restoreSave(raw, nil, { freshBoot = true, continued = true }) + U.wait(20) + local o = game.overworld + local p = o.player + ok(o.map.id == "MT_MOON_POKECENTER", "relocated to the origin center: " .. o.map.id) + ok(p.cellX == 3 and p.cellY == 3, ("standing in front of the nurse (%d,%d)"):format(p.cellX, p.cellY)) + ok(Origin.get(game.save) == nil, "origin cleared after relocation") + local def = game.data.maps.MT_MOON_POKECENTER + ok(def.blocks[6] == 13 and def.blocks[7] == 13 and def.blocks[13] == 34 and def.blocks[14] == 35, + "1F keeps the vanilla desk blocks") + local rec + for _, obj in ipairs(def.objects) do + if obj.sprite == "SPRITE_LINK_RECEPTIONIST" then rec = obj end + end + ok(rec and rec.x == 11 and rec.y == 2, "receptionist back at the 1F desk") + U.still(game, ("%s/%s_off_01_relocated.png"):format(SHOT_DIR, v)) + game.save.flags.EVENT_GOT_POKEDEX = true + U.teleport(game, "MT_MOON_POKECENTER", 11, 3, "up") + U.wait(30) + U.tap(game, "a") + local greeted = false + for _ = 1, 300 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do + if tostring(type(line) == "table" and table.concat(line) or line):find("Cable Club", 1, true) then greeted = true end + end + end + end + if greeted then break end + U.wait(1) + end + ok(greeted, "vanilla 1F cable club receptionist answers") + for _ = 1, 240 do + local top = game.stack:top() + if not (top and top.pages and not top.done) then break end + U.wait(1) + end + U.still(game, ("%s/%s_off_02_desk_text.png"):format(SHOT_DIR, v)) + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen1_pikachu.lua b/tests/drivers/union_gen1_pikachu.lua new file mode 100644 index 00000000..daffffa2 --- /dev/null +++ b/tests/drivers/union_gen1_pikachu.lua @@ -0,0 +1,112 @@ +local U = require("tests.drivers.util") +local Pokemon = require("src.pokemon.Pokemon") +local GameVersion = require("src.core.GameVersion") +local PF = require("src.world.PikachuFollower") +local UnionCenters = require("src.world.gen1.UnionCenters") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local function ow() return game.overworld end + local function idle() + local o = ow() + return o and game.stack:top() == o and not o.transitioning + and not o.runner:isRunning() and #o.scriptMoves == 0 + and not (o.pendingScripts and o.pendingScripts[1]) + and not o.healAnim and not o.emote + end + local function waitFor(cond, frames) + for _ = 1, frames do + if cond() then return true end + U.wait(1) + end + return cond() + end + local function mash(cond, frames) + for _ = 1, frames do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return cond() + end + local function at() + local p = ow().player + return ow().map.id, p.cellX, p.cellY + end + local function follower() return PF.current(ow()) end + + ok(GameVersion.isYellow(), "running as Yellow") + U.wait(10) + local f = game.save.flags + f.EVENT_GOT_STARTER = true + f.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + f.EVENT_GOT_POKEDEX = true + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20), Pokemon.new(game.data, "PIDGEY", 5) } + game.save.lastOutdoor = { id = "PEWTER_CITY", x = 13, y = 26 } + + local function stepTo(dir, cx, cy) + for _ = 1, 6 do + local _, x, y = at() + if x == cx and y == cy then return true end + U.hold(game, dir, 12) + waitFor(idle, 60) + end + local _, x, y = at() + return x == cx and y == cy + end + U.teleport(game, "PEWTER_POKECENTER", 11, 5, "up") + ok(stepTo("up", 11, 4) and stepTo("up", 11, 3) and stepTo("up", 11, 2), "walked up the aisle beside the counter end") + ok(follower() ~= nil, "Pikachu follows on the Pewter 1F") + U.still(game, SHOT_DIR .. "/yellow_pika_01_1f_before_stairs.png") + ok(stepTo("up", 11, 1) and stepTo("right", 12, 1), ("walked into the stairs alcove (%s %d,%d)"):format(at())) + U.hold(game, "right", 20) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900), "stairs up with Pikachu") + U.wait(30) + ok(follower() ~= nil, "Pikachu followed to 2F") + local walked = stepTo("down", 13, 2) and stepTo("down", 13, 3) + for cx = 12, 7, -1 do walked = walked and stepTo("left", cx, 3) end + ok(walked, "walked across the 2F to the union desk") + U.hold(game, "up", 4) + waitFor(idle, 60) + local m, x, y = at() + ok(m == UnionCenters.FLOOR_2F and x == 7 and y == 3, ("in front of the union desk (%s %d,%d)"):format(m, x, y)) + U.still(game, SHOT_DIR .. "/yellow_pika_02_2f_desk.png") + U.tap(game, "a") + ok(mash(function() return ow().map.id == UnionCenters.UNION_ROOM end, 900), "walked into the room with Pikachu") + ok(waitFor(idle, 600), "room idle") + U.wait(40) + local ex, ey = UnionRoomMap.entry() + m, x, y = at() + ok(x == ex and y == ey, ("on the exit carpet (%d,%d)"):format(x, y)) + U.hold(game, "up", 30) + waitFor(idle, 200) + U.still(game, SHOT_DIR .. "/yellow_pika_03_room.png") + ok(follower() ~= nil, "Pikachu inside the room") + U.teleport(game, UnionCenters.UNION_ROOM, ex, ey, "down") + U.hold(game, "down", 30) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F end, 600), "out through the carpet") + ok(mash(idle, 600), "walk-out finished") + m, x, y = at() + ok(x == 6 and y == 4, ("walked out to %d,%d"):format(x, y)) + U.still(game, SHOT_DIR .. "/yellow_pika_04_2f_out.png") + U.teleport(game, UnionCenters.FLOOR_2F, 13, 2, "up") + game.save.unionOrigin = nil + require("src.online.union.Origin").record(game.save, { gen = 1, version = "yellow", + map = "PEWTER_POKECENTER", warp = UnionCenters.planFor(game.data, "PEWTER_POKECENTER").warp, + x = 13, y = 1, facing = "down" }) + U.hold(game, "up", 20) + ok(waitFor(function() return ow().map.id == "PEWTER_POKECENTER" and idle() end, 900), "stairs back down to Pewter") + U.wait(30) + ok(follower() ~= nil, "Pikachu followed back down") + + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen1_room_presence.lua b/tests/drivers/union_gen1_room_presence.lua new file mode 100644 index 00000000..84c15338 --- /dev/null +++ b/tests/drivers/union_gen1_room_presence.lua @@ -0,0 +1,289 @@ +local U = require("tests.drivers.util") +local Avatars = require("src.online.union.Avatars") +local FakeRelay = require("tests.support.fake_relay") +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Pokemon = require("src.pokemon.Pokemon") +local Presence = require("src.world.gen1.UnionRoomPresence") +local Protocol2 = require("src.online.Protocol2") +local PF = require("src.world.PikachuFollower") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + +local FP = { blue = "1111111111111111", crystal = "4444444444444444", firered = "5555555555555555", + ruby = "7777777777777777", emerald = "6666666666666666" } + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + return cond + end + local function done() + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + U.wait(10) + local v = GameVersion.get() + local dir = (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-w5b-gen1") .. "/" .. v + local home = os.getenv("HOME") .. "/Library/Application Support/LOVE/" + Avatars.setReader(Avatars.directoryReader({ + red = home .. "g1r-red", blue = home .. "g1r-blue", yellow = home .. "g1r-yellow", + crystal = home .. "g1r-crystal", firered = home .. "g1r-firered", ruby = home .. "g1r-ruby", + })) + + local f = game.save.flags + f.EVENT_GOT_STARTER = true + f.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + f.EVENT_GOT_POKEDEX = true + if GameVersion.isYellow() then + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20), Pokemon.new(game.data, "PIDGEY", 5) } + end + game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 10, y = 10 } + + local relay = FakeRelay.new({ clock = function() return love.timer.getTime() end }) + local mine = relay:seat("00000001", "ME") + local Client = require("src.online.Client") + Client.reset() + Client.configure({ relayAddress = "fake:3", connect = function() return mine.transport end }) + Client.connect({ name = "ME", profiles = {} }) + + local function step(n) + for _ = 1, n or 1 do + relay:pump() + coroutine.yield() + end + end + local function waitFor(cond, n) + for _ = 1, n or 600 do + if cond() then return true end + step(1) + end + return cond() + end + local function waitReal(cond, seconds) + local stop = love.timer.getTime() + seconds + while love.timer.getTime() < stop do + if cond() then return true end + step(1) + end + return cond() + end + local function tap(btn) + table.insert(game.input.pressQueue, btn) + step(1) + game.input.state[btn] = false + step(2) + end + local function mash(btn, cond, n) + for _ = 1, n or 200 do + if cond() then return true end + tap(btn) + end + return cond() + end + local function ow() return game.overworld end + local function top() return game.stack:top() end + local function atWorld() return top() == ow() end + local function shot(name) + U.still(game, ("%s/%s_%s.png"):format(dir, v, name)) + end + ok(waitFor(function() return Client.state() == "online" end, 600), "the client links to the fake relay") + + FP.blue = require("src.online.union.Caps").fingerprint(game.data, 1) + local seats = {} + local function fake(n, version, name, gender, style) + local id = ("%08x"):format(n) + local seat = relay:seat(id, name) + local gen = GameVersion.generation(version) + local profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" } + relay:handle(seat, Protocol2.lobbyHello({ name = name, profiles = { profile }, xgen = 1 })) + relay:handle(seat, Protocol2.plazaJoin("union", profile, + { name = name, trainerId = n, gender = gender, version = version, style = style }, 40, + { xgen = 1, caps = { proto = 1, policy = 1, gens = { [tostring(gen)] = { { version = version, fp = FP[version] } } } } })) + seats[name] = { seat = seat, id = id, profile = profile } + return seat + end + fake(2, "blue", "BLUE", 0, "player") + fake(3, "crystal", "KRIS", 1, "player") + fake(4, "firered", "LEAF", 0, "g3:3") + fake(5, "ruby", "MAY", 1, "player") + fake(6, "emerald", "WALLY", 0, "g3:5") + step(4) + + local cx, cy = UnionRoomMap.cellFor(1) + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + ok(waitFor(function() + local s = Presence.active() + return s and s.state == "joined" and #s:entities() == 5 + end, 1200), "the room connects and shows five members") + local s = Presence.active() + if not s then return done() end + mash("a", atWorld, 60) + step(20) + + local byName = {} + for _, e in ipairs(s:entities()) do byName[e.p.name] = e end + ok(byName.BLUE and not byName.BLUE.entry.standin and byName.BLUE.entry.gen == 1, "BLUE wears the Gen 1 sprite") + ok(byName.KRIS and not byName.KRIS.entry.standin and byName.KRIS.entry.family == "gb2f", "KRIS wears Kris") + ok(byName.LEAF and not byName.LEAF.entry.standin and byName.LEAF.entry.h == 32, "LEAF wears the FRLG class sprite") + ok(byName.MAY and not byName.MAY.entry.standin and byName.MAY.entry.family == "rse:player", "MAY wears the R/S player") + ok(byName.WALLY and byName.WALLY.entry.hostStandin and byName.WALLY.entry.hostVersion == v + and #byName.WALLY.entry.need > 0, "WALLY wears a host-game trainer without an Emerald cache") + ok(s.focus == byName.BLUE, "the faced member shows its name tag") + for _, e in ipairs(s:entities()) do + local x, y = UnionRoomMap.cellFor(e.p.slot) + ok(e.cellX == x and e.cellY == y, ("%s stands on slot %d's cell"):format(e.p.name, e.p.slot)) + end + + local reads = Avatars.stats().reads + step(120) + ok(Avatars.stats().reads == reads, "no avatar cache reads while the room runs") + + local modes = { "gbc", "redpp", "ogred", "og" } + for _, mode in ipairs(modes) do + PaletteFX.setMode(mode) + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + step(10) + ok(Presence.active() == s and #ow().npcs >= 5, mode .. ": the members rebind after the map reloads") + shot("01_room_" .. mode) + end + PaletteFX.setMode("gbc") + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + step(10) + + local w0, h0, flags0 = love.window.getMode() + love.window.setMode(160, 144, { resizable = true, minwidth = 160, minheight = 144 }) + step(8) + shot("02_room_native_1x") + love.window.setMode(390, 844, { resizable = true, minwidth = 160, minheight = 144 }) + step(8) + shot("03_room_phone") + PaletteFX.setMode("redpp") + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + step(10) + shot("03b_room_phone_redpp") + PaletteFX.setMode("gbc") + love.window.setMode(w0, h0, flags0) + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + step(10) + + local lx, ly = byName.LEAF.cellX, byName.LEAF.cellY + ow().player.cellX, ow().player.cellY = lx, ly - 1 + ow().player.px, ow().player.py = lx * 16, (ly - 1) * 16 + ow().player.facing = "down" + step(6) + shot("04_occlusion_tall_sprite_below_player") + ok(s.focus == byName.LEAF, "facing LEAF from above tags LEAF") + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + step(10) + + tap("a") + ok(waitFor(function() return s.busy end, 120), "talking to BLUE opens the talk flow") + ok(byName.BLUE.facing == "down", "BLUE turns to face the player") + ok(mash("a", function() + local t = top() + return t and t.items and #t.items == 3 + end, 120), "the BATTLE / TRADE / CANCEL menu is up") + step(4) + shot("05_talk_menu") + tap("a") + local inv + ok(waitFor(function() + for _, i in pairs(relay.invites) do if i.to == seats.BLUE.id then inv = i end end + return inv ~= nil + end, 240), "a battle invite reaches BLUE") + ok(waitFor(function() local t = top() return t and t.tick ~= nil end, 240), "the waiting line is up") + shot("06_waiting") + relay:handle(seats.BLUE.seat, { type = "invite_reply", id = inv.id, accept = true }) + ok(waitFor(function() return s.activity ~= nil end, 600), "BLUE accepting begins the activity") + ok(waitFor(function() return s.activity and s.activity.state == "ready" and top() and top().tick ~= nil end, 900), + "the getting-ready line is up") + local prep = s.activity and s.activity.prep + ok(prep and prep.rules and prep.rules.ruleset == "native", "Gen 1 vs Gen 1 resolves the native ruleset") + shot("07_getting_ready") + tap("b") + ok(waitFor(function() local t = top() return t and t.pages ~= nil end, 120), "cancel shows a line") + shot("08_cancelled") + ok(mash("a", function() return s.activity == nil and not s.busy and atWorld() end, 120), + "the activity closes cleanly") + ok(waitFor(function() return Client.room() == nil end, 600), "the prep room is left") + + relay:handle(seats.KRIS.seat, { type = "invite", to = "00000001", activity = "xg_trade", + profile = seats.KRIS.profile, detail = {} }) + ok(waitFor(function() return s.busy end, 600), "KRIS's trade request opens a prompt") + ok(byName.KRIS.facing ~= "down" or byName.KRIS.cellY < ow().player.cellY, "KRIS turns toward the player") + ok(mash("a", function() + local t = top() + return t and getmetatable(t) == require("src.ui.ChoiceBox") + end, 200), "the YES / NO box is up") + shot("09_incoming_trade") + tap("b") + ok(waitFor(function() return not s.busy and atWorld() end, 240), "NO declines") + + ow().player.cellX, ow().player.cellY = byName.WALLY.cellX + 1, byName.WALLY.cellY + ow().player.px, ow().player.py = ow().player.cellX * 16, ow().player.cellY * 16 + ow().player.facing = "left" + step(4) + tap("a") + ok(waitFor(function() return s.busy end, 120), "talking to the stand-in opens the talk flow") + step(60) + shot("10_standin_line") + mash("a", function() local t = top() return t and t.items ~= nil end, 200) + tap("b") + mash("a", function() return not s.busy and atWorld() end, 120) + + relay:handle(seats.MAY.seat, { type = "presence", status = "busy" }) + ow().player.cellX, ow().player.cellY = byName.MAY.cellX, byName.MAY.cellY + 1 + ow().player.px, ow().player.py = ow().player.cellX * 16, ow().player.cellY * 16 + ow().player.facing = "up" + ok(waitFor(function() return s:entity(byName.MAY.p.slot).p.status == "busy" end, 240), "MAY's busy status arrives") + tap("a") + ok(waitFor(function() local t = top() return t and t.pages ~= nil end, 120), "talking to a busy member answers") + step(40) + shot("11_busy") + mash("a", atWorld, 120) + + if GameVersion.isYellow() then + U.teleport(game, UnionRoomMap.MAP_ID, UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y, "up") + step(20) + for _ = 1, 4 do + U.hold(game, "up", 18) + step(20) + end + local pika = PF.current(ow()) + ok(pika ~= nil, "Pikachu follows inside the Union Room") + local clash = false + for _, e in ipairs(s:entities()) do + if pika and pika.cellX == e.cellX and pika.cellY == e.cellY then clash = true end + end + ok(not clash, "Pikachu never shares a member's cell") + shot("12_yellow_pikachu") + end + + relay:drop(mine) + ok(waitFor(function() return s.state == "reconnecting" end, 240), "a dropped link is noticed") + ok(waitFor(function() return s.lostBox ~= nil end, 400), "the reconnecting line shows") + step(60) + shot("13_reconnecting") + relay:reconnect(mine) + ok(waitReal(function() return s.state == "joined" end, 10), "the link comes back") + print(("client %s presence %s room %s"):format(Client.state(), s.state, s.room.state)) + ok(waitFor(function() return s.lostBox == nil and atWorld() end, 240), "the reconnecting line closes") + ok(#s:entities() == 5, "every member is back after the resume") + step(30) + shot("14_resumed") + + U.teleport(game, "POKECENTER_2F", 6, 4, "down") + step(4) + ok(waitFor(function() return Presence.active() == nil end, 240), "leaving the map ends the presence") + ok(waitFor(function() + for _, inst in ipairs(relay.plazas.union) do if inst.slots["00000001"] then return false end end + return true + end, 240), "leaving the room leaves the plaza") + done() +end diff --git a/tests/drivers/union_gen1_room_relay.lua b/tests/drivers/union_gen1_room_relay.lua new file mode 100644 index 00000000..3975e07a --- /dev/null +++ b/tests/drivers/union_gen1_room_relay.lua @@ -0,0 +1,86 @@ +local U = require("tests.drivers.util") +local GameVersion = require("src.core.GameVersion") +local Presence = require("src.world.gen1.UnionRoomPresence") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + return cond + end + local function done() + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + local role = os.getenv("UR_ROLE") or "host" + local v = GameVersion.get() + local dir = (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-w5b-gen1") .. "/relay" + local function shot(name) U.still(game, ("%s/%s_%s_%s.png"):format(dir, role, v, name)) end + local function waitReal(cond, seconds) + local stop = love.timer.getTime() + seconds + while love.timer.getTime() < stop do + if cond() then return true end + coroutine.yield() + end + return cond() + end + local function tap(btn) + table.insert(game.input.pressQueue, btn) + U.wait(1) + game.input.state[btn] = false + U.wait(2) + end + local function top() return game.stack:top() end + U.wait(10) + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.player.name = role == "host" and "HOST" or "GUEST" + local cx, cy = UnionRoomMap.cellFor(role == "host" and 20 or 21) + U.teleport(game, UnionRoomMap.MAP_ID, cx, cy + 1, "up") + local s + ok(waitReal(function() + s = Presence.active() + return s and s.state == "joined" + end, 20), role .. " joins the live relay's Union Room") + if not s then return done() end + ok(waitReal(function() return #s:entities() >= 1 end, 30), role .. " sees the other process") + local other = s:entities()[1] + print(("%s sees %s gen %s slot %s"):format(role, tostring(other and other.p.name), tostring(other and other.p.gen), + tostring(other and other.p.slot))) + shot("01_room") + + if role == "host" then + require("src.ui.union.gen1.Talk").invite(game, s, other.p, "xg_battle", function() end) + ok(waitReal(function() return s.activity ~= nil end, 30), "the guest accepts over the live relay") + ok(waitReal(function() return s.activity and (s.activity.state == "ready" or s.activity.state == "done") end, 20), + "the prep resolves") + local prep = s.activity and s.activity.prep + print(("host prep state %s rules %s blocked %s"):format(tostring(prep and prep.state), + tostring(prep and prep.rules and prep.rules.ruleset), tostring(prep and prep.blocked))) + ok(s.activity and s.activity.state == "ready", "the host reaches the getting-ready line") + waitReal(function() return top() and top().tick ~= nil end, 10) + shot("02_ready") + waitReal(function() return false end, 2) + tap("b") + ok(waitReal(function() return top() and top().pages ~= nil end, 10), "cancel shows a line") + shot("03_cancelled") + else + ok(waitReal(function() return s.busy end, 40), "the host's invite reaches the guest") + for _ = 1, 400 do + if top() and getmetatable(top()) == require("src.ui.ChoiceBox") then break end + tap("a") + end + shot("02_prompt") + tap("a") + ok(waitReal(function() return s.activity and s.activity.state == "ready" end, 30), + "the guest reaches the getting-ready line") + ok(waitReal(function() return s.activity == nil or s.activity.state == "done" end, 40), + "the host's cancel reaches the guest") + waitReal(function() return top() and top().pages ~= nil end, 10) + shot("03_peer_cancelled") + end + waitReal(function() return false end, 3) + done() +end diff --git a/tests/drivers/union_gen1_stairs.lua b/tests/drivers/union_gen1_stairs.lua new file mode 100644 index 00000000..a468b5e9 --- /dev/null +++ b/tests/drivers/union_gen1_stairs.lua @@ -0,0 +1,88 @@ +local U = require("tests.drivers.util") +local UnionCenters = require("src.world.gen1.UnionCenters") +local Origin = require("src.online.union.Origin") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local function ow() return game.overworld end + local function idle() + local o = ow() + return o and game.stack:top() == o and not o.transitioning + and not o.runner:isRunning() and #o.scriptMoves == 0 + and not (o.pendingScripts and o.pendingScripts[1]) + and not o.healAnim and not o.emote + end + local function waitFor(cond, frames) + for _ = 1, frames do + if cond() then return true end + U.wait(1) + end + return cond() + end + local function at() + local p = ow().player + return ow().map.id, p.cellX, p.cellY + end + local v = GameVersion.get() + U.wait(10) + local r = UnionCenters.forData(game.data) + ok(r ~= nil, "union centers registry present") + if not r then love.event.quit(1) return end + ok(#r.order == #UnionCenters.EXPECTED, ("%d of %d expected centers planned"):format(#r.order, #UnionCenters.EXPECTED)) + for _, id in ipairs(UnionCenters.EXPECTED) do + ok(r.plans[id] and r.plans[id].verified, id .. " plan verified at load") + end + + for _, id in ipairs(r.order) do + local plan = r.plans[id] + local town = id == "INDIGO_PLATEAU_LOBBY" and "INDIGO_PLATEAU" or "VIRIDIAN_CITY" + game.save.lastOutdoor = { id = town, x = 10, y = 10 } + Origin.clear(game.save) + U.teleport(game, id, plan.stairs.x, plan.stairs.y + 1, "up") + ow().lastOutdoor = game.save.lastOutdoor + local outdoorBefore = ow().lastOutdoor + if id == "VIRIDIAN_POKECENTER" or id == "MT_MOON_POKECENTER" or id == "INDIGO_PLATEAU_LOBBY" then + U.still(game, ("%s/%s_stairs_1f_%s.png"):format(SHOT_DIR, v, id:lower())) + end + U.hold(game, "up", 20) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900), + id .. ": stairs lead to 2F") + local m, x, y = at() + ok(m == UnionCenters.FLOOR_2F and x == UnionCenters.STAIRS_2F.x and y == UnionCenters.STAIRS_2F.y, + ("%s: arrived on the 2F stairs (%s %d,%d)"):format(id, m, x, y)) + local o = Origin.get(game.save) + ok(o and o.gen == 1 and o.map == id and o.warp == plan.warp and o.x == plan.stairs.x + and o.y == plan.stairs.y and o.version == v, + ("%s: origin recorded (%s #%s)"):format(id, tostring(o and o.map), tostring(o and o.warp))) + ok(ow().lastOutdoor == outdoorBefore and game.save.lastOutdoor.id == town, + id .. ": outdoor bookkeeping untouched") + U.hold(game, "down", 20) + waitFor(idle, 200) + U.hold(game, "up", 20) + ok(waitFor(function() return ow().map.id == id and idle() end, 900), + id .. ": 2F stairs return to the same center") + m, x, y = at() + ok(m == id and x == plan.stairs.x and y == plan.stairs.y, + ("%s: back on the 1F stairs (%s %d,%d)"):format(id, m, x, y)) + ok(Origin.get(game.save) == nil, id .. ": origin cleared after returning") + ok(game.save.lastOutdoor.id == town, id .. ": lastOutdoor still the town") + end + + Origin.clear(game.save) + U.teleport(game, UnionCenters.FLOOR_2F, 13, 2, "up") + U.hold(game, "up", 20) + ok(waitFor(function() return ow().map.id ~= UnionCenters.FLOOR_2F and idle() end, 900), + "2F stairs without an origin fall back to the heal point") + local heal = ow():escapeWarpTarget() + ok(ow().map.id == heal, ("fallback landed on %s (heal %s)"):format(ow().map.id, tostring(heal))) + + print(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen2_center.lua b/tests/drivers/union_gen2_center.lua new file mode 100644 index 00000000..c4ba6347 --- /dev/null +++ b/tests/drivers/union_gen2_center.lua @@ -0,0 +1,329 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local Center = require("src.world.gen2.UnionCenter2F") +local Room = require("src.world.gen2.UnionRoomMap") +local Origin = require("src.online.union.Origin") +local Gen2Save = require("src.core.gen2.Save") +local GameVersion = require("src.core.GameVersion") + +return function(game) + local fails = 0 + local version = GameVersion.current + local shots = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-gen2" + local function ok(cond, line) + if not cond then fails = fails + 1 end + print(((cond and "PASS " or "FAIL ") .. line)) + return cond + end + + U.wait(60) + local world = game.world + if not ok(world and world.map, "world booted") then love.event.quit(1) return end + local maps = world.maps + if not ok(maps[Room.ID] ~= nil, "union room map present") then love.event.quit(1) return end + local base = game.stack:top() + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + local gate = game.data.gen2Scripts[Center.RECEPTIONIST_KEY][3].event + world.events:set(gate, true) + + local function settle(limit) + U.wait(8) + for _ = 1, limit or 900 do + if not world:busy() and not world.player.moving and game.stack:top() == base then + return true + end + if game.stack:top() ~= base then U.tap(game, "a") end + U.wait(2) + end + return false + end + + local function at() return world.map.id, world.player.cellX, world.player.cellY end + + local function step(dir) + local _, x0, y0 = at() + local m0 = world.map.id + for _ = 1, 60 do + U.hold(game, dir, 2) + local m, x, y = at() + if m ~= m0 or x ~= x0 or y ~= y0 then break end + end + settle() + end + + local function walk(dir, n) + for _ = 1, n do step(dir) end + end + + local function face(dir) + world.player.facing = dir + U.wait(2) + end + + local function talk(maxPresses) + U.tap(game, "a") + U.wait(2) + local started = world.vm:running() + local key = world.vm.ctxKey + local quiet = 0 + for _ = 1, maxPresses or 200 do + if not world.vm:running() and game.stack:top() == base and not world.mapSetup then + quiet = quiet + 1 + if quiet >= 6 then break end + else + quiet = 0 + end + if game.stack:top() ~= base then U.tap(game, "a") end + U.wait(3) + end + settle() + return started, key + end + + local function place(mapId, x, y, facing) + world:warpToMapId(mapId, x, y, facing) + U.wait(20) + settle() + end + + local centers = {} + for id, def in pairs(maps) do + for i, w in ipairs(def.warps or {}) do + if w.destMap == Center.MAP_ID and w.destWarp == 1 and id:find("_1F$") then + centers[#centers + 1] = { id = id, warp = i, x = w.x, y = w.y } + end + end + end + table.sort(centers, function(a, b) return a.id < b.id end) + ok(#centers >= 22, ("%d centers lead to the 2F"):format(#centers)) + + local function climb(c) + place(c.id, c.x + 1, c.y, "left") + if world.map.id ~= c.id or world.player.cellX ~= c.x + 1 or world.player.cellY ~= c.y then + place(c.id, c.x + 1, c.y, "left") + end + walk("left", 1) + local m = at() + return m == Center.MAP_ID + end + + local function descend() + place(Center.MAP_ID, 1, 7, "left") + walk("left", 1) + end + + local matrix = {} + for _, c in ipairs(centers) do + local up = climb(c) + local banked = world.backupWarp and world.backupWarp.map == c.id + local origin = Origin.get(save) + local recorded = origin and origin.map == c.id and origin.gen == 2 + step("right") + walk("left", 1) + local m, x, y = at() + local back = m == c.id and x == c.x and y == c.y + matrix[#matrix + 1] = ("%s up=%s banked=%s origin=%s back=%s"):format( + c.id, tostring(up), tostring(banked), tostring(recorded), tostring(back)) + ok(up and banked and recorded and back, "stairs round trip " .. c.id) + end + for _, line in ipairs(matrix) do print("MATRIX " .. version .. " " .. line) end + + local home = centers[1] + for _, c in ipairs(centers) do + if c.id == "CHERRYGROVE_POKECENTER_1F" then home = c end + end + local def1f = maps[home.id] + local nurse + for _, o in ipairs(def1f.objects) do + if o.sprite == "SPRITE_NURSE" then nurse = o end + end + place(home.id, nurse.x, nurse.y + 2, "up") + face("up") + save.party[1].hp = 1 + talk() + ok(save.party[1].hp == save.party[1].maxHp, "nurse healed the party") + local pcx, pcy + local m1 = require("src.world.gen2.Map").new(def1f, world.tilesets[def1f.tileset]) + for y = 0, m1.heightCells - 1 do + for x = 0, m1.widthCells - 1 do + if m1:cellCollision(x, y) == 0x93 then pcx, pcy = x, y end + end + end + place(home.id, pcx, pcy + 1, "up") + face("up") + U.tap(game, "a") + U.wait(30) + local opened = game.stack:top() ~= base + for _ = 1, 20 do + if game.stack:top() == base and not world.vm:running() then break end + U.tap(game, "b") + U.wait(6) + end + settle() + ok(opened, "1F PC opens") + + ok(climb(home), "up from " .. home.id) + local banked = { map = world.backupWarp.map, warp = world.backupWarp.warp } + local def2f = maps[Center.MAP_ID] + U.still(game, ("%s/%s_on_2f_left.png"):format(shots, version)) + for i = 1, 3 do + local o = def2f.objects[i] + place(Center.MAP_ID, o.x, o.y + 1, "up") + face("up") + local started, key = talk() + ok(key == o.scriptKey, ("2F receptionist %d runs %s"):format(i, tostring(o.scriptKey))) + local m, x, y = at() + ok(m == Center.MAP_ID and x == o.x and y == o.y + 1, ("receptionist %d leaves the player in place"):format(i)) + end + local sign = def2f.bgEvents[1] + place(Center.MAP_ID, sign.x, sign.y + 1, "up") + face("up") + local started, key = talk() + ok(key == sign.scriptKey, "2F sign reads") + + local rcpt + for _, o in ipairs(def2f.objects) do + if o.scriptKey == Center.RECEPTIONIST_KEY then rcpt = o end + end + ok(rcpt ~= nil, "union receptionist is on the 2F") + + place(Center.MAP_ID, rcpt.x, rcpt.y + 1, "up") + face("up") + world.events:set(gate, false) + talk() + ok(at() == Center.MAP_ID, "gate closed keeps the player on the 2F") + world.events:set(gate, true) + local party = save.party + save.party = {} + face("up") + talk() + ok(at() == Center.MAP_ID, "empty party keeps the player on the 2F") + save.party = party + + local leaveScene + for id, row in pairs(def2f.sceneScripts) do + if row.scriptKey == Center.LEFT_KEY then leaveScene = id end + end + + local function enter(tag) + place(Center.MAP_ID, rcpt.x, rcpt.y + 2, "up") + step("up") + local _, px, py = at() + ok(px == rcpt.x and py == rcpt.y + 1, ("%s: walked to the union desk (%d,%d)"):format(tag, px, py)) + if tag == "first" then U.still(game, ("%s/%s_union_desk.png"):format(shots, version)) end + face("up") + save.party[1].hp = 1 + U.tap(game, "a") + if tag == "first" then + U.wait(90) + U.still(game, ("%s/%s_union_receptionist_text.png"):format(shots, version)) + end + local entered = false + for _ = 1, 600 do + if world.map.id == Room.ID then entered = true break end + if game.stack:top() ~= base then U.tap(game, "a") end + U.wait(3) + end + U.wait(30) + settle() + ok(entered, tag .. ": union receptionist walks the player into the room") + ok(save.party[1].hp == save.party[1].maxHp, tag .. ": union receptionist healed the party") + local onDisk = Gen2Save.load(version) + ok(onDisk and onDisk.position and onDisk.position.map == home.id + and onDisk.position.x == nurse.x and onDisk.position.y == nurse.y + 2 + and onDisk.position.facing == "up", + tag .. ": desk save is written at the origin nurse front") + ok(world.mapScenes[Center.MAP_ID] == leaveScene and world.mapScenes[Room.ID] == 1, + tag .. ": room setup armed the 2F leave scene") + local _, rx, ry = at() + ok(rx == Room.EXIT_X and ry == Room.EXIT_Y, ("%s: room entry at (%d,%d)"):format(tag, rx, ry)) + ok(world.backupWarp.map == banked.map and world.backupWarp.warp == banked.warp, + tag .. ": entering the room leaves backupWarp on " .. banked.map) + end + + local function leave(tag) + for _ = 1, 4 do + if world.map.id ~= Room.ID then break end + step("down") + end + U.wait(30) + settle() + local m, x, y = at() + ok(m == Center.MAP_ID, tag .. ": room exit returns to the 2F") + ok(x == rcpt.x and y == rcpt.y + 1, ("%s: leave walk ends in front of the desk (%d,%d)"):format(tag, x, y)) + local back + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.scriptKey == Center.RECEPTIONIST_KEY then back = npc end + end + ok(back and back.cellX == rcpt.x and back.cellY == rcpt.y, tag .. ": receptionist back at the desk") + ok((world.mapScenes[Center.MAP_ID] or 0) == 0 and (world.mapScenes[Room.ID] or 0) == 0, + tag .. ": leave scene disarmed") + ok(world.backupWarp.map == banked.map and world.backupWarp.warp == banked.warp, + tag .. ": leaving the room keeps backupWarp on " .. banked.map) + end + + enter("first") + U.still(game, ("%s/%s_union_room_entry.png"):format(shots, version)) + local sx, sy = Room.cellFor(1) + place(Room.ID, sx, sy + 1, "up") + U.still(game, ("%s/%s_union_room_slot1.png"):format(shots, version)) + place(Room.ID, Room.EXIT_X, Room.EXIT_Y - 1, "down") + leave("first") + U.still(game, ("%s/%s_union_left_room.png"):format(shots, version)) + + local function reload() + game:continueGame(Gen2Save.load(version)) + U.wait(30) + world = game.world + base = game.stack:top() + settle() + end + + local function sealedAt(what, shot) + local onDisk = Gen2Save.load(version) + local p = onDisk and onDisk.position + ok(p and p.map == home.id and p.x == nurse.x and p.y == nurse.y + 2 and p.facing == "up", + what .. ": written save is at the origin nurse front") + ok(onDisk and onDisk.backupWarp and onDisk.backupWarp.map == home.id + and onDisk.backupWarp.warp == home.warp, what .. ": written backupWarp names the 1F stairs") + ok(onDisk and onDisk.mapScenes and onDisk.mapScenes[Room.ID] == nil + and (onDisk.mapScenes[Center.MAP_ID] or 0) == 0, what .. ": written save has no union scenes") + reload() + local m, x, y = at() + ok(m == home.id and x == nurse.x and y == nurse.y + 2 and world.player.facing == "up", + ("%s: reload lands in front of the nurse (%s %d,%d %s)"):format(what, m, x, y, world.player.facing)) + U.still(game, ("%s/%s_%s.png"):format(shots, version, shot)) + end + + enter("second") + place(Room.ID, 12, 23, "down") + ok(game:writeSave(), "save inside the room") + local m, x, y = at() + ok(m == Room.ID and x == 12 and y == 23, "saving in the room leaves the live player in the room") + ok(world.mapScenes[Center.MAP_ID] == leaveScene and world.mapScenes[Room.ID] == 1, + "saving in the room leaves the live scenes armed") + ok(world.backupWarp.map == banked.map, "saving in the room leaves the live backupWarp") + leave("after saving in the room") + sealedAt("room save", "reload_after_room_save") + + ok(climb(home), "climb again after the room reload") + place(Center.MAP_ID, 18, 5, "down") + ok(game:writeSave(), "save on the added 2F columns") + m, x, y = at() + ok(m == Center.MAP_ID and x == 18 and y == 5, "saving at x>=16 leaves the live player there") + sealedAt("2F x>=16 save", "reload_after_2f_added_save") + + ok(climb(home), "climb again after the x>=16 reload") + place(Center.MAP_ID, 14, 4, "down") + ok(game:writeSave(), "save on a vanilla 2F cell") + reload() + m, x, y = at() + ok(m == Center.MAP_ID and x == 14 and y == 4, ("vanilla 2F save reloads in place (%s %d,%d)"):format(m, x, y)) + descend() + m, x, y = at() + ok(m == home.id and x == home.x and y == home.y, ("2F stairs after reload lead to %s"):format(m)) + + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen2_layout.lua b/tests/drivers/union_gen2_layout.lua new file mode 100644 index 00000000..7b456b82 --- /dev/null +++ b/tests/drivers/union_gen2_layout.lua @@ -0,0 +1,68 @@ +local U = require("tests.drivers.util") +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") +local Setting = require("src.online.union.Setting") +local Center = require("src.world.gen2.UnionCenter2F") +local Room = require("src.world.gen2.UnionRoomMap") + +local function deepEq(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do + if not deepEq(v, b[k]) then return false end + end + for k in pairs(b) do + if a[k] == nil then return false end + end + return true +end + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + U.wait(60) + local world = game.world + if not (world and world.map) then print("FAIL world did not boot") love.event.quit(1) return end + local v = GameVersion.current + local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-gen2" + local on = Setting.patchesOn(2) + local tag = on and "on" or "off" + local maps, tilesets = world.maps, world.tilesets + local fresh = CacheFs.loadActive("data/generated/maps.lua") + local freshTs = CacheFs.loadActive("data/generated/tilesets.lua") + print(("setting %s for %s"):format(tag, v)) + if on then + ok(maps[Room.ID] ~= nil, "union room added") + ok(maps[Center.MAP_ID].width == fresh[Center.MAP_ID].width + 2, "2F widened by two blocks") + ok(not deepEq(maps[Center.MAP_ID], fresh[Center.MAP_ID]), "2F differs from the cache") + else + ok(maps[Room.ID] == nil, "no union room") + ok(deepEq(maps[Center.MAP_ID], fresh[Center.MAP_ID]), "2F equals the cache table") + ok(deepEq(tilesets[maps[Center.MAP_ID].tileset], freshTs[maps[Center.MAP_ID].tileset]), + "2F tileset equals the cache table") + ok(deepEq(tilesets.TILESET_GATE, freshTs.TILESET_GATE), "gate tileset equals the cache table") + local scripts = CacheFs.loadActive("data/generated/scripts.lua") + ok(game.data.gen2Scripts[Center.RECEPTIONIST_KEY] == nil + and deepEq(game.data.gen2Scripts, scripts), "scripts equal the cache table") + end + local spots = { { 4, 4 }, { 11, 4 } } + if on then spots[#spots + 1] = { 16, 4 } end + for _, spot in ipairs(spots) do + world:warpToMapId(Center.MAP_ID, spot[1], spot[2], "up") + U.wait(40) + U.still(game, ("%s/%s_%s_2f_x%d.png"):format(dir, v, tag, spot[1])) + end + if on then + world:warpToMapId(Room.ID, 12, 12, "down") + U.wait(40) + U.still(game, ("%s/%s_on_room_center.png"):format(dir, v)) + world:warpToMapId(Room.ID, 12, 23, "down") + U.wait(40) + U.still(game, ("%s/%s_on_room_exit.png"):format(dir, v)) + end + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen2_off_safety.lua b/tests/drivers/union_gen2_off_safety.lua new file mode 100644 index 00000000..59ca9661 --- /dev/null +++ b/tests/drivers/union_gen2_off_safety.lua @@ -0,0 +1,105 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local Setting = require("src.online.union.Setting") +local Origin = require("src.online.union.Origin") +local Center = require("src.world.gen2.UnionCenter2F") +local Room = require("src.world.gen2.UnionRoomMap") +local GameVersion = require("src.core.GameVersion") + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + U.wait(60) + if not (game.world and game.world.map) then print("FAIL world did not boot") love.event.quit(1) return end + local on = Setting.patchesOn(2) + local v = GameVersion.current + local tag = on and "on" or "off" + local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-gen2" + print(("setting %s for %s"):format(tag, v)) + local maps = game.world.maps + local function nurseFront(id) + for _, o in ipairs(maps[id].objects) do + if o.sprite == "SPRITE_NURSE" then return o.x, o.y + 2 end + end + end + local home = "VIOLET_POKECENTER_1F" + local healCenter = "CHERRYGROVE_POKECENTER_1F" + local stairs = maps[home].warps[3] + local template = copy(game.save) + template.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + template.spawn = "SPAWN_CHERRYGROVE" + + local function boot(pos, backup, withOrigin) + local save = copy(template) + save.position = pos + save.backupWarp = backup + save.mapScenes = { [Center.MAP_ID] = 99, [Room.ID] = 1 } + if withOrigin ~= false then + Origin.record(save, { gen = 2, version = v, map = home, warp = 3, + x = stairs.x, y = stairs.y, facing = "left" }) + end + game:continueGame(save) + U.wait(40) + return save, game.world + end + local function at(w) return w.map.id, w.player.cellX, w.player.cellY, w.player.facing end + local function atNurse(w, id, what) + local m, x, y, f = at(w) + local nx, ny = nurseFront(id) + ok(m == id and x == nx and y == ny and f == "up", + ("%s: %s lands in front of the %s nurse (%s %d,%d %s)"):format(tag, what, id, m, x, y, f)) + end + + local good = { map = home, warp = 3 } + local save, w = boot({ map = Room.ID, x = 12, y = 23, facing = "down" }, good) + atNurse(w, home, "room save") + ok(Origin.get(save) == nil, tag .. ": origin cleared") + ok(save.mapScenes[Room.ID] == nil, tag .. ": room scene cleared") + U.still(game, ("%s/%s_%s_safety_room_save.png"):format(dir, v, tag)) + w:warpToMapId(home, stairs.x + 1, stairs.y, "left") + U.wait(30) + U.hold(game, "left", 30) + U.wait(60) + ok(game.world.map.id == Center.MAP_ID, tag .. ": the 1F stairs climb to the 2F") + ok(game.world.backupWarp and game.world.backupWarp.map == home, tag .. ": backupWarp banks " .. home) + + save, w = boot({ map = Room.ID, x = 12, y = 23 }, { map = "NOWHERE", warp = 1 }, false) + atNurse(w, healCenter, "room save with no origin") + + save, w = boot({ map = Center.MAP_ID, x = 17, y = 3, facing = "up" }, good) + atNurse(w, home, "save at the added desk") + U.still(game, ("%s/%s_%s_safety_2f_added.png"):format(dir, v, tag)) + + for _, id in ipairs({ "INDIGO_PLATEAU_POKECENTER_1F", "GOLDENROD_POKECENTER_1F" }) do + local idx + for i, wp in ipairs(maps[id].warps) do + if wp.destMap == Center.MAP_ID then idx = i end + end + local s = copy(template) + s.position = { map = Room.ID, x = 12, y = 23, facing = "down" } + s.backupWarp = { map = id, warp = idx } + Origin.record(s, { gen = 2, version = v, map = id, warp = idx, + x = maps[id].warps[idx].x, y = maps[id].warps[idx].y }) + game:continueGame(s) + U.wait(40) + atNurse(game.world, id, "room save from " .. id) + U.still(game, ("%s/%s_%s_safety_%s.png"):format(dir, v, tag, id:lower())) + end + + save, w = boot({ map = Center.MAP_ID, x = 0, y = 7, facing = "right" }, good) + local m, x, y = at(w) + ok(m == Center.MAP_ID and x == 0 and y == 7, tag .. ": a save on the 2F stairs is left alone") + ok(Origin.get(save) ~= nil, tag .. ": and keeps its origin") + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/union_gen2_room_presence.lua b/tests/drivers/union_gen2_room_presence.lua new file mode 100644 index 00000000..32300e83 --- /dev/null +++ b/tests/drivers/union_gen2_room_presence.lua @@ -0,0 +1,228 @@ +local U = require("tests.drivers.util") +local Avatars = require("src.online.union.Avatars") +local FakeRelay = require("tests.support.fake_relay") +local GameVersion = require("src.core.GameVersion") +local Presence = require("src.world.gen2.UnionRoomPresence") +local Protocol2 = require("src.online.Protocol2") +local RoomMap = require("src.world.gen2.UnionRoomMap") +local Text = require("src.ui.gen2.union.Text") + +local FP = { red = "1111111111111111", crystal = "4444444444444444", firered = "5555555555555555", + ruby = "7777777777777777", emerald = "6666666666666666" } + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + return cond + end + local function done() + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + U.wait(60) + local world = game.world + if not ok(world and world.map ~= nil, "world booted") then return done() end + local v = GameVersion.current + local dir = (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-w5b") .. "/" .. v + local home = os.getenv("HOME") .. "/Library/Application Support/LOVE/" + Avatars.setReader(Avatars.directoryReader({ + red = home .. "g1r-red", crystal = home .. "g1r-crystal", [v] = home .. "g1r-" .. v, + firered = home .. "g1r-firered", ruby = home .. "g1r-ruby", + })) + + local relay = FakeRelay.new({ clock = function() return love.timer.getTime() end }) + local mine = relay:seat("00000001", "ME") + local Client = require("src.online.Client") + Client.reset() + Client.configure({ relayAddress = "fake:3", connect = function() return mine.transport end }) + + local function step(n) + for _ = 1, n or 1 do + relay:pump() + Client.update(0) + coroutine.yield() + end + end + local function waitFor(cond, n) + for _ = 1, n or 600 do + if cond() then return true end + step(1) + end + return cond() + end + local function waitReal(cond, seconds) + local stop = love.timer.getTime() + seconds + while love.timer.getTime() < stop do + if cond() then return true end + step(1) + end + return cond() + end + local function remode(w, h, flags) + love.window.setMode(w, h, flags) + world.mapImages = {} + world.mapImage = world:imageFor(world.map.id) + step(6) + end + local function tap(btn) + table.insert(game.input.pressQueue, btn) + step(1) + game.input.state[btn] = false + step(2) + end + local function mash(btn, cond, n) + for _ = 1, n or 200 do + if cond() then return true end + tap(btn) + end + return cond() + end + local function shot(name) + U.still(game, ("%s/%s_%s.png"):format(dir, v, name)) + end + + local seats = {} + local function fake(n, version, name, gender, style) + local id = ("%08x"):format(n) + local seat = relay:seat(id, name) + local gen = GameVersion.generation(version) + local profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" } + relay:handle(seat, Protocol2.lobbyHello({ name = name, profiles = { profile }, xgen = 1 })) + relay:handle(seat, Protocol2.plazaJoin("union", profile, + { name = name, trainerId = n, gender = gender, version = version, style = style }, 40, + { xgen = 1, caps = { proto = 1, policy = 1, gens = { [tostring(gen)] = { { version = version, fp = FP[version] } } } } })) + seats[name] = { seat = seat, id = id, profile = profile } + return seat + end + fake(2, "red", "RED", 0, "player") + fake(3, "crystal", "KRIS", 1, "player") + fake(4, "firered", "LEAF", 0, "g3:3") + fake(5, "ruby", "MAY", 1, "player") + fake(6, "emerald", "WALLY", 0, "g3:5") + + local cx, cy = RoomMap.cellFor(1) + world:warpToMapId(RoomMap.ID, cx, cy + 1, "up") + ok(waitFor(function() + local s = Presence.active() + return s and s.state == "joined" and #s:entities() == 5 + end, 1200), "the room connects and shows five members") + local s = Presence.active() + if not s then return done() end + mash("a", function() return game.stack:top() == nil and s.ui == nil end, 60) + step(30) + + local byName = {} + for _, e in ipairs(s:entities()) do byName[e.participant.name] = e end + ok(byName.RED and not byName.RED.avatar.standin and byName.RED.avatar.gen == 1, "RED wears the Gen 1 sprite") + ok(byName.KRIS and not byName.KRIS.avatar.standin and byName.KRIS.avatar.family == "gb2f", "KRIS wears Kris") + ok(byName.LEAF and not byName.LEAF.avatar.standin and byName.LEAF.avatar.h == 32, "LEAF wears the FRLG class sprite") + ok(byName.MAY and not byName.MAY.avatar.standin and byName.MAY.avatar.family == "rse:player", "MAY wears the R/S player") + ok(byName.WALLY and byName.WALLY.avatar.hostStandin and byName.WALLY.avatar.hostVersion == v + and #byName.WALLY.avatar.need > 0, "WALLY wears a host-game trainer without an Emerald cache") + ok(s.tagged == byName.RED, "the faced member shows its name tag") + shot("01_room_default") + + local G = love.graphics + local w0, h0, flags0 = love.window.getMode() + remode(160, 144, { resizable = true, minwidth = 160, minheight = 144 }) + shot("02_room_native_1x") + remode(390, 844, { resizable = true, minwidth = 160, minheight = 144 }) + shot("03_room_phone") + remode(w0, h0, flags0) + + tap("a") + ok(waitFor(function() return s.ui == "talk" end, 120), "talking to RED opens the talk flow") + ok(byName.RED.facing == "down", "RED turns to face the player") + ok(mash("a", function() + local top = game.stack:top() + return top and top.items and #top.items == 3 + end, 120), "the BATTLE / TRADE / CANCEL menu is up") + step(4) + shot("04_talk_menu") + tap("a") + local inv + ok(waitFor(function() + for _, i in pairs(relay.invites) do if i.to == seats.RED.id then inv = i end end + return inv ~= nil + end, 240), "a battle invite reaches RED") + ok(waitFor(function() local t = game.stack:top() return t and t.tick ~= nil end, 240), "the waiting line is up") + shot("05_waiting") + relay:handle(seats.RED.seat, { type = "invite_reply", id = inv.id, accept = true }) + ok(waitFor(function() return s.activity ~= nil end, 600), "RED accepting begins the activity") + ok(waitFor(function() return s.activity and s.activity.waiter and s.activity.waiter:shown() end, 600), + "the getting-ready line is up") + local prep = s.activity and s.activity.prep + waitFor(function() return prep and prep.rules ~= nil end, 120) + print(("prep state %s blocked %s caps %s"):format(tostring(prep and prep.state), tostring(prep and prep.blocked), + tostring(s.room.caps and next(s.room.caps.gens)))) + ok(prep and prep.rules and prep.rules.ruleset == "g3u", "Gen 2 vs Gen 1 resolves the g3u ruleset") + shot("06_getting_ready") + tap("b") + ok(waitFor(function() local t = game.stack:top() return t and t.pages ~= nil end, 120), "cancel shows a line") + shot("07_cancelled") + ok(mash("a", function() return s.activity == nil and s.ui == nil and game.stack:top() == nil end, 120), + "the activity closes cleanly") + ok(waitFor(function() return Client.room() == nil end, 600), "the prep room is left") + + relay:handle(seats.KRIS.seat, { type = "invite", to = "00000001", activity = "xg_trade", + profile = seats.KRIS.profile, detail = {} }) + ok(waitFor(function() return s.ui == "prompt" end, 600), "KRIS's trade request opens a prompt") + ok(mash("a", function() + local top = game.stack:top() + return top and getmetatable(top) == require("src.ui.ChoiceBox") + end, 200), "the YES / NO box is up") + shot("08_incoming_trade") + tap("down") + tap("a") + ok(waitFor(function() return s.ui == nil and game.stack:top() == nil end, 240), "NO declines") + + world.player.cellX, world.player.cellY = byName.WALLY.cellX + 1, byName.WALLY.cellY + world.player.px, world.player.py = world.player.cellX * 16, world.player.cellY * 16 + world.player.facing = "left" + step(4) + tap("a") + ok(waitFor(function() return s.ui == "talk" end, 120), "talking to the stand-in opens the talk flow") + step(40) + shot("09_standin_line") + mash("b", function() local t = game.stack:top() return t and t.items ~= nil end, 200) + tap("b") + mash("a", function() return s.ui == nil and game.stack:top() == nil end, 120) + + relay:handle(seats.MAY.seat, { type = "presence", status = "busy" }) + world.player.cellX, world.player.cellY = byName.MAY.cellX, byName.MAY.cellY + 1 + world.player.px, world.player.py = world.player.cellX * 16, world.player.cellY * 16 + world.player.facing = "up" + ok(waitFor(function() return Presence.active():entity(byName.MAY.unionSlot).participant.status == "busy" end, 240), + "MAY's busy status arrives") + tap("a") + ok(waitFor(function() local t = game.stack:top() return t and t.pages ~= nil end, 120), "talking to a busy member answers") + step(40) + shot("10_busy") + mash("a", function() return s.ui == nil and game.stack:top() == nil end, 120) + + relay:drop(mine) + ok(waitFor(function() return s.lost end, 240), "a dropped link is noticed") + step(60) + ok(waitFor(function() return s.ui == "notice" end, 240), "the reconnecting line shows") + step(20) + shot("11_reconnecting") + relay:reconnect(mine) + ok(waitReal(function() return not s.lost end, 10), "the link comes back") + ok(waitFor(function() return s.ui == nil end, 240), "the reconnecting line closes") + ok(#s:entities() == 5, "every member is back after the resume") + step(30) + shot("12_resumed") + + world:warpToMapId("POKECENTER_2F", 4, 4, "down") + ok(waitFor(function() return Presence.active() == nil end, 240), "leaving the map ends the presence") + ok(waitFor(function() + for _, inst in ipairs(relay.plazas.union) do if inst.slots["00000001"] then return false end end + return true + end, 240), "leaving the room leaves the plaza") + done() +end diff --git a/tests/drivers/union_gen2_room_relay.lua b/tests/drivers/union_gen2_room_relay.lua new file mode 100644 index 00000000..5bdeacc6 --- /dev/null +++ b/tests/drivers/union_gen2_room_relay.lua @@ -0,0 +1,116 @@ +local U = require("tests.drivers.util") +local GameVersion = require("src.core.GameVersion") +local Presence = require("src.world.gen2.UnionRoomPresence") +local RoomMap = require("src.world.gen2.UnionRoomMap") + +return function(game) + local fails = 0 + local role = os.getenv("POKEPORT_UNION_ROLE") or "host" + local address = os.getenv("POKEPORT_UNION_RELAY") + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. role .. " " .. line) + return cond + end + local function done() + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) + coroutine.yield() + end + U.wait(60) + local world = game.world + if not ok(world and world.map ~= nil and address ~= nil, "world booted with a relay address") then return done() end + local v = GameVersion.current + local dir = (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/union-w5b") .. "/relay" + local Client = require("src.online.Client") + Client.configure({ relayAddress = address }) + + local function waitReal(cond, seconds) + local stop = love.timer.getTime() + seconds + while love.timer.getTime() < stop do + if cond() then return true end + coroutine.yield() + end + return cond() + end + local function tap(btn) + table.insert(game.input.pressQueue, btn) + coroutine.yield() + game.input.state[btn] = false + U.wait(2) + end + local function mashReal(btn, cond, seconds) + local stop = love.timer.getTime() + seconds + while love.timer.getTime() < stop do + if cond() then return true end + tap(btn) + end + return cond() + end + local function shot(name) + U.still(game, ("%s/%s_%s_%s.png"):format(dir, role, v, name)) + end + + world:warpToMapId(RoomMap.ID, 12, 22, "up") + ok(waitReal(function() + local s = Presence.active() + return s and s.state == "joined" and #s:entities() >= 1 + end, 20), "the live room shows the other game") + local s = Presence.active() + if not s then return done() end + waitReal(function() return game.stack:top() == nil and s.ui == nil end, 3) + local other = s:entities()[1] + ok(other and other.participant.gen == 2, "the other trainer reads gen 2") + print(("%s sees %s on %s slot %d"):format(role, tostring(other and other.participant.name), + tostring(other and other.participant.game), other and other.unionSlot or -1)) + local p = world.player + p.cellX, p.cellY = other.cellX, other.cellY + 1 + p.px, p.py = p.cellX * 16, p.cellY * 16 + p.facing = "up" + U.wait(10) + shot("01_room") + + if role == "host" then + waitReal(function() return false end, 2) + tap("a") + ok(waitReal(function() return s.ui == "talk" end, 3), "talking opens the menu flow") + ok(mashReal("a", function() + local top = game.stack:top() + return top and top.items ~= nil + end, 5), "the menu is up") + tap("a") + ok(waitReal(function() return s.activity ~= nil end, 20), "the guest accepts over the live relay") + else + ok(waitReal(function() return s.ui == "prompt" end, 25), "the live request arrives") + ok(mashReal("a", function() + local top = game.stack:top() + return top and getmetatable(top) == require("src.ui.ChoiceBox") + end, 5), "YES / NO is up") + shot("02_prompt") + tap("a") + ok(waitReal(function() return s.activity ~= nil end, 20), "accepting opens the activity") + end + ok(waitReal(function() return s.activity and s.activity.waiter and s.activity.waiter:shown() end, 10), + "the getting-ready line is up") + local prep = s.activity and s.activity.prep + ok(waitReal(function() prep = s.activity and s.activity.prep return prep and prep.rules ~= nil end, 10), + "live rules arrive") + print(("%s rules %s"):format(role, tostring(prep and prep.rules and prep.rules.ruleset))) + shot("03_getting_ready") + if role == "host" then + U.wait(60) + tap("b") + ok(waitReal(function() return s.activity and s.activity.state == "done" end, 5), "the host cancels") + U.wait(20) + shot("04_cancelled") + else + ok(waitReal(function() return s.activity and s.activity.why == "peer" end, 20), "the guest sees the host cancel") + U.wait(20) + shot("04_peer_cancelled") + end + mashReal("a", function() return s.activity == nil and game.stack:top() == nil end, 5) + ok(s.activity == nil, "the activity closes") + U.wait(30) + Client.disconnect() + done() +end diff --git a/tests/drivers/union_gen3_centers.lua b/tests/drivers/union_gen3_centers.lua new file mode 100644 index 00000000..55f990af --- /dev/null +++ b/tests/drivers/union_gen3_centers.lua @@ -0,0 +1,118 @@ +local U = require("tests.drivers.util") +local G = require("tests.drivers.union_gen3_util") + +local CENTERS = { + firered = { + { "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F" }, + { "FR_INDIGO_PLATEAU_POKEMON_CENTER_2F", "FR_INDIGO_PLATEAU_POKEMON_CENTER_1F" }, + { "SEVII_ONE_ISLAND_POKECENTER_2F", "SEVII_ONE_ISLAND_POKECENTER" }, + { "FR_SEVEN_ISLAND_POKEMON_CENTER_2F", "FR_SEVEN_ISLAND_POKEMON_CENTER_1F" }, + }, + leafgreen = { + { "FR_CERULEAN_CITY_POKEMON_CENTER_2F", "FR_CERULEAN_CITY_POKEMON_CENTER_1F" }, + { "FR_INDIGO_PLATEAU_POKEMON_CENTER_2F", "FR_INDIGO_PLATEAU_POKEMON_CENTER_1F" }, + { "SEVII_ONE_ISLAND_POKECENTER_2F", "SEVII_ONE_ISLAND_POKECENTER" }, + { "FR_ROUTE_4_POKEMON_CENTER_2F", "FR_ROUTE_4_POKEMON_CENTER_1F" }, + }, + emerald = { + { "EM_OLDALE_TOWN_POKEMON_CENTER_2F", "EM_OLDALE_TOWN_POKEMON_CENTER_1F" }, + { "EM_BATTLE_FRONTIER_POKEMON_CENTER_2F", "EM_BATTLE_FRONTIER_POKEMON_CENTER_1F" }, + { "EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_2F", "EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_1F" }, + }, +} + +return function(game) + local version = G.version() + local d = G.start("union_gen3_centers_" .. version) + local session = G.boot(d, game, version == "leafgreen" and 1 or 0) + if not session then return d.finish() end + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Union = require("src.core.game3.link.union_room") + local Plaza = require("src.core.game3.link.union_plaza_map") + local Client = require("src.online.Client") + local Family = require("src.core.game3.link.family") + local Flags = require("src.core.game3.scripting.flags") + local e = G.env(d, game, "ME") + if not d.check(e.connect(), "online on the fake relay") then return d.finish() end + if version == "emerald" then + -- pokeemerald/data/scripts/cable_club.inc:104 + Flags.setVar(Space.store, nil, Family.var("emerald", "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2) + end + local peered = false + for i, row in ipairs(CENTERS[version] or CENTERS.firered) do + local twoF, oneF = row[1], row[2] + local name = twoF + if not d.check(game.data.maps[twoF] ~= nil, twoF .. " exists") then return d.finish() end + G.loadMap(game, twoF, 6, 4, "up") + e.wait(30) + U.tap(game, "a") + local entered = e.drive(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 30) + d.note(name .. " map=" .. tostring(Space.mapId) .. " union=" .. tostring(Union.state)) + if not d.check(entered, name .. ": the attendant walks the player into the Union Room") then + d.shot(game, i .. "_" .. name .. "_enter_failed.png") + return d.finish() + end + d.check(e.waitFor(function() return Client.plaza() ~= nil end, 5, 60), name .. ": joined the plaza") + if not peered then + e.peer("b0000001", "RED", "red", 1, 11, 0) + e.peer("b0000002", "KRIS", "crystal", 2, 22, 1) + e.peer("b0000003", "MAY", "emerald", 3, 44, 1, "g3:4") + peered = true + end + d.check(e.waitFor(function() return Union.playerCount() == 3 end, 8, 200), name .. ": three cross-gen members appear") + e.wait(40) + e.place(12, 19, "up") + e.wait(20) + d.still(game, i .. "_" .. name .. "_room.png") + e.place(12, 23, "down") + e.wait(10) + for _ = 1, 30 do + if Map.current == twoF then break end + U.hold(game, "down", 8) + e.relay:pump() + end + local back = e.waitFor(function() return Map.current == twoF and not require("src.core.game3.warp").isBusy() end, 8, 300) + d.check(back, name .. ": the exit pad returns to " .. twoF .. " (" .. tostring(Map.current) .. ")") + G.settleText(game) + e.wait(20) + d.check(Union.state == "off", name .. ": leaving stops the Union Room") + d.still(game, i .. "_" .. name .. "_back_2f.png") + G.loadMap(game, twoF, 10, 4, "up") + d.check(G.talksOpen(game), name .. ": the direct corner attendant still talks") + G.settleText(game) + if game.data.maps[oneF] then + local nurse + for _, o in ipairs(game.data.maps[oneF].objects or {}) do + local nid = require("src.core.game3.constants").of(version):require("event_objects", "OBJ_EVENT_GFX_NURSE") + if tonumber(o.graphicsId or o.graphics) == nid then nurse = o end + end + if nurse then + G.loadMap(game, oneF, nurse.x, nurse.y + 2, "up") + d.check(G.talksOpen(game), name .. ": the 1F nurse still talks") + G.settleText(game, 2000) + end + end + end + + local twoF = (CENTERS[version] or CENTERS.firered)[1][1] + G.loadMap(game, twoF, 6, 4, "up") + e.wait(30) + U.tap(game, "a") + e.drive(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 30) + e.wait(30) + e.place(10, 12, "down") + d.check(game:saveGame() ~= false, "saving in the plaza writes the slot") + local SaveData = require("src.core.SaveData") + local Schema = require("src.core.game3.save_schema_firered") + local raw = love.filesystem.read(SaveData.saveFilename and SaveData.saveFilename() or ("saves/" .. version .. "/slot1.lua")) + local saved = raw and SaveData.decode(raw) + local cont = saved and Schema.fromSaveTable(saved) + d.note("saved map=" .. tostring(saved and saved.map) .. " continue -> " .. tostring(cont and cont.map) + .. " " .. tostring(cont and cont.x) .. "," .. tostring(cont and cont.y)) + d.check(cont and cont.map == twoF, "continue after a plaza save resolves to the PC 2F (cart continue warp)") + e.close() + return d.finish() +end diff --git a/tests/drivers/union_gen3_mixed.lua b/tests/drivers/union_gen3_mixed.lua new file mode 100644 index 00000000..869fa244 --- /dev/null +++ b/tests/drivers/union_gen3_mixed.lua @@ -0,0 +1,64 @@ +local U = require("tests.drivers.util") +local G = require("tests.drivers.union_gen3_util") + +local CENTER = { + firered = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", leafgreen = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", + emerald = "EM_OLDALE_TOWN_POKEMON_CENTER_2F", ruby = "RU_OLDALE_TOWN_POKEMON_CENTER_2F", + sapphire = "SA_OLDALE_TOWN_POKEMON_CENTER_2F", +} + +return function(game) + local version = G.version() + local tag = os.getenv("UNION_SHOT_TAG") or "mixed" + local d = G.start("union_gen3_mixed_" .. version) + local session = G.boot(d, game, 0) + if not session then return d.finish() end + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Union = require("src.core.game3.link.union_room") + local Plaza = require("src.core.game3.link.union_plaza_map") + local Family = require("src.core.game3.link.family") + local Flags = require("src.core.game3.scripting.flags") + local Warp = require("src.core.game3.warp") + local e = G.env(d, game, "ME") + if not d.check(e.connect(), "online on the fake relay") then return d.finish() end + if version == "emerald" then + -- pokeemerald/data/scripts/cable_club.inc:104 + Flags.setVar(Space.store, nil, Family.var("emerald", "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2) + end + local rs = Family.isRubySapphire(version) + G.loadMap(game, CENTER[version], rs and 1 or 6, rs and 3 or 4, "up") + e.wait(30) + U.tap(game, "a") + local entered = e.drive(function() + return Map.current == Plaza.MAP_ID and Union.state == "main" and not Warp.isBusy() + end, 40) + if not d.check(entered, "entered the Union Room") then return d.finish() end + e.peer("b0000001", "RED", "red", 1, 11, 0) + e.peer("b0000002", "KRIS", "crystal", 2, 22, 1) + e.peer("b0000003", "MAY", "emerald", 3, 44, 1, "g3:4") + d.check(e.waitFor(function() return Union.playerCount() == 3 end, 8, 200), "three members appear") + e.wait(60) + local slot + for s = 1, Plaza.CAP do + local p = Union.players[s] + if p and p.name == "RED" then slot = s end + end + local foreign = 0 + for s = 1, Plaza.CAP do + local v = Union.vobj(s) + if v and v.foreign then foreign = foreign + 1 end + end + d.note("members drawn from other games' caches: " .. foreign) + local cx, cy = Plaza.cellFor(slot or 1) + e.place(cx + 1, cy, "left") + e.wait(30) + d.still(game, version .. "_" .. tag .. "_room.png") + G.resize(240, 160) + d.still(game, version .. "_" .. tag .. "_native.png") + G.resize(844, 390) + d.still(game, version .. "_" .. tag .. "_phone.png") + G.resize(960, 640) + e.close() + return d.finish() +end diff --git a/tests/drivers/union_gen3_plaza_save.lua b/tests/drivers/union_gen3_plaza_save.lua new file mode 100644 index 00000000..2bd833a8 --- /dev/null +++ b/tests/drivers/union_gen3_plaza_save.lua @@ -0,0 +1,68 @@ +local U = require("tests.drivers.util") +local G = require("tests.drivers.union_gen3_util") + +local CENTER = { + firered = { "FR_CERULEAN_CITY_POKEMON_CENTER_2F", "FR_CERULEAN_CITY_POKEMON_CENTER_1F" }, + leafgreen = { "FR_CERULEAN_CITY_POKEMON_CENTER_2F", "FR_CERULEAN_CITY_POKEMON_CENTER_1F" }, + emerald = { "EM_LAVARIDGE_TOWN_POKEMON_CENTER_2F", "EM_LAVARIDGE_TOWN_POKEMON_CENTER_1F" }, +} + +return function(game) + local version = G.version() + local d = G.start("union_gen3_plaza_save_" .. version) + local session = G.boot(d, game, 0) + if not session then return d.finish() end + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Union = require("src.core.game3.link.union_room") + local Plaza = require("src.core.game3.link.union_plaza_map") + local Family = require("src.core.game3.link.family") + local Flags = require("src.core.game3.scripting.flags") + local Warp = require("src.core.game3.warp") + local Spot = require("src.core.game3.link.union_save_spot") + local e = G.env(d, game, "ME") + if not d.check(e.connect(), "online on the fake relay") then return d.finish() end + if version == "emerald" then + -- pokeemerald/data/scripts/cable_club.inc:104 + Flags.setVar(Space.store, nil, Family.var("emerald", "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2) + end + local twoF, oneF = CENTER[version][1], CENTER[version][2] + G.loadMap(game, twoF, 6, 4, "up") + e.wait(30) + U.tap(game, "a") + local entered = e.drive(function() + return Map.current == Plaza.MAP_ID and Union.state == "main" and not Warp.isBusy() + end, 40) + if not d.check(entered, "entered the Union Room from " .. twoF) then return d.finish() end + e.place(10, 12, "down") + e.wait(10) + local o = Spot.live(require("src.core.game3.runtime").getSession(), game) + d.check(o ~= nil and o.map == oneF, "the origin is the center's 1F (" .. tostring(o and o.map) .. ")") + d.check(game:saveGame() ~= false, "saving inside the plaza writes the slot") + local SaveData = require("src.core.SaveData") + local Schema = require("src.core.game3.save_schema_firered") + local saved = SaveData.decode(love.filesystem.read(SaveData.saveFilename())) + d.note("saved " .. tostring(saved.map) .. " " .. tostring(saved.x) .. "," .. tostring(saved.y) .. " " + .. tostring(saved.facing) .. " flags=" .. tostring(saved.specialSaveWarpFlags)) + d.check(o ~= nil and saved.map == o.map and saved.x == o.x and saved.y == o.y and saved.facing == "up", + "the slot records the player facing the origin nurse desk") + d.check(require("bit").band(tonumber(saved.specialSaveWarpFlags) or 0, 1) == 0, "no continue warp to the 2F door") + d.check(Map.current == Plaza.MAP_ID, "the live player stays in the plaza") + local bytes, err = require("src.save_convert.SaveConvert").exportSav(saved, version, nil) + d.check(bytes ~= nil, "the slot exports as a cartridge save (" .. tostring(err) .. ")") + e.close() + e.wait(10) + local cont = Schema.fromSaveTable(saved) + require("src.core.game3.options").bind(cont, game.options) + game:adoptSave(cont, true) + game:_enterField(cont, "continue") + U.wait(60) + G.settleText(game) + U.wait(30) + d.still(game, version .. "_plaza_continue_nurse_front.png") + d.check(o ~= nil and Map.current == o.map and Player.cellX == o.x and Player.cellY == o.y and Player.facing == "up", + "reload lands facing the nurse (" .. tostring(Map.current) .. " " .. tostring(Player.cellX) .. "," + .. tostring(Player.cellY) .. " " .. tostring(Player.facing) .. ")") + return d.finish() +end diff --git a/tests/drivers/union_gen3_rs_room.lua b/tests/drivers/union_gen3_rs_room.lua new file mode 100644 index 00000000..21efb4ef --- /dev/null +++ b/tests/drivers/union_gen3_rs_room.lua @@ -0,0 +1,153 @@ +local U = require("tests.drivers.util") +local G = require("tests.drivers.union_gen3_util") + +local CENTERS = { "OLDALE_TOWN", "MAUVILLE_CITY", "EVER_GRANDE_CITY", "PACIFIDLOG_TOWN" } + +return function(game) + local version = G.version() + local d = G.start("union_gen3_rs_room_" .. version) + local session = G.boot(d, game, version == "sapphire" and 1 or 0) + if not session then return d.finish() end + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Union = require("src.core.game3.link.union_room") + local Plaza = require("src.core.game3.link.union_plaza_map") + local UnionRs = require("src.core.game3.rse.union_rs") + local Screen = require("src.ui.game3.union_room") + local Client = require("src.online.Client") + local Warp = require("src.core.game3.warp") + local prefix = G.prefix() + local ROOM = prefix .. "UNION_ROOM" + d.check(Plaza.MAP_ID == ROOM, "the added room id is " .. ROOM) + d.check(game.data.maps[ROOM] ~= nil, "the RS Union Room map is built at load") + local e = G.env(d, game, "ME") + if not d.check(e.connect(), "online on the fake relay") then return d.finish() end + + local function enter(name, idx) + local twoF = prefix .. name .. "_POKEMON_CENTER_2F" + local def = game.data.maps[twoF] + if not d.check(def ~= nil and UnionRs.isCenter(twoF), name .. ": 2F has the added entrance") then return false end + d.check(def.midLayout:midAt(2, 1) == def.midLayout:midAt(5, 1), name .. ": the added door uses the bay door tile") + G.loadMap(game, twoF, 1, 3, "up") + e.wait(20) + d.check(require("src.core.game3.virtual_objects").get(UnionRs.VOBJ_ID) ~= nil, name .. ": the attendant stands by the door") + if idx == 1 then d.still(game, "rs_" .. idx .. "_" .. name .. "_2f_attendant.png") end + U.tap(game, "a") + local sawWelcome = false + local entered = e.drive(function() + if Message.isOpen() and Message.currentPage():find("UNION ROOM", 1, true) then sawWelcome = true end + return Map.current == ROOM and Union.state == "main" and not Warp.isBusy() + end, 40) + d.check(sawWelcome, name .. ": the attendant greets with the UNION ROOM text") + d.note(name .. " map=" .. tostring(Map.current) .. " union=" .. tostring(Union.state) .. " at " + .. tostring(Player.cellX) .. "," .. tostring(Player.cellY)) + if not d.check(entered, name .. ": the attendant saves and walks the player in") then + d.shot(game, "rs_" .. idx .. "_" .. name .. "_enter_failed.png") + return false + end + d.check(e.waitFor(function() return Client.plaza() ~= nil end, 5, 60), name .. ": joined the relay plaza") + local join = Client.plazaJoinInfo and Client.plazaJoinInfo("union") + d.check(join and join.avatar and join.avatar.style == "player" and join.opts and join.opts.xgen ~= nil, + name .. ": RS joins with style player over xgen") + return true, twoF + end + + local function leave(name, twoF) + e.place(12, 23, "down") + e.wait(10) + for _ = 1, 30 do + if Map.current == twoF then break end + U.hold(game, "down", 8) + e.relay:pump() + end + local back = e.waitFor(function() return Map.current == twoF and not Warp.isBusy() end, 8, 300) + d.check(back, name .. ": the exit pads return to the same 2F") + e.wait(30) + d.check(Player.cellX == UnionRs.FRONT.x and Player.cellY == UnionRs.FRONT.y, + name .. ": the player steps out of the added door (" .. tostring(Player.cellX) .. "," .. tostring(Player.cellY) .. ")") + d.check(Union.state == "off", name .. ": leaving stops the Union Room") + end + + local ok, twoF = enter(CENTERS[1], 1) + if not ok then return d.finish() end + e.peer("b0000001", "RED", "red", 1, 11, 0) + e.peer("b0000002", "KRIS", "crystal", 2, 22, 1) + e.peer("b0000003", "MAY", "emerald", 3, 44, 1, "g3:4") + d.check(e.waitFor(function() return Union.playerCount() == 3 end, 8, 200), "three cross-gen members appear in the RS room") + e.wait(40) + e.place(12, 19, "up") + e.wait(20) + d.still(game, "rs_room_mixed.png") + G.resize(240, 160) + d.still(game, "rs_room_mixed_native.png") + G.resize(844, 390) + d.still(game, "rs_room_mixed_phone.png") + G.resize(960, 640) + local slot + for s = 1, Plaza.CAP do + local p = Union.players[s] + if p and p.name == "RED" then slot = s end + end + local cx, cy = Plaza.cellFor(slot or 1) + e.place(cx, cy + 1, "up") + e.wait(10) + U.tap(game, "a") + local menu = false + for _ = 1, 400 do + if Screen.isOpen() then menu = true break end + if Message.isOpen() and Message.isWaiting() and not Message._stay then U.tap(game, "a") end + e.wait(1) + end + d.check(menu, "talking to a Gen 1 member opens BATTLE / TRADE") + d.still(game, "rs_room_xg_menu.png") + U.tap(game, "b") + G.settleText(game) + e.wait(10) + leave(CENTERS[1], twoF) + G.loadMap(game, twoF, 4, 4, "up") + d.check(G.talksOpen(game), "the Colosseum attendant still talks") + G.settleText(game) + local oneF = UnionRs.centers[twoF].oneF + local origin = UnionRs.originFor(twoF) + d.check(origin ~= nil, "the origin nurse front resolves") + G.loadMap(game, oneF, origin.x, origin.y, "up") + d.check(G.talksOpen(game), "the 1F nurse still talks") + G.settleText(game, 2000) + + for i = 2, #CENTERS do + local okI, tf = enter(CENTERS[i], i) + if okI then leave(CENTERS[i], tf) end + end + + local okS, saveCenter = enter(CENTERS[3], 9) + if okS then + e.place(10, 12, "down") + e.wait(10) + local o = UnionRs.originFor(saveCenter) + d.check(game:saveGame() ~= false, "saving inside the added room writes the slot") + local SaveData = require("src.core.SaveData") + local Schema = require("src.core.game3.save_schema_firered") + local saved = SaveData.decode(love.filesystem.read(SaveData.saveFilename())) + d.note("saved " .. tostring(saved.map) .. " " .. tostring(saved.x) .. "," .. tostring(saved.y) .. " flags=" + .. tostring(saved.specialSaveWarpFlags)) + d.check(saved.map == o.map and saved.x == o.x and saved.y == o.y, + "the slot records the player in front of the origin nurse desk") + e.close() + e.wait(10) + local cont = Schema.fromSaveTable(saved) + require("src.core.game3.options").bind(cont, game.options) + game:adoptSave(cont, true) + game:_enterField(cont, "continue") + U.wait(60) + G.settleText(game) + U.wait(30) + d.still(game, "rs_continue_nurse_front.png") + d.check(Map.current == o.map and Player.cellX == o.x and Player.cellY == o.y and Player.facing == "up", + "reload lands facing the nurse (" .. tostring(Map.current) .. " " .. tostring(Player.cellX) .. "," + .. tostring(Player.cellY) .. " " .. tostring(Player.facing) .. ")") + end + e.close() + return d.finish() +end diff --git a/tests/drivers/union_gen3_util.lua b/tests/drivers/union_gen3_util.lua new file mode 100644 index 00000000..04636fec --- /dev/null +++ b/tests/drivers/union_gen3_util.lua @@ -0,0 +1,168 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local G = {} + +local function now() return love.timer.getTime() end + +function G.version() + return os.getenv("POKEPORT_VERSION") or "firered" +end + +function G.prefix() + local v = G.version() + if v == "emerald" then return "EM_" end + if v == "ruby" then return "RU_" end + if v == "sapphire" then return "SA_" end + return "FR_" +end + +function G.start(name) + local d = X.new(name, "/tmp/" .. name) + return d +end + +function G.boot(d, game, gender) + local session = X.newGame(d, game, gender or 0) + if not session then return nil end + X.settle(game) + U.wait(30) + local Party = require("src.core.game3.party") + local Runtime = require("src.core.game3.runtime") + session = Runtime.getSession() + local species = G.version() == "ruby" or G.version() == "sapphire" or G.version() == "emerald" + while #(session.party or {}) < 2 do Party.giveMon(session, species and 280 or 25, 12) end + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local flags = require("src.ui.game3.screens").flags(session).IDS + if flags.SYS_POKEDEX_GET then Flags.setFlag(Space.store, nil, flags.SYS_POKEDEX_GET, true) end + if flags.SYS_POKEMON_GET then Flags.setFlag(Space.store, nil, flags.SYS_POKEMON_GET, true) end + return session +end + +function G.env(d, game, myName) + local Client = require("src.online.Client") + local Relay = require("tests.support.fake_relay") + local Link = require("src.core.game3.link") + 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 e = {} + e.relay = Relay.new({ clock = now }) + e.me = e.relay:seat("a0000001", myName or "ME") + Client.configure({ relayAddress = "fake:1", connect = function() return e.me.transport end }) + function e.wait(n) + for _ = 1, n do + e.relay:pump() + U.wait(1) + end + end + function e.waitFor(cond, seconds, frames) + local t0, n = now(), 0 + while not cond() do + e.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 + function e.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 + e.wait(6) + end + return cond() + end + function e.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 + function e.connect() + Link.connect() + return e.waitFor(function() return Client.state() == "online" end, 5, 120) + end + function e.live() return Link.liveProfile() end + function e.peer(id, name, version, gen, trainerId, gender, style) + local live = Link.liveProfile() + local s = e.relay:seat(id, name) + local profile = { engine = gen, version = version, engineVersion = live.engineVersion, + apiVersion = live.apiVersion, fingerprint = live.fingerprint, rulesetId = gen == 3 and live.rulesetId or "union", + kind = "vanilla" } + if gen == 3 then + profile = {} + for k, v in pairs(live) do profile[k] = v end + profile.version = version + end + e.relay:handle(s, { type = "lobby_hello", protocol = 3, name = name, profiles = { profile }, xgen = 1, + presence = { where = "union", status = "idle", version = version } }) + s.avatar = { name = name, trainerId = trainerId or 7, gender = gender or 0, version = version, + style = style or "player" } + e.relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, xgen = 1, profile = profile, avatar = s.avatar }) + return s + end + function e.walk(dir, axis, target) + for _ = 1, 600 do + local v = axis == "x" and Player.cellX or Player.cellY + if v == target and not Player.moving then return true end + if Player.moving then e.wait(1) else U.hold(game, dir, 1) end + end + return false + end + function e.close() + pcall(Link.reset) + pcall(Client.disconnect) + end + return e +end + +function G.loadMap(game, mapId, x, y, facing) + local Map = require("src.core.game3.map") + X.settle(game) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing }) + local Player = require("src.core.game3.player") + local s = require("src.core.game3.runtime").getSession() + if s then s.x, s.y, s.facing = x, y, facing end + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + U.wait(40) +end + +function G.settleText(game, frames) + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local Space = require("src.core.game3.scripting.space") + for _ = 1, frames or 900 do + local busy = Message.isOpen() or Choice.active or (Space.vm and Space.vm:isRunning()) + if not busy then return true end + U.tap(game, "a") + U.wait(4) + end + return false +end + +function G.talksOpen(game, frames) + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + U.tap(game, "a") + for _ = 1, frames or 120 do + if Message.isOpen() or Choice.active then return true end + U.wait(1) + end + return false +end + +function G.resize(w, h) + love.window.setMode(w, h, { resizable = true }) + if love.resize then love.resize(w, h) end + U.wait(8) +end + +return G diff --git a/tests/drivers/union_prep_battle_gen1.lua b/tests/drivers/union_prep_battle_gen1.lua new file mode 100644 index 00000000..fe466c74 --- /dev/null +++ b/tests/drivers/union_prep_battle_gen1.lua @@ -0,0 +1,28 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_prep_driver") +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local GameVersion = require("src.core.GameVersion") + +return function(game) + U.wait(30) + local save = game.save + local pika = Pokemon.new(game.data, "PIKACHU", 25) + pika.moves[4] = { id = "SURF", pp = 15 } + save.party = { pika, Pokemon.new(game.data, "BULBASAUR", 12), Pokemon.new(game.data, "RATTATA", 10) } + Boxes.ensure(save)[1] = { Pokemon.new(game.data, "CHARMANDER", 8) } + return D.run(game, { + version = GameVersion.get(), + snapshot = function() return { party = save.party, boxes = save.boxes } end, + rules = { ruleset = "g3u", dexMax = 151, moveMax = 165, moveGen = 1, gens = { 1, 2 } }, + opponent = { name = "KRIS", version = "gold", gen = 2 }, + rentalCount = 15, + problems = { "can't learn SURF" }, + move = nil, + pickMove = true, + peerSize = 2, + sitOut = "RATTATA", + expectRental = false, + closed = function(screen) return game.stack:top() ~= screen end, + }) +end diff --git a/tests/drivers/union_prep_battle_gen2.lua b/tests/drivers/union_prep_battle_gen2.lua new file mode 100644 index 00000000..03aaa8f4 --- /dev/null +++ b/tests/drivers/union_prep_battle_gen2.lua @@ -0,0 +1,28 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_prep_driver") +local Mon = require("src.battle.gen2.Mon") +local GameVersion = require("src.core.GameVersion") + +return function(game) + U.wait(60) + local save = game.save + local pika = Mon.new(game.data, "PIKACHU", 20) + pika.moves[#pika.moves] = { id = "CRUNCH", pp = 15, maxPp = 15 } + save.party = { Mon.new(game.data, "CHIKORITA", 15), pika, Mon.new(game.data, "GEODUDE", 12) } + save.boxes = save.boxes or {} + save.boxes[1] = { Mon.new(game.data, "BULBASAUR", 14) } + return D.run(game, { + version = GameVersion.get(), + snapshot = function() return { party = save.party, boxes = save.boxes } end, + rules = { ruleset = "g3u", dexMax = 151, moveMax = 165, moveGen = 1, gens = { 1, 2 } }, + opponent = { name = "RED", version = "red", gen = 1 }, + rentalCount = 15, + problems = { "CHIKORITA can't join", "CRUNCH isn't used" }, + substitute = { owned = "BULBASAUR", id = "swap_rental", label = "VENUSAUR" }, + pickMove = true, + peerSize = 2, + sitOut = "GEODUDE", + expectRental = true, + closed = function(screen) return game.stack:top() ~= screen end, + }) +end diff --git a/tests/drivers/union_prep_battle_gen3.lua b/tests/drivers/union_prep_battle_gen3.lua new file mode 100644 index 00000000..8a50b6f7 --- /dev/null +++ b/tests/drivers/union_prep_battle_gen3.lua @@ -0,0 +1,50 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_prep_driver") +local GameVersion = require("src.core.GameVersion") + +return function(game) + io.stdout:setvbuf("line") + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "MAY", gender = 1 }) + U.wait(240) + local version = GameVersion.get() + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local Storage = require("src.core.game3.storage") + local Stack = require("src.ui.game3.stack") + local C = require("src.core.game3.constants").of(version) + local session = Runtime.getSession() + if not session then + print("FAIL " .. version .. " field session exists") + love.event.quit(1) + return + end + local S = C.species.byName + session.party = {} + Party.giveMon(session, S.SPECIES_TREECKO, 10, "") + Party.giveMon(session, S.SPECIES_PIKACHU, 20, "") + Party.giveMon(session, S.SPECIES_BULBASAUR, 12, "") + Party.giveMon(session, S.SPECIES_IVYSAUR, 15, "") + local ivy = table.remove(session.party) + Storage.ensure(session) + session.storage.boxes[1].mons[1] = ivy + local pika = session.party[2] + pika.moves[4], pika.pp[4], pika.maxPp[4] = 57, 15, 15 + return D.run(game, { + version = version, + snapshot = function() return { party = session.party, storage = session.storage, overlay = session.move_overlay } end, + rules = { ruleset = "g3u", dexMax = 151, moveMax = 165, moveGen = 1, gens = { 1, 3 } }, + opponent = { name = "RED", version = "red", gen = 1 }, + rentalCount = 15, + problems = { "TREECKO can't join", "can't learn SURF" }, + substitute = { owned = "IVYSAUR", id = "swap_rental", label = "VENUSAUR" }, + pickMove = true, + peerSize = 2, + sitOut = "BULBASAUR", + expectRental = true, + closed = function() return not Stack.has("union_battle_prep") end, + }) +end diff --git a/tests/drivers/union_trade_firered_gold.lua b/tests/drivers/union_trade_firered_gold.lua new file mode 100644 index 00000000..0a5a3d9e --- /dev/null +++ b/tests/drivers/union_trade_firered_gold.lua @@ -0,0 +1,33 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_trade_driver") +local GameVersion = require("src.core.GameVersion") + +return function(game) + io.stdout:setvbuf("line") + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "LEAF", gender = 1 }) + U.wait(240) + local version = GameVersion.get() + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local C = require("src.core.game3.constants").of(version) + local session = Runtime.getSession() + if not session then + print("FAIL " .. version .. " field session exists") + love.event.quit(1) + return + end + local S = C.species.byName + session.party = {} + Party.giveMon(session, S.SPECIES_MACHOKE, 30, "") + Party.giveMon(session, S.SPECIES_PIDGEY, 5, "") + return D.run(game, { + peerVersion = "gold", + peerGame = function() return D.peerGame("gold", 152, 10, { 33, 45 }) end, + mine = function() return session.party[1] and session.party[1].species end, + expectMoveFix = true, + }) +end diff --git a/tests/drivers/union_trade_gold_red.lua b/tests/drivers/union_trade_gold_red.lua new file mode 100644 index 00000000..726c712c --- /dev/null +++ b/tests/drivers/union_trade_gold_red.lua @@ -0,0 +1,20 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_trade_driver") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(60) + local save = game.save + local bulba = Mon.new(game.data, "BULBASAUR", 14) + bulba.moves[#bulba.moves + 1] = { id = "SWEET_SCENT", pp = 20, maxPp = 20 } + while #bulba.moves > 4 do table.remove(bulba.moves, 1) end + save.party = { bulba, Mon.new(game.data, "SENTRET", 6) } + for _, mon in ipairs(save.party) do Mon.stampOT(save, mon) end + return D.run(game, { + peerVersion = "red", + peerGame = function() return D.peerGame("red", 7, 12, { 33, 39 }) end, + mine = function() return save.party[1] and save.party[1].species end, + expectReceived = "SQUIRTLE", + expectMoveFix = true, + }) +end diff --git a/tests/drivers/union_trade_red_firered.lua b/tests/drivers/union_trade_red_firered.lua new file mode 100644 index 00000000..93444d9d --- /dev/null +++ b/tests/drivers/union_trade_red_firered.lua @@ -0,0 +1,24 @@ +local U = require("tests.drivers.util") +local D = require("tests.support.union_trade_driver") +local Pokemon = require("src.pokemon.Pokemon") + +return function(game) + U.wait(30) + if not D.installGen3("firered") then + print("FAIL red<->firered needs an imported FireRed identity") + love.event.quit(1) + return + end + U.teleport(game, "VIRIDIAN_POKECENTER", 4, 4, "down") + U.wait(30) + local save = game.save + save.party = { Pokemon.new(game.data, "PIKACHU", 25), Pokemon.new(game.data, "BULBASAUR", 12) } + local stamp = require("src.battle.BattleState").stampOT + for _, mon in ipairs(save.party) do stamp(save, mon) end + return D.run(game, { + peerVersion = "firered", + peerGame = function() return D.peerGame("firered", 4, 10, { 10, 45 }) end, + mine = function() return save.party[1] and save.party[1].species end, + expectReceived = "CHARMANDER", + }) +end diff --git a/tests/drivers/union_w9_yellow_desk.lua b/tests/drivers/union_w9_yellow_desk.lua new file mode 100644 index 00000000..267f9299 --- /dev/null +++ b/tests/drivers/union_w9_yellow_desk.lua @@ -0,0 +1,94 @@ +local U = require("tests.drivers.util") +local Pokemon = require("src.pokemon.Pokemon") +local GameVersion = require("src.core.GameVersion") +local UnionCenters = require("src.world.gen1.UnionCenters") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. line) + end + local function ow() return game.overworld end + local function idle() + local o = ow() + return o and game.stack:top() == o and not o.transitioning + and not o.runner:isRunning() and #o.scriptMoves == 0 + and not (o.pendingScripts and o.pendingScripts[1]) + end + local function waitFor(cond, frames) + for _ = 1, frames do + if cond() then return true end + U.wait(1) + end + return cond() + end + local function flat(t, out) + out = out or {} + if type(t) == "string" then out[#out + 1] = t + elseif type(t) == "table" then for _, v in ipairs(t) do flat(v, out) end end + return out + end + local function boxText() + local top = game.stack:top() + if not (top and top.pages) then return nil end + return table.concat(flat(top.pages), " ") + end + local function closeBoxes() + for _ = 1, 40 do + if idle() then return true end + U.tap(game, "a") + U.wait(6) + end + return idle() + end + + ok(GameVersion.isYellow(), "running as Yellow") + U.wait(10) + local f = game.save.flags + f.EVENT_GOT_STARTER = true + f.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + f.EVENT_GOT_POKEDEX = true + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 } + + U.teleport(game, "VIRIDIAN_POKECENTER", 4, 3, "up") + waitFor(idle, 120) + U.tap(game, "a") + local seen = waitFor(function() + local t = boxText() + return t and t:find("CHANSEY", 1, true) + end, 240) + U.wait(40) + U.still(game, SHOT_DIR .. "/w9_01_viridian_chansey_talk.png") + ok(seen, "Viridian Chansey answers across the counter: " .. tostring(boxText())) + ok(closeBoxes(), "Chansey box closes") + + U.teleport(game, "PEWTER_POKECENTER", 11, 5, "up") + waitFor(idle, 120) + ow().pikachuPewterSleepScene = true + game.save.lastOutdoor = { id = "PEWTER_CITY", x = 13, y = 26 } + local plan = UnionCenters.planFor(game.data, "PEWTER_POKECENTER") + ok(plan ~= nil, "Pewter 1F has the union stairs") + if plan then + U.teleport(game, "PEWTER_POKECENTER", plan.stairs.x - 1, plan.stairs.y, "right") + waitFor(idle, 120) + ow().pikachuPewterSleepScene = true + U.hold(game, "right", 20) + ok(waitFor(function() return ow().map.id == UnionCenters.FLOOR_2F and idle() end, 900), "took the stairs to 2F") + end + ok(ow().pikachuPewterSleepScene == nil, "the map load ended the Pewter sleep scene") + ow():cableClubReceptionist(function() end) + local text + waitFor(function() text = boxText() return text ~= nil end, 30) + U.wait(90) + U.still(game, SHOT_DIR .. "/w9_02_2f_cable_club_apply.png") + ok(text and not text:find("content", 1, true), "2F cable club desk does not say looks content: " .. tostring(text)) + ok(game.stack:top() and game.stack:top().choice ~= nil, "2F cable club desk asks to apply") + + print(("W9 YELLOW DESK %s (%d failures)"):format(fails == 0 and "PASS" or "FAIL", fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/engine/_g3u_fixture.lua b/tests/engine/_g3u_fixture.lua new file mode 100644 index 00000000..9b08c965 --- /dev/null +++ b/tests/engine/_g3u_fixture.lua @@ -0,0 +1,296 @@ +local F = {} + +local GEN1_MOVES = { + { 1, "NO_ADDITIONAL_EFFECT", "NORMAL", 40, 100, 35 }, + { 2, "NO_ADDITIONAL_EFFECT", "FIGHTING", 50, 100, 25 }, + { 3, "TWO_TO_FIVE_ATTACKS_EFFECT", "NORMAL", 15, 85, 10 }, + { 4, "FREEZE_SIDE_EFFECT2", "ICE", 70, 90, 10 }, + { 5, "ATTACK_DOWN2_EFFECT", "NORMAL", 0, 100, 20 }, + { 6, "PAY_DAY_EFFECT", "NORMAL", 40, 100, 20 }, + { 7, "BURN_SIDE_EFFECT1", "FIRE", 75, 100, 15 }, + { 8, "FREEZE_SIDE_EFFECT1", "ICE", 75, 100, 15 }, + { 9, "SPEED_DOWN2_EFFECT", "BUG", 0, 85, 20 }, + { 10, "SPECIAL_DOWN2_EFFECT", "NORMAL", 0, 100, 20 }, + { 13, "CHARGE_EFFECT", "NORMAL", 80, 75, 10 }, + { 14, "ATTACK_UP2_EFFECT", "NORMAL", 0, 100, 30 }, + { 18, "SWITCH_AND_TELEPORT_EFFECT", "NORMAL", 0, 85, 20 }, + { 19, "FLY_EFFECT", "FLYING", 70, 95, 15 }, + { 20, "TRAPPING_EFFECT", "NORMAL", 15, 75, 20 }, + { 23, "FLINCH_SIDE_EFFECT2", "NORMAL", 65, 100, 20 }, + { 24, "ATTACK_TWICE_EFFECT", "FIGHTING", 30, 100, 30 }, + { 26, "JUMP_KICK_EFFECT", "FIGHTING", 70, 95, 25 }, + { 28, "ACCURACY_DOWN1_EFFECT", "NORMAL", 0, 100, 15 }, + { 32, "OHKO_EFFECT", "NORMAL", 1, 30, 5 }, + { 33, "NO_ADDITIONAL_EFFECT", "NORMAL", 35, 95, 35 }, + { 34, "PARALYZE_SIDE_EFFECT2", "NORMAL", 85, 100, 15 }, + { 36, "RECOIL_EFFECT", "NORMAL", 90, 85, 20 }, + { 37, "THRASH_PETAL_DANCE_EFFECT", "NORMAL", 90, 100, 20 }, + { 39, "DEFENSE_DOWN1_EFFECT", "NORMAL", 0, 100, 30 }, + { 40, "POISON_SIDE_EFFECT1", "POISON", 15, 100, 35 }, + { 41, "TWINEEDLE_EFFECT", "BUG", 25, 100, 20 }, + { 44, "FLINCH_SIDE_EFFECT1", "NORMAL", 60, 100, 25 }, + { 45, "ATTACK_DOWN1_EFFECT", "NORMAL", 0, 100, 40 }, + { 48, "CONFUSION_EFFECT", "NORMAL", 0, 55, 20 }, + { 49, "SPECIAL_DAMAGE_EFFECT", "NORMAL", 1, 90, 20 }, + { 50, "DISABLE_EFFECT", "NORMAL", 0, 55, 20 }, + { 51, "DEFENSE_DOWN_SIDE_EFFECT", "POISON", 40, 100, 30 }, + { 53, "BURN_SIDE_EFFECT1", "FIRE", 95, 100, 15 }, + { 54, "MIST_EFFECT", "ICE", 0, 100, 30 }, + { 55, "NO_ADDITIONAL_EFFECT", "WATER", 40, 100, 25 }, + { 58, "FREEZE_SIDE_EFFECT1", "ICE", 95, 100, 10 }, + { 60, "CONFUSION_SIDE_EFFECT", "PSYCHIC_TYPE", 65, 100, 20 }, + { 61, "SPEED_DOWN_SIDE_EFFECT", "WATER", 65, 100, 20 }, + { 62, "ATTACK_DOWN_SIDE_EFFECT", "ICE", 65, 100, 20 }, + { 63, "HYPER_BEAM_EFFECT", "NORMAL", 150, 90, 5 }, + { 68, "NO_ADDITIONAL_EFFECT", "FIGHTING", 1, 100, 20 }, + { 69, "SPECIAL_DAMAGE_EFFECT", "FIGHTING", 1, 100, 20 }, + { 71, "DRAIN_HP_EFFECT", "GRASS", 20, 100, 20 }, + { 73, "LEECH_SEED_EFFECT", "GRASS", 0, 90, 10 }, + { 74, "SPECIAL_UP1_EFFECT", "NORMAL", 0, 100, 40 }, + { 76, "CHARGE_EFFECT", "GRASS", 120, 100, 10 }, + { 77, "POISON_EFFECT", "POISON", 0, 75, 35 }, + { 78, "PARALYZE_EFFECT", "GRASS", 0, 75, 30 }, + { 79, "SLEEP_EFFECT", "GRASS", 0, 75, 15 }, + { 81, "SPEED_DOWN1_EFFECT", "BUG", 0, 95, 40 }, + { 82, "SPECIAL_DAMAGE_EFFECT", "DRAGON", 1, 100, 10 }, + { 84, "PARALYZE_SIDE_EFFECT1", "ELECTRIC", 40, 100, 30 }, + { 85, "PARALYZE_SIDE_EFFECT1", "ELECTRIC", 95, 100, 15 }, + { 86, "PARALYZE_EFFECT", "ELECTRIC", 0, 100, 20 }, + { 89, "NO_ADDITIONAL_EFFECT", "GROUND", 100, 100, 10 }, + { 91, "CHARGE_EFFECT", "GROUND", 100, 100, 10 }, + { 92, "POISON_EFFECT", "POISON", 0, 85, 10 }, + { 93, "CONFUSION_SIDE_EFFECT", "PSYCHIC_TYPE", 50, 100, 25 }, + { 94, "SPECIAL_DOWN_SIDE_EFFECT", "PSYCHIC_TYPE", 90, 100, 10 }, + { 97, "SPEED_UP2_EFFECT", "PSYCHIC_TYPE", 0, 100, 30 }, + { 98, "NO_ADDITIONAL_EFFECT", "NORMAL", 40, 100, 30 }, + { 99, "RAGE_EFFECT", "NORMAL", 20, 100, 20 }, + { 100, "SWITCH_AND_TELEPORT_EFFECT", "PSYCHIC_TYPE", 0, 100, 20 }, + { 101, "SPECIAL_DAMAGE_EFFECT", "GHOST", 0, 100, 15 }, + { 102, "MIMIC_EFFECT", "NORMAL", 0, 100, 10 }, + { 103, "DEFENSE_DOWN2_EFFECT", "NORMAL", 0, 85, 40 }, + { 104, "EVASION_UP1_EFFECT", "NORMAL", 0, 100, 15 }, + { 105, "HEAL_EFFECT", "NORMAL", 0, 100, 20 }, + { 106, "DEFENSE_UP1_EFFECT", "NORMAL", 0, 100, 30 }, + { 112, "DEFENSE_UP2_EFFECT", "PSYCHIC_TYPE", 0, 100, 30 }, + { 113, "LIGHT_SCREEN_EFFECT", "PSYCHIC_TYPE", 0, 100, 30 }, + { 114, "HAZE_EFFECT", "ICE", 0, 100, 30 }, + { 115, "REFLECT_EFFECT", "PSYCHIC_TYPE", 0, 100, 20 }, + { 116, "FOCUS_ENERGY_EFFECT", "NORMAL", 0, 100, 30 }, + { 117, "BIDE_EFFECT", "NORMAL", 0, 100, 10 }, + { 118, "METRONOME_EFFECT", "NORMAL", 0, 100, 10 }, + { 119, "MIRROR_MOVE_EFFECT", "FLYING", 0, 100, 20 }, + { 120, "EXPLODE_EFFECT", "NORMAL", 130, 100, 5 }, + { 123, "POISON_SIDE_EFFECT2", "POISON", 20, 70, 20 }, + { 126, "BURN_SIDE_EFFECT2", "FIRE", 120, 85, 5 }, + { 129, "SWIFT_EFFECT", "NORMAL", 60, 100, 20 }, + { 133, "SPECIAL_UP2_EFFECT", "PSYCHIC_TYPE", 0, 100, 20 }, + { 138, "DREAM_EATER_EFFECT", "PSYCHIC_TYPE", 100, 100, 15 }, + { 143, "CHARGE_EFFECT", "FLYING", 140, 90, 5 }, + { 144, "TRANSFORM_EFFECT", "NORMAL", 0, 100, 10 }, + { 150, "SPLASH_EFFECT", "NORMAL", 0, 100, 40 }, + { 156, "HEAL_EFFECT", "PSYCHIC_TYPE", 0, 100, 10 }, + { 160, "CONVERSION_EFFECT", "NORMAL", 0, 100, 30 }, + { 162, "SUPER_FANG_EFFECT", "NORMAL", 1, 90, 10 }, + { 163, "NO_ADDITIONAL_EFFECT", "NORMAL", 70, 100, 20 }, + { 164, "SUBSTITUTE_EFFECT", "NORMAL", 0, 100, 10 }, + { 165, "RECOIL_EFFECT", "NORMAL", 50, 100, 10 }, +} + +local GEN1_SPECIES = { + [6] = { "FIRE", "FLYING" }, [25] = { "ELECTRIC" }, [81] = { "ELECTRIC" }, [94] = { "GHOST", "POISON" }, + [95] = { "ROCK", "GROUND" }, [143] = { "NORMAL" }, [150] = { "PSYCHIC" }, +} + +local GEN2_MOVES = { + { 1, "EFFECT_NORMAL_HIT", "NORMAL", 40, 0 }, { 2, "EFFECT_NORMAL_HIT", "FIGHTING", 50, 0 }, + { 3, "EFFECT_MULTI_HIT", "NORMAL", 15, 0 }, { 6, "EFFECT_PAY_DAY", "NORMAL", 40, 0 }, + { 7, "EFFECT_BURN_HIT", "FIRE", 75, 10 }, { 8, "EFFECT_FREEZE_HIT", "ICE", 75, 10 }, + { 13, "EFFECT_RAZOR_WIND", "NORMAL", 80, 0 }, { 14, "EFFECT_ATTACK_UP_2", "NORMAL", 0, 0 }, + { 16, "EFFECT_GUST", "FLYING", 40, 0 }, { 18, "EFFECT_FORCE_SWITCH", "NORMAL", 0, 0 }, + { 19, "EFFECT_FLY", "FLYING", 70, 0 }, { 20, "EFFECT_TRAP_TARGET", "NORMAL", 15, 0 }, + { 23, "EFFECT_STOMP", "NORMAL", 65, 30 }, { 24, "EFFECT_DOUBLE_HIT", "FIGHTING", 30, 0 }, + { 26, "EFFECT_JUMP_KICK", "FIGHTING", 70, 0 }, { 28, "EFFECT_ACCURACY_DOWN", "GROUND", 0, 0 }, + { 32, "EFFECT_OHKO", "NORMAL", 1, 0 }, { 34, "EFFECT_PARALYZE_HIT", "NORMAL", 85, 30 }, + { 36, "EFFECT_RECOIL_HIT", "NORMAL", 90, 0 }, { 37, "EFFECT_RAMPAGE", "NORMAL", 90, 0 }, + { 39, "EFFECT_DEFENSE_DOWN", "NORMAL", 0, 0 }, { 40, "EFFECT_POISON_HIT", "POISON", 15, 30 }, + { 41, "EFFECT_POISON_MULTI_HIT", "BUG", 25, 20 }, { 44, "EFFECT_FLINCH_HIT", "DARK", 60, 30 }, + { 45, "EFFECT_ATTACK_DOWN", "NORMAL", 0, 0 }, { 48, "EFFECT_CONFUSE", "NORMAL", 0, 0 }, + { 49, "EFFECT_STATIC_DAMAGE", "NORMAL", 20, 0 }, { 50, "EFFECT_DISABLE", "NORMAL", 0, 0 }, + { 51, "EFFECT_DEFENSE_DOWN_HIT", "POISON", 40, 10 }, { 54, "EFFECT_MIST", "ICE", 0, 0 }, + { 60, "EFFECT_CONFUSE_HIT", "PSYCHIC_TYPE", 65, 10 }, { 61, "EFFECT_SPEED_DOWN_HIT", "WATER", 65, 10 }, + { 62, "EFFECT_ATTACK_DOWN_HIT", "ICE", 65, 10 }, { 63, "EFFECT_HYPER_BEAM", "NORMAL", 150, 0 }, + { 68, "EFFECT_COUNTER", "FIGHTING", 1, 0 }, { 69, "EFFECT_LEVEL_DAMAGE", "FIGHTING", 1, 0 }, + { 71, "EFFECT_LEECH_HIT", "GRASS", 20, 0 }, { 73, "EFFECT_LEECH_SEED", "GRASS", 0, 0 }, + { 74, "EFFECT_SP_ATK_UP", "NORMAL", 0, 0 }, { 76, "EFFECT_SOLARBEAM", "GRASS", 120, 0 }, + { 77, "EFFECT_POISON", "POISON", 0, 0 }, { 78, "EFFECT_PARALYZE", "GRASS", 0, 0 }, + { 79, "EFFECT_SLEEP", "GRASS", 0, 0 }, { 81, "EFFECT_SPEED_DOWN", "BUG", 0, 0 }, + { 82, "EFFECT_STATIC_DAMAGE", "DRAGON", 40, 0 }, { 87, "EFFECT_THUNDER", "ELECTRIC", 120, 30 }, + { 89, "EFFECT_EARTHQUAKE", "GROUND", 100, 0 }, { 91, "EFFECT_FLY", "GROUND", 60, 0 }, + { 92, "EFFECT_TOXIC", "POISON", 0, 0 }, { 94, "EFFECT_SP_DEF_DOWN_HIT", "PSYCHIC_TYPE", 90, 10 }, + { 97, "EFFECT_SPEED_UP_2", "PSYCHIC_TYPE", 0, 0 }, { 98, "EFFECT_PRIORITY_HIT", "NORMAL", 40, 0 }, + { 99, "EFFECT_RAGE", "NORMAL", 20, 0 }, { 100, "EFFECT_TELEPORT", "PSYCHIC_TYPE", 0, 0 }, + { 102, "EFFECT_MIMIC", "NORMAL", 0, 0 }, { 103, "EFFECT_DEFENSE_DOWN_2", "NORMAL", 0, 0 }, + { 104, "EFFECT_EVASION_UP", "NORMAL", 0, 0 }, { 105, "EFFECT_HEAL", "NORMAL", 0, 0 }, + { 106, "EFFECT_DEFENSE_UP", "NORMAL", 0, 0 }, { 107, "EFFECT_EVASION_UP", "NORMAL", 0, 0 }, + { 111, "EFFECT_DEFENSE_CURL", "NORMAL", 0, 0 }, { 112, "EFFECT_DEFENSE_UP_2", "PSYCHIC_TYPE", 0, 0 }, + { 113, "EFFECT_LIGHT_SCREEN", "PSYCHIC_TYPE", 0, 0 }, { 114, "EFFECT_RESET_STATS", "ICE", 0, 0 }, + { 115, "EFFECT_REFLECT", "PSYCHIC_TYPE", 0, 0 }, { 116, "EFFECT_FOCUS_ENERGY", "NORMAL", 0, 0 }, + { 117, "EFFECT_BIDE", "NORMAL", 0, 0 }, { 118, "EFFECT_METRONOME", "NORMAL", 0, 0 }, + { 119, "EFFECT_MIRROR_MOVE", "FLYING", 0, 0 }, { 120, "EFFECT_SELFDESTRUCT", "NORMAL", 200, 0 }, + { 129, "EFFECT_ALWAYS_HIT", "NORMAL", 60, 0 }, { 130, "EFFECT_SKULL_BASH", "NORMAL", 100, 0 }, + { 133, "EFFECT_SP_DEF_UP_2", "PSYCHIC_TYPE", 0, 0 }, { 138, "EFFECT_DREAM_EATER", "PSYCHIC_TYPE", 100, 0 }, + { 143, "EFFECT_SKY_ATTACK", "FLYING", 140, 0 }, { 144, "EFFECT_TRANSFORM", "NORMAL", 0, 0 }, + { 150, "EFFECT_SPLASH", "NORMAL", 0, 0 }, { 156, "EFFECT_HEAL", "PSYCHIC_TYPE", 0, 0 }, + { 160, "EFFECT_CONVERSION", "NORMAL", 0, 0 }, { 161, "EFFECT_TRI_ATTACK", "NORMAL", 80, 20 }, + { 162, "EFFECT_SUPER_FANG", "NORMAL", 1, 0 }, { 164, "EFFECT_SUBSTITUTE", "NORMAL", 0, 0 }, + { 165, "EFFECT_RECOIL_HIT", "NORMAL", 50, 0 }, { 166, "EFFECT_SKETCH", "NORMAL", 0, 0 }, + { 167, "EFFECT_TRIPLE_KICK", "FIGHTING", 10, 0 }, { 168, "EFFECT_THIEF", "DARK", 40, 100 }, + { 169, "EFFECT_MEAN_LOOK", "BUG", 0, 0 }, { 170, "EFFECT_LOCK_ON", "NORMAL", 0, 0 }, + { 171, "EFFECT_NIGHTMARE", "GHOST", 0, 0 }, { 172, "EFFECT_FLAME_WHEEL", "FIRE", 60, 10 }, + { 173, "EFFECT_SNORE", "NORMAL", 40, 30 }, { 174, "EFFECT_CURSE", "CURSE_TYPE", 0, 0 }, + { 175, "EFFECT_REVERSAL", "NORMAL", 1, 0 }, { 176, "EFFECT_CONVERSION2", "NORMAL", 0, 0 }, + { 178, "EFFECT_SPEED_DOWN_2", "GRASS", 0, 0 }, { 180, "EFFECT_SPITE", "GHOST", 0, 0 }, + { 182, "EFFECT_PROTECT", "NORMAL", 0, 0 }, { 187, "EFFECT_BELLY_DRUM", "NORMAL", 0, 0 }, + { 189, "EFFECT_ACCURACY_DOWN_HIT", "GROUND", 20, 100 }, { 191, "EFFECT_SPIKES", "GROUND", 0, 0 }, + { 193, "EFFECT_FORESIGHT", "NORMAL", 0, 0 }, { 194, "EFFECT_DESTINY_BOND", "GHOST", 0, 0 }, + { 195, "EFFECT_PERISH_SONG", "NORMAL", 0, 0 }, { 201, "EFFECT_SANDSTORM", "ROCK", 0, 0 }, + { 203, "EFFECT_ENDURE", "NORMAL", 0, 0 }, { 204, "EFFECT_ATTACK_DOWN_2", "NORMAL", 0, 0 }, + { 205, "EFFECT_ROLLOUT", "ROCK", 30, 0 }, { 206, "EFFECT_FALSE_SWIPE", "NORMAL", 40, 0 }, + { 207, "EFFECT_SWAGGER", "NORMAL", 0, 100 }, { 210, "EFFECT_FURY_CUTTER", "BUG", 10, 0 }, + { 211, "EFFECT_DEFENSE_UP_HIT", "STEEL", 70, 10 }, { 213, "EFFECT_ATTRACT", "NORMAL", 0, 0 }, + { 214, "EFFECT_SLEEP_TALK", "NORMAL", 0, 0 }, { 215, "EFFECT_HEAL_BELL", "NORMAL", 0, 0 }, + { 216, "EFFECT_RETURN", "NORMAL", 1, 0 }, { 217, "EFFECT_PRESENT", "NORMAL", 1, 0 }, + { 218, "EFFECT_FRUSTRATION", "NORMAL", 1, 0 }, { 219, "EFFECT_SAFEGUARD", "NORMAL", 0, 0 }, + { 220, "EFFECT_PAIN_SPLIT", "NORMAL", 0, 0 }, { 221, "EFFECT_SACRED_FIRE", "FIRE", 100, 50 }, + { 222, "EFFECT_MAGNITUDE", "GROUND", 1, 0 }, { 226, "EFFECT_BATON_PASS", "NORMAL", 0, 0 }, + { 227, "EFFECT_ENCORE", "NORMAL", 0, 0 }, { 228, "EFFECT_PURSUIT", "DARK", 40, 0 }, + { 229, "EFFECT_RAPID_SPIN", "NORMAL", 20, 0 }, { 232, "EFFECT_ATTACK_UP_HIT", "STEEL", 50, 10 }, + { 233, "EFFECT_ALWAYS_HIT", "FIGHTING", 70, 0 }, { 234, "EFFECT_MORNING_SUN", "NORMAL", 0, 0 }, + { 235, "EFFECT_SYNTHESIS", "GRASS", 0, 0 }, { 236, "EFFECT_MOONLIGHT", "NORMAL", 0, 0 }, + { 237, "EFFECT_HIDDEN_POWER", "NORMAL", 1, 0 }, { 239, "EFFECT_TWISTER", "DRAGON", 40, 20 }, + { 240, "EFFECT_RAIN_DANCE", "WATER", 0, 0 }, { 241, "EFFECT_SUNNY_DAY", "FIRE", 0, 0 }, + { 243, "EFFECT_MIRROR_COAT", "PSYCHIC_TYPE", 1, 0 }, { 244, "EFFECT_PSYCH_UP", "NORMAL", 0, 0 }, + { 245, "EFFECT_PRIORITY_HIT", "NORMAL", 80, 0 }, { 246, "EFFECT_ALL_UP_HIT", "ROCK", 60, 10 }, + { 248, "EFFECT_FUTURE_SIGHT", "PSYCHIC_TYPE", 80, 0 }, { 251, "EFFECT_BEAT_UP", "DARK", 10, 0 }, + { 252, "EFFECT_NORMAL_HIT", "NORMAL", 40, 0 }, +} + +local function canonType(name) + local s = name:gsub("_TYPE$", "") + if s == "CURSE" then return "MYSTERY" end + return s +end + +local function species(gen, dexMax, special) + local out = {} + for n = 1, dexMax do + local types = special[n] or { "NORMAL" } + local t = {} + for _, name in ipairs(types) do t[#t + 1] = canonType(name) end + local spa = 60 + (n % 40) + local spd = (gen == 1) and spa or (55 + (n % 30)) + out[n] = { national = n, types = t, + base = { hp = 50 + (n % 50), atk = 40 + (n % 60), def = 45 + (n % 45), spe = 30 + (n % 70), spa = spa, + spd = spd } } + end + return out +end + +function F.gen1(extra) + local moves = {} + for id = 1, 165 do + moves[id] = { id = id, type = "NORMAL", power = 40, accuracy = 100, pp = 20, effect = "NO_ADDITIONAL_EFFECT" } + end + for _, r in ipairs(GEN1_MOVES) do + moves[r[1]] = { id = r[1], effect = r[2], type = canonType(r[3]), power = r[4], accuracy = r[5], pp = r[6] } + end + for id, row in pairs(extra or {}) do moves[id] = row end + return { generation = 1, dexMax = 151, moveMax = 165, moves = moves, species = species(1, 151, GEN1_SPECIES) } +end + +function F.gen2(extra) + local moves = {} + for id = 1, 251 do + moves[id] = { id = id, type = "NORMAL", power = 40, accuracy = 100, pp = 20, effect = "EFFECT_NORMAL_HIT", + effectChance = 0 } + end + for _, r in ipairs(GEN2_MOVES) do + if r[1] <= 251 then + moves[r[1]] = { id = r[1], effect = r[2], type = canonType(r[3]), power = r[4], accuracy = 100, pp = 15, + effectChance = r[5] } + end + end + for id, row in pairs(extra or {}) do moves[id] = row end + local special = { [81] = { "ELECTRIC", "STEEL" }, [197] = { "DARK" }, [25] = { "ELECTRIC" } } + return { generation = 2, dexMax = 251, moveMax = 251, moves = moves, species = species(2, 251, special) } +end + +function F.real(version, identity) + local home = os.getenv("POKEPORT_REAL_HOME") or os.getenv("HOME") + if not home then return nil end + local root = home .. "/Library/Application Support/LOVE/" .. identity + local Datasets = require("src.online.xgen.Datasets") + local probe = io.open(root .. "/" .. version .. "/data/generated/moves.lua", "rb") + if not probe then return nil end + probe:close() + Datasets.setReader(Datasets.directoryReader(root)) + local data = Datasets.get(version) + Datasets.setReader(nil) + return data +end + +local function lcg(seed) + local r = seed % 2147483648 + return function(n) + r = (r * 1103515245 + 12345) % 2147483648 + return math.floor(r / 65536) % n + 1 + end +end +F.lcg = lcg + +function F.record(t, n, moveIds, level, opts) + opts = opts or {} + local b = t.species[n] + level = level or 50 + local function st(base) return math.floor((2 * base + 31) * level / 100) + 5 end + local hp = math.floor((2 * b[3] + 31) * level / 100) + level + 10 + local moves = {} + for i, id in ipairs(moveIds) do moves[i] = { id = id, pp = t.moves[id][4], ppUps = 0 } end + return { + species = n, level = level, hp = hp, maxHp = hp, atk = opts.atk or st(b[4]), def = opts.def or st(b[5]), + speed = opts.speed or st(b[6]), spAtk = opts.spAtk or st(b[7]), spDef = opts.spDef or st(b[8]), + moves = moves, gender = opts.gender or 2, friendship = opts.friendship or 70, nickname = opts.nickname, + ivs = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }, + } +end + +function F.randomParty(t, rnd, size) + local illegal = {} + for _, id in ipairs(t.illegal) do illegal[id] = true end + local party = {} + for i = 1, size do + local n = rnd(t.dexMax) + local ids, used = {}, {} + while #ids < 4 do + local id = rnd(t.moveMax) + if not used[id] and not illegal[id] and id ~= 165 then + used[id] = true + ids[#ids + 1] = id + end + end + party[i] = F.record(t, n, ids, 30 + rnd(40), { gender = rnd(3) - 1, friendship = rnd(256) - 1 }) + end + return party +end + +function F.pick(m, seat, rnd) + local l = m:legalActions(seat) + if #l > 1 and rnd(50) ~= 1 then + local filtered = {} + for _, a in ipairs(l) do if a.kind ~= "forfeit" then filtered[#filtered + 1] = a end end + l = filtered + end + return l[rnd(#l)] +end + +return F diff --git a/tests/engine/_union_prep_pair.lua b/tests/engine/_union_prep_pair.lua new file mode 100644 index 00000000..6e213f4d --- /dev/null +++ b/tests/engine/_union_prep_pair.lua @@ -0,0 +1,65 @@ +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") + +local P = {} + +P.CLOCK = 0 +love.timer.getTime = function() return P.CLOCK end + +local function pid(n) return ("%08x"):format(n) end +P.pid = pid + +local FP = { red = "1111111111111111", yellow = "2222222222222222", gold = "3333333333333333", + crystal = "4444444444444444", emerald = "6666666666666666", firered = "5555555555555555" } + +local function ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" }, + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local function world() + local w = { relay = FakeRelay.new({ clock = function() return P.CLOCK end }), clients = {}, rooms = {}, seats = {} } + function w:add(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + self.seats[#self.seats + 1] = seat + local r = Room.new({ client = C }) + self.rooms[#self.rooms + 1] = r + return r, C, seat + end + function w:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end + end + return w +end + +function P.pair(va, vb, activity) + local w = world() + local ra = w:add(1, "ALICE") + local rb = w:add(2, "BOB") + w:pump() + ra:join(ctxFor(va, "ALICE", 1)) + rb:join(ctxFor(vb, "BOB", 2)) + w:pump() + ra:poll(); rb:poll() + ra:invite(pid(2), activity or "xg_battle") + w:pump() + rb:reply(rb:incoming()[1].id, true) + w:pump() + return w, ra:prep(), rb:prep(), ra, rb +end + +return P diff --git a/tests/engine/_union_trade_fixture.lua b/tests/engine/_union_trade_fixture.lua new file mode 100644 index 00000000..644dd530 --- /dev/null +++ b/tests/engine/_union_trade_fixture.lua @@ -0,0 +1,91 @@ +local F = require("tests.engine._xgen_fixture") +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local GameVersion = require("src.core.GameVersion") +local Save2 = require("src.core.gen2.Save") + +local U = {} + +U.CLOCK = { t = 0 } +U.FP = { red = "1111111111111111", gold = "3333333333333333", emerald = "6666666666666666" } + +function U.pid(n) return ("%08x"):format(n) end + +function U.ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = U.FP[version], rulesetId = gen == 3 and "g3_single" or "union", kind = "vanilla" }, + vanillaFingerprint = U.FP[version], gameplayMods = false } +end + +function U.world() + local w = { relay = FakeRelay.new({ clock = function() return U.CLOCK.t end }), clients = {} } + function w:add(n, name) + local seat = self.relay:seat(U.pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + return Room.new({ client = C }), C + end + function w:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end + end + return w +end + +function U.pair(va, vb) + local w = U.world() + local ra = w:add(1, "A") + local rb = w:add(2, "B") + w:pump() + ra:join(U.ctxFor(va, "A", 1)) + rb:join(U.ctxFor(vb, "B", 2)) + w:pump() + ra:poll(); rb:poll() + ra:invite(U.pid(2), "xg_trade") + w:pump() + rb:reply(rb:incoming()[1].id, true) + w:pump() + return w, ra, rb +end + +function U.pika1(level) + return { species = "PIKACHU", level = level or 25, exp = 15625, hp = 50, nickname = "ZAPPY", ot = "RED", otId = 4242, + dvs = { attack = 10, defense = 10, speed = 10, special = 10 }, + statExp = { hp = 100, attack = 400, defense = 0, speed = 900, special = 2500 }, + stats = { hp = 50, attack = 40, defense = 30, speed = 60, special = 40 }, + moves = { { id = "THUNDERSHOCK", pp = 30 }, { id = "GROWL", pp = 40 } }, catchRate = 190 } +end + +function U.bulba2() + return { species = "BULBASAUR", level = 10, experience = 560, ot = "GOLD", otId = 9, + dvs = { attack = 12, defense = 9, speed = 5, special = 7, hp = 8 }, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 30, attack = 15, defense = 15, speed = 12, specialAttack = 16, specialDefense = 16 }, hp = 30, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 } }, happiness = 90, pokerus = 0 } +end + +function U.g1game() + local save = { version = "red", party = { U.pika1(), U.pika1(30) }, pokedex = { seen = {}, owned = {} }, + player = { map = "PALLET_TOWN", x = 5, y = 5, facing = "down", name = "RED" } } + return { save = save, data = F.raw("red") } +end + +function U.g2game() + local was = GameVersion.get() + GameVersion.set("gold") + local save = Save2.newGame({ playerName = "GOLD" }) + GameVersion.set(was) + save.version = "gold" + save.party = { U.bulba2() } + return { save = save, data = F.raw("gold") } +end + +return U diff --git a/tests/engine/_xgen_fixture.lua b/tests/engine/_xgen_fixture.lua new file mode 100644 index 00000000..ef9e25da --- /dev/null +++ b/tests/engine/_xgen_fixture.lua @@ -0,0 +1,280 @@ +local Datasets = require("src.online.xgen.Datasets") + +local F = {} + +local function gbMove(index, id, name, typeName, power, pp, accuracy) + return { index = index, id = id, name = name, type = typeName, power = power, pp = pp, accuracy = accuracy or 100 } +end + +local GB_MOVES = { + TACKLE = gbMove(33, "TACKLE", "TACKLE", "NORMAL", 35, 35, 95), + GROWL = gbMove(45, "GROWL", "GROWL", "NORMAL", 0, 40), + VINE_WHIP = gbMove(22, "VINE_WHIP", "VINE WHIP", "GRASS", 35, 10), + RAZOR_LEAF = gbMove(75, "RAZOR_LEAF", "RAZOR LEAF", "GRASS", 55, 25, 95), + THUNDERSHOCK = gbMove(84, "THUNDERSHOCK", "THUNDERSHOCK", "ELECTRIC", 40, 30), + THUNDERBOLT = gbMove(85, "THUNDERBOLT", "THUNDERBOLT", "ELECTRIC", 95, 15), + BODY_SLAM = gbMove(34, "BODY_SLAM", "BODY SLAM", "NORMAL", 85, 15), + PSYCHIC_M = gbMove(94, "PSYCHIC_M", "PSYCHIC", "PSYCHIC_TYPE", 90, 10), + SURF = gbMove(57, "SURF", "SURF", "WATER", 95, 15), + TOXIC = gbMove(92, "TOXIC", "TOXIC", "POISON", 0, 10, 85), + QUICK_ATTACK = gbMove(98, "QUICK_ATTACK", "QUICK ATTACK", "NORMAL", 40, 30), +} + +local GEN2_MOVES = { + GIGA_DRAIN = gbMove(202, "GIGA_DRAIN", "GIGA DRAIN", "GRASS", 60, 5), + SWEET_SCENT = gbMove(230, "SWEET_SCENT", "SWEET SCENT", "NORMAL", 0, 20), + CRUNCH = gbMove(242, "CRUNCH", "CRUNCH", "DARK", 80, 15), +} + +local function merge(...) + local out = {} + for _, t in ipairs({ ... }) do for k, v in pairs(t) do out[k] = v end end + return out +end + +local function gen1Pokemon() + return { + BULBASAUR = { id = "BULBASAUR", name = "BULBASAUR", dex = 1, types = { "GRASS", "POISON" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 }, catchRate = 45, + growthRate = "MEDIUM_SLOW", level1Moves = { "TACKLE", "GROWL" }, + learnset = { { level = 7, move = "VINE_WHIP" } }, tmhm = { "BODY_SLAM", "TOXIC" }, + evolutions = { { level = 16, method = "LEVEL", species = "IVYSAUR" } } }, + IVYSAUR = { id = "IVYSAUR", name = "IVYSAUR", dex = 2, types = { "GRASS", "POISON" }, + baseStats = { hp = 60, attack = 62, defense = 63, speed = 60, special = 80 }, catchRate = 45, + growthRate = "MEDIUM_SLOW", level1Moves = { "TACKLE", "GROWL" }, + learnset = { { level = 22, move = "RAZOR_LEAF" } }, tmhm = { "BODY_SLAM", "TOXIC" }, evolutions = {} }, + PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25, types = { "ELECTRIC", "ELECTRIC" }, + baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 }, catchRate = 190, + growthRate = "MEDIUM_FAST", level1Moves = { "THUNDERSHOCK", "GROWL" }, learnset = {}, + tmhm = { "THUNDERBOLT", "BODY_SLAM", "TOXIC" }, evolutions = {} }, + MEWTWO = { id = "MEWTWO", name = "MEWTWO", dex = 150, types = { "PSYCHIC_TYPE", "PSYCHIC_TYPE" }, + baseStats = { hp = 106, attack = 110, defense = 90, speed = 130, special = 154 }, catchRate = 3, + growthRate = "SLOW", level1Moves = { "PSYCHIC_M" }, learnset = {}, tmhm = { "SURF", "BODY_SLAM" }, + evolutions = {} }, + } +end + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, linear = 100, constant = 140 }, + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0, constant = 0 }, +} + +local function gen2Pokemon(layout) + local function bs(hp, a, d, s, sa, sd) return { hp = hp, attack = a, defense = d, speed = s, specialAttack = sa, specialDefense = sd } end + local rows = { + BULBASAUR = { dex = 1, types = { "GRASS", "POISON" }, baseStats = bs(45, 49, 49, 45, 65, 65), genderRatio = 31, + growthRate = "GROWTH_MEDIUM_SLOW", catchRate = 45, + level = { { level = 1, move = "TACKLE" }, { level = 4, move = "GROWL" }, { level = 7, move = "VINE_WHIP" } }, + tmhm = { "TOXIC", "GIGA_DRAIN" }, egg = { "RAZOR_LEAF" }, evo = { { into = "IVYSAUR", level = 16, method = "EVOLVE_LEVEL" } } }, + IVYSAUR = { dex = 2, types = { "GRASS", "POISON" }, baseStats = bs(60, 62, 63, 60, 80, 80), genderRatio = 31, + growthRate = "GROWTH_MEDIUM_SLOW", catchRate = 45, + level = { { level = 1, move = "TACKLE" }, { level = 20, move = "SWEET_SCENT" } }, tmhm = { "TOXIC", "GIGA_DRAIN" }, egg = {}, evo = {} }, + PIKACHU = { dex = 25, types = { "ELECTRIC", "ELECTRIC" }, baseStats = bs(35, 55, 30, 90, 50, 40), genderRatio = 127, + growthRate = "GROWTH_MEDIUM_FAST", catchRate = 190, + level = { { level = 1, move = "THUNDERSHOCK" }, { level = 1, move = "GROWL" } }, tmhm = { "THUNDERBOLT", "TOXIC" }, egg = {}, evo = {} }, + CHIKORITA = { dex = 152, types = { "GRASS", "GRASS" }, baseStats = bs(45, 49, 65, 45, 49, 65), genderRatio = 31, + growthRate = "GROWTH_MEDIUM_SLOW", catchRate = 45, + level = { { level = 1, move = "TACKLE" }, { level = 1, move = "GROWL" }, { level = 8, move = "RAZOR_LEAF" } }, + tmhm = { "TOXIC", "GIGA_DRAIN" }, egg = {}, evo = {} }, + UMBREON = { dex = 197, types = { "DARK", "DARK" }, baseStats = bs(95, 65, 110, 65, 60, 130), genderRatio = 31, + growthRate = "GROWTH_MEDIUM_FAST", catchRate = 45, + level = { { level = 1, move = "TACKLE" }, { level = 30, move = "CRUNCH" } }, tmhm = { "TOXIC" }, egg = {}, evo = {} }, + UNOWN = { dex = 201, types = { "PSYCHIC_TYPE", "PSYCHIC_TYPE" }, baseStats = bs(48, 72, 48, 48, 72, 48), genderRatio = 255, + growthRate = "GROWTH_MEDIUM_FAST", catchRate = 225, level = { { level = 1, move = "TACKLE" } }, tmhm = {}, egg = {}, evo = {} }, + } + local out = { growthRates = GROWTH } + for id, r in pairs(rows) do + local def = { id = id, name = id, dex = r.dex, types = r.types, baseStats = r.baseStats, genderRatio = r.genderRatio, + growthRate = r.growthRate, catchRate = r.catchRate, tmhm = r.tmhm, evolutions = r.evo } + if layout == "learnset" then + def.level1Moves, def.learnset = {}, {} + for _, row in ipairs(r.level) do + if row.level == 1 then def.level1Moves[#def.level1Moves + 1] = row.move else def.learnset[#def.learnset + 1] = row end + end + def.eggMoves = r.egg + else + def.levelMoves, def.eggMoves = r.level, r.egg + end + out[id] = def + end + return out +end + +local function gen2Items() + return { + LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", index = 146 }, + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", index = 158 }, + PINK_BOW = { id = "PINK_BOW", name = "PINK BOW", index = 104 }, + POTION = { id = "POTION", name = "POTION", index = 18 }, + LIGHT_BALL = { id = "LIGHT_BALL", name = "LIGHT BALL", index = 163 }, + MINT_BERRY = { id = "MINT_BERRY", name = "MINT BERRY", index = 190 }, + } +end + +local GEN3_TYPE = { NORMAL = 0, FIGHTING = 1, FLYING = 2, POISON = 3, GROUND = 4, ROCK = 5, BUG = 6, GHOST = 7, + STEEL = 8, FIRE = 10, WATER = 11, GRASS = 12, ELECTRIC = 13, PSYCHIC = 14, ICE = 15, DRAGON = 16, DARK = 17 } + +local function gen3Tables() + local moveNames, battle = { [0] = "-" }, {} + local function mv(id, name, t, power, pp, acc, priority) + moveNames[id] = name + battle[id] = { type = GEN3_TYPE[t], power = power, pp = pp, accuracy = acc or 100, priority = priority or 0, effect = 0 } + end + mv(33, "TACKLE", "NORMAL", 35, 35, 95) + mv(45, "GROWL", "NORMAL", 0, 40) + mv(22, "VINE WHIP", "GRASS", 35, 10) + mv(75, "RAZOR LEAF", "GRASS", 55, 25, 95) + mv(84, "THUNDERSHOCK", "ELECTRIC", 40, 30) + mv(85, "THUNDERBOLT", "ELECTRIC", 95, 15) + mv(34, "BODY SLAM", "NORMAL", 85, 15) + mv(94, "PSYCHIC", "PSYCHIC", 90, 10) + mv(57, "SURF", "WATER", 95, 15) + mv(92, "TOXIC", "POISON", 0, 10, 85) + mv(202, "GIGA DRAIN", "GRASS", 60, 5) + mv(230, "SWEET SCENT", "NORMAL", 0, 20) + mv(242, "CRUNCH", "DARK", 80, 15) + mv(345, "MAGICAL LEAF", "GRASS", 60, 20) + mv(98, "QUICK ATTACK", "NORMAL", 40, 30, 100, 1) + local species = { + [1] = { nat = 1, name = "BULBASAUR", types = { 12, 3 }, stats = { hp = 45, atk = 49, def = 49, spe = 45, spa = 65, spd = 65 }, + ratio = 31, growth = 3, friendship = 70, abilities = { 65, 0 }, learn = { { 1, 33 }, { 4, 45 }, { 7, 22 } }, + egg = { 75 }, evo = { { method = 4, param = 16, target = 2 } } }, + [2] = { nat = 2, name = "IVYSAUR", types = { 12, 3 }, stats = { hp = 60, atk = 62, def = 63, spe = 60, spa = 80, spd = 80 }, + ratio = 31, growth = 3, friendship = 70, abilities = { 65, 0 }, learn = { { 1, 33 }, { 20, 230 } }, egg = {}, evo = {} }, + [25] = { nat = 25, name = "PIKACHU", types = { 13, 13 }, stats = { hp = 35, atk = 55, def = 30, spe = 90, spa = 50, spd = 40 }, + ratio = 127, growth = 0, friendship = 70, abilities = { 9, 0 }, learn = { { 1, 84 }, { 1, 45 }, { 11, 98 } }, egg = {}, evo = {} }, + [150] = { nat = 150, name = "MEWTWO", types = { 14, 14 }, stats = { hp = 106, atk = 110, def = 90, spe = 130, spa = 154, spd = 90 }, + ratio = 255, growth = 5, friendship = 0, abilities = { 46, 0 }, learn = { { 1, 94 } }, egg = {}, evo = {} }, + [152] = { nat = 152, name = "CHIKORITA", types = { 12, 12 }, stats = { hp = 45, atk = 49, def = 65, spe = 45, spa = 49, spd = 65 }, + ratio = 31, growth = 3, friendship = 70, abilities = { 65, 0 }, learn = { { 1, 33 }, { 1, 45 }, { 8, 75 } }, egg = {}, evo = {} }, + [197] = { nat = 197, name = "UMBREON", types = { 17, 17 }, stats = { hp = 95, atk = 65, def = 110, spe = 65, spa = 60, spd = 130 }, + ratio = 31, growth = 0, friendship = 35, abilities = { 28, 0 }, learn = { { 1, 33 }, { 30, 242 } }, egg = {}, evo = {} }, + [201] = { nat = 201, name = "UNOWN", types = { 14, 14 }, stats = { hp = 48, atk = 72, def = 48, spe = 48, spa = 72, spd = 48 }, + ratio = 255, growth = 0, friendship = 70, abilities = { 26, 0 }, learn = { { 1, 33 } }, egg = {}, evo = {} }, + [277] = { nat = 252, name = "TREECKO", types = { 12, 12 }, stats = { hp = 40, atk = 45, def = 35, spe = 70, spa = 65, spd = 55 }, + ratio = 31, growth = 3, friendship = 70, abilities = { 65, 0 }, learn = { { 1, 33 }, { 1, 345 } }, egg = {}, evo = {} }, + } + local t = { names = { [0] = "??????????" }, national = { toNational = {}, toSpecies = {} }, types = {}, stats = {}, + meta = {}, abilities = {}, learnsets = {}, eggMoves = {}, evolutions = {}, + battleMoves = { moves = battle }, moveNames = moveNames, + tmhm = { machines = { [5] = 92, [23] = 85, [21] = 202 }, learnsets = {} }, + tutor = { moves = { [0] = 34, [1] = 57 }, learnsets = {} }, + typeNames = { [0] = "NORMAL", [12] = "GRASS", [13] = "ELECTR", [14] = "PSYCHC", [17] = "DARK", [9] = "???" }, + items = { items = { + [13] = { name = "POTION" }, [200] = { name = "LEFTOVERS" }, [121] = { name = "ORANGE MAIL", fieldUseName = "ItemUseOutOfBattle_Mail" }, + [202] = { name = "LIGHT BALL" }, [44] = { name = "BERRY JUICE" }, + } }, + } + for internal, s in pairs(species) do + t.names[internal] = s.name + t.national.toNational[internal], t.national.toSpecies[s.nat] = s.nat, internal + t.types[internal], t.stats[internal] = s.types, s.stats + t.meta[internal] = { genderRatio = s.ratio, growthRate = s.growth, friendship = s.friendship, catchRate = 45 } + t.abilities[internal], t.learnsets[internal] = s.abilities, s.learn + t.eggMoves[internal], t.evolutions[internal] = s.egg, s.evo + t.tmhm.learnsets[internal] = { lo = 2 ^ 5 + 2 ^ 21 + (s.nat == 25 and 2 ^ 23 or 0), hi = 0 } + t.tutor.learnsets[internal] = 1 + end + return t +end + +function F.raw(version) + if version == "red" then return { pokemon = gen1Pokemon(), moves = merge(GB_MOVES), items = {} } end + if version == "gold" then return { pokemon = gen2Pokemon("levelMoves"), moves = merge(GB_MOVES, GEN2_MOVES), items = gen2Items() } end + if version == "silver" then return { pokemon = gen2Pokemon("learnset"), moves = merge(GB_MOVES, GEN2_MOVES), items = gen2Items() } end + if version == "emerald" then return gen3Tables() end + error("no fixture for " .. tostring(version)) +end + +function F.data(version) + return assert(Datasets.build(version, F.raw(version))) +end + +function F.deepEqual(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not F.deepEqual(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +function F.copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = F.copy(x) end + return out +end + +function F.shares(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + local seen = {} + local function walk(t, mark) + if type(t) ~= "table" or seen[t] == mark then return end + seen[t] = seen[t] and "both" or mark + for _, v in pairs(t) do walk(v, mark) end + end + walk(a, "a") + local hit = false + local visited = {} + local function walkB(t) + if type(t) ~= "table" or visited[t] then return end + visited[t] = true + if seen[t] then hit = true end + for _, v in pairs(t) do walkB(v) end + end + walkB(b) + return hit +end + +local function readable(path) + local f = io.open(path, "rb") + if f then f:close() return true end + return false +end + +function F.cacheRoot(version) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + local prefix = require("src.core.GameVersion").cachePrefix(version) + local probe = require("src.core.GameVersion").generation(version) == 3 and "data/generated/gba/pokemon/names.lua" + or "data/generated/pokemon.lua" + local ids = {} + local env = os.getenv("POKEPORT_IDENTITY") + if env and env ~= "" then ids[#ids + 1] = env end + ids[#ids + 1] = "g1r-" .. version + ids[#ids + 1] = "pokeport-test-caches" + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs(ids) do + local root = base .. "/" .. id + if readable(root .. "/" .. prefix .. probe) then return root end + end + end + return nil +end + +local readerInstalled = false +function F.real(version) + if not readerInstalled then + readerInstalled = true + local roots = {} + Datasets.setReader(function(v, rel) + if roots[v] == nil then roots[v] = F.cacheRoot(v) or false end + if not roots[v] then return nil end + return Datasets.directoryReader(roots[v])(v, rel) + end) + end + if not F.cacheRoot(version) then return nil end + return (Datasets.get(version)) +end + +function F.allReal() + local out, list = {}, {} + for _, version in ipairs(require("src.core.GameVersion").ORDER) do + local d = F.real(version) + if d then out[version] = d; list[#list + 1] = version end + end + return out, list +end + +return F diff --git a/tests/engine/g3u_gen1_screen_test.lua b/tests/engine/g3u_gen1_screen_test.lua new file mode 100644 index 00000000..7ebc8652 --- /dev/null +++ b/tests/engine/g3u_gen1_screen_test.lua @@ -0,0 +1,171 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local BattleSession = require("src.online.union.BattleSession") +local L = require("tests.support.g3u_loopback") +local G = require("src.ui.g3u.Gen1Screen") + +local data = { pokemon = {}, moves = {}, text = { + _CriticalHitText = "FIX CRIT{PROMPT}", + _EnemyMonFaintedText = "FIX FOE {RAM:wEnemyMonNick}\nDOWN{PROMPT}", + _TrainerDefeatedText = "{PLAYER} FIX BEAT\n{RAM:wTrainerName}!{PROMPT}", + _FlewUpHighText = "\nFIX FLEW{PROMPT}", +} } +for n = 1, 151 do data.pokemon["MON" .. n] = { dex = n, name = "MON" .. n } end +for i = 1, 165 do + data.moves["MV" .. i] = { index = i, name = "MOVE" .. i, effect = (i == 7) and "BURN_SIDE_EFFECT1" or "NO_ADDITIONAL_EFFECT" } +end + +local t1 = assert(Table.build(F.gen1())) +local p0 = { F.record(t1, 25, { 33, 7, 45 }), F.record(t1, 6, { 33 }, 50, { nickname = "BLAZE" }) } +local p1 = { F.record(t1, 95, { 33, 89 }), F.record(t1, 143, { 33 }) } + +local ctx = assert(G.newCtx(data, 0, { [0] = p0, [1] = p1 }, { me = "RED", foe = "GARY" })) +T.eq(ctx.mons[0][1].species, "MON25", "national 25 maps to the cache species key") +T.eq(ctx.mons[0][1].moves[2].id, "MV7", "move id 7 maps to the cache move key") +T.eq(ctx.mons[0][1].stats.special, p0[1].spAtk, "Special shows the record's Sp. Atk") +T.eq(ctx.mons[0][2].nickname, "BLAZE", "a real nickname is kept") +T.eq(ctx.mons[0][1].nickname, nil, "a species-name nickname falls back to the cache name") + +local function only(rows, op) + T.eq(#rows, 1, "one row for " .. op) + return rows[1] or {} +end + +local r = only(G.rowsFor({ kind = "msg", id = "STRINGID_USEDMOVE", + fill = { atk = { side = 0, index = 1 }, currentMove = { move = 33 } } }, ctx), "my used move") +T.eq(r.op, "say", "used move is a message") +T.eq(r.text, "MON25\nused MOVE33!", "my used move names my mon and the cache move") +T.eq(r.auto, true, "used move does not wait for a button") +r = only(G.rowsFor({ kind = "msg", id = "STRINGID_USEDMOVE", + fill = { atk = { side = 1, index = 1 }, currentMove = { move = 89 } } }, ctx), "foe used move") +T.eq(r.text, "Enemy MON95\nused MOVE89!", "the foe's mon gets the Enemy prefix") +r = only(G.rowsFor({ kind = "msg", id = "STRINGID_CRITICALHIT", fill = {} }, ctx), "crit") +T.eq(r.text, "FIX CRIT{PROMPT}", "a msg with a cart equivalent reads the cache label") +r = only(G.rowsFor({ kind = "msg", id = "STRINGID_TARGETFAINTED", fill = { def = { side = 1, index = 1 } } }, ctx), "foe faint text") +T.eq(r.text, "FIX FOE MON95\nDOWN{PROMPT}", "foe faint text fills the cache label") +r = only(G.rowsFor({ kind = "msg", id = "STRINGID_PKMNFLEWHIGH", fill = { atk = { side = 0, index = 1 } } }, ctx), "fly") +T.eq(r.text, "MON25\nFIX FLEW{PROMPT}", "charge text is the user plus the cache fragment") +r = only(G.rowsFor({ kind = "msg", id = "STRINGID_DEFENDERSSTATFELL", + fill = { def = { side = 1, index = 1 }, stat = "spDef", delta = -2 } }, ctx), "stat fell") +T.eq(r.text, "Enemy MON95's\nSPECIAL\ngreatly fell!", "Sp. Def drop reads as SPECIAL in Gen 1 register") +local rows = G.rowsFor({ kind = "msg", id = "STRINGID_PKMNFASTASLEEP", fill = { atk = { side = 0, index = 1 } } }, ctx) +T.eq(#rows, 2, "fast asleep gives text and the sleep anim") +T.eq(rows[1].op, "anim", "my sleeping mon's anim plays before the text") +T.eq(rows[1].name, "SLP_PLAYER_ANIM", "player side sleep anim") +rows = G.rowsFor({ kind = "msg", id = "STRINGID_PKMNISCONFUSED", fill = { atk = { side = 1, index = 1 } } }, ctx) +T.eq(rows[1].op, "say", "foe confusion text first") +T.eq(rows[2].name, "CONF_ANIM", "foe confusion anim after") +T.eq(#G.rowsFor({ kind = "msg", id = "STRINGID_SOMETHINGNEW", fill = {} }, ctx), 0, "unknown ids are skipped") +T.eq(#G.rowsFor({ kind = "msg", id = "STRINGID_ATTACKMISSED", fill = {} }, ctx), 0, "missing battler skips the line") +T.eq(#G.rowsFor({ kind = "msg", id = "STRINGID_PKMNSXWOREOFF", fill = { atk = "enemy" } }, ctx), 0, + "Gen 3 only lines are skipped") +T.eq(#G.rowsFor({ kind = "msg", id = "STRINGID_ATTACKMISSED", fill = { atk = "enemy" } }, ctx), 1, + "a string battler ref resolves by side") + +local mv = only(G.rowsFor({ kind = "move", moveId = 7, user = 0, target = 1, turn = 0 }, ctx), "move") +T.eq(mv.op, "move", "move event is an anim row") +T.eq(mv.move, "MV7", "anim keyed by the cache move key") +local rest = { { kind = "msg", id = "STRINGID_SUPEREFFECTIVE", fill = {} } } +local hp = only(G.rowsFor({ kind = "hp", side = 1, from = 100, to = 60, max = 100, hit = true }, ctx, rest, 1), "hp") +T.eq(hp.op, "hp", "hp event is an hp row") +T.eq(hp.to, 60, "hp row carries the event value") +T.eq(hp.index, 1, "hp row targets the active foe mon") +T.eq(mv.hit and mv.hit.animType, 5, "my added-effect hit shakes lightly") +T.eq(mv.hit and mv.hit.sfx.sound, "Super_Effective", "effectiveness picks the hit sound") +local mv2 = only(G.rowsFor({ kind = "move", moveId = 33, user = 1, target = 0 }, ctx), "foe move") +G.rowsFor({ kind = "hp", side = 0, from = 100, to = 90, max = 100, hit = true }, ctx, {}, 1) +T.eq(mv2.hit and mv2.hit.animType, 1, "a plain foe hit shakes the screen") +T.eq(mv2.hit and mv2.hit.sfx.sound, "Damage", "neutral hit sound") +local heal = G.rowsFor({ kind = "hp", side = 0, from = 50, to = 90, max = 100 }, ctx) +T.eq(heal[1].to, 90, "a heal is an hp row too") + +r = only(G.rowsFor({ kind = "status", side = 1, status = "TOX" }, ctx), "status") +T.eq(r.status, "PSN", "toxic shows as PSN in Gen 1") +r = only(G.rowsFor({ kind = "status", side = 1, status = "NONE" }, ctx), "status clear") +T.eq(r.status, nil, "NONE clears the status") +r = only(G.rowsFor({ kind = "stage", side = 0, stat = "attack", delta = 2 }, ctx), "stage") +T.eq(r.op, "stage", "stage event kept as a display-only row") +r = only(G.rowsFor({ kind = "faint", side = 1 }, ctx), "faint") +T.eq(r.op, "faint", "faint event is a faint row") +T.eq(#G.rowsFor({ kind = "withdraw", side = 1, index = 1, reason = "switch" }, ctx), 0, "a fainted mon withdraws silently") +r = only(G.rowsFor({ kind = "sendout", side = 1, index = 2, reason = "switch" }, ctx), "sendout") +T.eq(r.op, "sendout", "replacement is a sendout row") +T.eq(ctx.active[1], 2, "sendout moves the active index") +r = only(G.rowsFor({ kind = "withdraw", side = 0, index = 1, reason = "switch" }, ctx), "withdraw") +T.eq(r.op, "withdraw", "a voluntary switch withdraws with text") +T.eq(#G.rowsFor({ kind = "sendout", side = 0, index = 1, reason = "start" }, ctx), 0, "start send-out is the intro's") +for _, k in ipairs({ "weather", "anim", "need_replacement", "end", "ready" }) do + T.eq(#G.rowsFor({ kind = k, side = 0, result = {} }, ctx), 0, k .. " needs no row") +end +T.eq(#G.rowsFor({ kind = "sendout", side = 0, index = 9, reason = "switch" }, ctx), 0, "bad index does not crash") +T.eq(#G.rowsFor(nil, ctx), 0, "nil event does not crash") + +local m = G.menuFor({ { kind = "move", slot = 1 }, { kind = "move", slot = 3 }, { kind = "switch", index = 2 }, + { kind = "forfeit" } }) +T.check(m.slots[1] and m.slots[3] and not m.slots[2], "only legal move slots are selectable") +T.check(m.switches[2] and not m.switches[1], "switches limited to legal indices") +T.check(m.forfeit and not m.struggle and not m.locked, "forfeit offered, no struggle or lock") +m = G.menuFor({ { kind = "move", slot = 0 }, { kind = "forfeit" } }) +T.check(m.struggle and not m.any, "no usable move means Struggle") +m = G.menuFor({ { kind = "move", slot = 2, locked = true }, { kind = "forfeit" } }) +T.eq(m.locked and m.locked.slot, 2, "locked move auto submits") +m = G.menuFor({ { kind = "switch", index = 3 }, { kind = "switch", index = 4 } }) +T.check(m.switches[3] and m.switches[4] and not m.any, "replacement menu limited to legal indices") + +local function texts(rs) local o = {} for _, x in ipairs(rs) do o[#o + 1] = x.text end return table.concat(o, "|") end +T.eq(texts(G.endRows({ outcome = "win", why = "faint" }, ctx)), "RED FIX BEAT\nGARY!{PROMPT}", "win line from the cache") +T.eq(texts(G.endRows({ outcome = "lose", why = "faint" }, ctx)), "RED lost to\nGARY!", "lose line") +T.eq(texts(G.endRows({ outcome = "win", why = "forfeit" }, ctx)), "GARY forfeited\nthe match!|RED FIX BEAT\nGARY!{PROMPT}", + "foe forfeit then win") +T.eq(texts(G.endRows({ outcome = "draw", why = "desync" }, ctx)), "The link was\nlost.", "desync line") +T.eq(texts(G.endRows({ outcome = "draw", why = "disconnect" }, ctx)), "The link was\nlost.", "disconnect line") +T.eq(texts(G.endRows({ outcome = "draw", why = "error" }, ctx)), "The battle can't\ncontinue.", "error line") + +local function stream(seed) + local a, b = L.pair() + local go = { seed = seed, size = 2 } + local gens = { [0] = 1, [1] = 3 } + local me = BattleSession.new({ net = a, seat = 0, go = go, gens = gens, data = F.gen1(), records = p0, + names = { [0] = "RED", [1] = "BOT" }, now = function() return 0 end }) + local bot = BattleSession.new({ net = b, seat = 1, go = go, gens = gens, records = p1, + names = { [0] = "RED", [1] = "BOT" }, now = function() return 0 end }) + local stepMe, stepBot = L.bot(me, { seed = seed, switches = true }), L.bot(bot, { seed = seed + 1 }) + local evs = {} + for _ = 1, 3000 do + stepMe() + stepBot() + for _, e in ipairs(me:events()) do evs[#evs + 1] = e end + if me.result and bot.result then break end + end + return evs, me +end + +local seen, said, errs = {}, 0, 0 +for seed = 1, 12 do + local evs, me = stream(seed) + T.check(me.result ~= nil, "loopback battle " .. seed .. " ends") + local c + for i, e in ipairs(evs) do + if e.kind == "ready" then + c = assert(G.newCtx(data, 0, e.parties, { me = "RED", foe = "BOT" })) + elseif c then + local rest = { unpack(evs, i + 1) } + local ok, rs = pcall(G.rowsFor, e, c, rest, 1) + if not ok then errs = errs + 1 end + for _, x in ipairs(ok and rs or {}) do + seen[x.op] = true + if x.op == "say" then said = said + 1 end + end + end + end +end +T.eq(errs, 0, "every streamed event maps without an error") +for _, op in ipairs({ "say", "move", "hp", "faint", "sendout" }) do + T.check(seen[op], "the stream produced " .. op .. " rows") +end +T.check(said > 50, "streamed msgs became text (" .. said .. ")") + +T.finish() diff --git a/tests/engine/g3u_gen2_facade_test.lua b/tests/engine/g3u_gen2_facade_test.lua new file mode 100644 index 00000000..7d4f97e9 --- /dev/null +++ b/tests/engine/g3u_gen2_facade_test.lua @@ -0,0 +1,287 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local L = require("tests.support.g3u_loopback") +local Table = require("src.battle.g3u.Table") +local BattleSession = require("src.online.union.BattleSession") +local Battle = require("src.battle.gen2.Battle") +local Effects = require("src.battle.gen2.Effects") +local Strings = require("src.core.Strings") + +local okLoad, Gen2Facade = pcall(require, "src.ui.g3u.Gen2Facade") +T.check(okLoad, "src.ui.g3u.Gen2Facade loads: " .. tostring(not okLoad and Gen2Facade or "")) +if not okLoad then T.finish("g3u gen2 facade") return end + +local function readFile(path) + local f = assert(io.open(path, "rb")) + local s = f:read("*a") + f:close() + return s +end + +local function uiMembers() + local src = readFile("src/ui/gen2/BattleState.lua") + src = src:gsub("%-%-%[%[.-%]%]", "") + local lines = {} + for raw in (src .. "\n"):gmatch("([^\n]*)\n") do + local line = raw:gsub('"[^"]*"', '""'):gsub("'[^']*'", "''"):gsub("%-%-.*$", "") + lines[#lines + 1] = line + end + src = table.concat(lines, "\n") + local methods, fields, hooks = {}, {}, {} + for name in src:gmatch("%f[%w_]battle:([%a_][%w_]*)%s*%(") do methods[name] = true end + for name in src:gmatch("%f[%w_]battle%.([%a_][%w_]*)") do fields[name] = true end + for name in src:gmatch("self%.link%.([%a_][%w_]*)") do hooks[name] = true end + return methods, fields, hooks +end + +local function dataFixture() + local pokemon, moves = {}, {} + for n = 1, 251 do + pokemon[("MON%03d"):format(n)] = { dex = n, name = ("MON%03d"):format(n), types = { "NORMAL" } } + end + for id = 1, 251 do + moves[("MOVE%03d"):format(id)] = { index = id, name = ("MOVE%03d"):format(id) } + end + pokemon.growthRates = {} + return { pokemon = pokemon, moves = moves } +end + +local game = { data = dataFixture(), save = { player = { name = "GOLD" } } } +local t2 = assert(Table.build(F.gen2(), 2)) + +local function pair(p0, p1, seed) + local netA, netB = L.pair() + local go = { seed = seed or 11, size = 6 } + local gens = { [0] = 2, [1] = 3 } + local names = { [0] = "GOLD", [1] = "MAY" } + local me = BattleSession.new({ net = netA, seat = 0, go = go, gens = gens, data = F.gen2(), records = p0, + names = names }) + local bot = BattleSession.new({ net = netB, seat = 1, go = go, gens = gens, records = p1, names = names }) + return me, bot +end + +local function pumpBoth(me, bot, facade, n) + local out = {} + for _ = 1, n or 4 do + me:update() + bot:update() + for _, e in ipairs(facade:translate(me:events())) do out[#out + 1] = e end + end + return out +end + +local function find(list, pred) + for _, e in ipairs(list) do if pred(e) then return e end end + return nil +end + +do + local methods, fields, hooks = uiMembers() + local nM, nF = 0, 0 + local listed = {} + for _, k in ipairs(Gen2Facade.FIELDS) do listed[k] = true end + local mine = { F.record(t2, 25, { 33, 85 }), F.record(t2, 1, { 33 }) } + local foe = { F.record(t2, 4, { 33 }) } + local me, bot = pair(mine, foe) + local facade = Gen2Facade.new(game, me, { names = { me = "GOLD", foe = "MAY" } }) + pumpBoth(me, bot, facade) + for name in pairs(methods) do + nM = nM + 1 + T.check(type(facade[name]) == "function", "facade method battle:" .. name .. " exists") + end + for _, name in ipairs(Gen2Facade.METHODS) do + T.check(type(facade[name]) == "function", "declared method " .. name .. " is a function") + end + for name in pairs(fields) do + nF = nF + 1 + T.check(listed[name] == true, "UI field battle." .. name .. " is in Gen2Facade.FIELDS") + end + for _, name in ipairs(Gen2Facade.FIELDS) do + T.check(facade[name] ~= nil or Gen2Facade.NILABLE[name], "field " .. name .. " is set") + end + T.check(nM >= 15 and nF >= 12, ("grep found the UI members (%d methods, %d fields)"):format(nM, nF)) + T.check(type(facade.player) == "table" and type(facade.enemy) == "table", "player and enemy are views") + T.check(type(facade.random) == "function", "random is a function") + T.check(type(facade.trainer) == "table" and facade.trainer.name == "MAY", "trainer carries the foe name") + local h = facade:hooks() + for name in pairs(hooks) do + if name ~= "forcedPrompt" then + T.check(type(h[name]) == "function", "link hook " .. name .. " exists") + end + end +end + +do + local mine = { F.record(t2, 25, { 33, 85 }, 50, { nickname = "SPARKY" }), F.record(t2, 1, { 33 }) } + local foe = { F.record(t2, 4, { 33 }), F.record(t2, 7, { 33 }) } + local me, bot = pair(mine, foe) + local facade = Gen2Facade.new(game, me, { names = { me = "GOLD", foe = "MAY" } }) + pumpBoth(me, bot, facade) + T.check(facade.ready, "ready builds the views") + T.eq(#facade.party, 2, "my party has both mons") + T.eq(#facade.enemyParty, 2, "foe party has both mons") + T.eq(facade.player.species, "MON025", "species key from the active cache by national dex") + T.eq(facade.player.nickname, "SPARKY", "nickname from the record") + T.eq(facade.enemy.nickname, "MON004", "no nickname: species name from the active cache") + T.eq(facade.player.moves[1].id, "MOVE033", "move ids map to the active cache keys") + T.eq(facade.awaiting, "move", "the first prompt arms the move menu") + T.check(facade:hasUsableMoves(facade.player), "usable moves while choosing") + local legalSlots = {} + for _, a in ipairs(me:legal()) do if a.kind == "move" then legalSlots[a.slot] = true end end + for i, mv in ipairs(facade.player.moves) do + T.eq(facade:moveDisabled(facade.player, mv.id), not legalSlots[i], "move " .. i .. " disabled iff not legal") + end + T.check(facade:moveDisabled(facade.player, "MOVE099"), "a move the mon does not have is refused") + T.eq(facade:switchLocked(), false, "switching is open with a bench") + T.eq(facade:lockedInMove(facade.player), nil, "nothing is locked in") + + local refused + local s = { phase = "moves", queue = {} } + function s.refuseMenu(_, text) refused = text end + function s.refuseSwitch() refused = "switch" end + local h = facade:hooks() + h.menuChoice(s, "item") + T.check(refused ~= nil, "items are refused") + refused = nil + T.eq(h.menuChoice(s, "run"), true, "first RUN only warns") + T.eq(me.phase, "choose", "first RUN does not forfeit") + T.eq(h.menuChoice(s, "fight"), false, "FIGHT falls through to the native menu") + h.submit(s, { kind = "move", move = facade.player.moves[1].id }) + T.eq(me.phase, "wait", "the move menu submits the legal slot") + T.eq(s.phase, "link-wait", "the screen waits for the peer") + local bstep = L.bot(bot, { seed = 3 }) + local out = {} + for _ = 1, 6 do + bstep() + me:update() + for _, e in ipairs(facade:translate(me:events())) do out[#out + 1] = e end + end + local mv = find(out, function(e) return e.kind == "move" and e.side == "player" end) + T.check(mv and mv.move == "MOVE033" and type(mv.text) == "string" and mv.text:find("SPARKY"), + "USEDMOVE + move merge into one Gen 2 move event with text") + T.check(find(out, function(e) return e.kind == "damage" and e.side == "enemy" and e.hp ~= nil end) ~= nil, + "a hit becomes a damage event with the new hp") + T.eq(facade.awaiting, "move", "the next prompt arms the menu again") +end + +do + local mine = { F.record(t2, 25, { 33 }, 5), F.record(t2, 1, { 33 }) } + local foe = { F.record(t2, 143, { 33 }, 100) } + local me, bot = pair(mine, foe, 5) + local facade = Gen2Facade.new(game, me, {}) + pumpBoth(me, bot, facade) + local s = { phase = "moves", queue = {} } + local refused + function s.refuseMenu(_, text) refused = text end + function s.refuseSwitch() refused = "switch" end + local h = facade:hooks() + h.submit(s, { kind = "move", move = facade.player.moves[1].id }) + local bstep = L.bot(bot, { seed = 1 }) + local out = {} + for _ = 1, 6 do + bstep() + me:update() + for _, e in ipairs(facade:translate(me:events())) do out[#out + 1] = e end + end + local fe = find(out, function(e) return e.kind == "faint" and e.side == "player" end) + T.check(fe and fe.text:find("fainted"), "faint carries its line") + local dupes = 0 + for _, e in ipairs(out) do + if e.kind == "message" and e.text and e.text:find("fainted") then dupes = dupes + 1 end + end + T.eq(dupes, 0, "the engine faint line is not repeated") + T.check(find(out, function(e) return e.kind == "choose-switch" end) ~= nil, "my replacement prompt opens the party") + T.eq(facade.awaiting, "replace", "awaiting a replacement") + refused = nil + h.forcedSwitch(s, 1) + T.eq(refused, "switch", "the fainted lead cannot be sent back") + T.eq(h.forcedSwitch(s, 2), true, "a legal replacement is accepted") + out = pumpBoth(me, bot, facade, 4) + local send = find(out, function(e) return e.kind == "send" and e.side == "player" end) + T.check(send and send.mon == facade.party[2] and send.text:find("Go!"), "the replacement sends out with Go!") + T.check(facade.player == facade.party[2], "battle.player follows the replacement") +end + +do + local function fakeBs(legal) + return { seat = 0, names = { [0] = "GOLD", [1] = "MAY" }, seed = 1, + legal = function() return legal end, choose = function() return true end } + end + local recs = { F.record(t2, 25, { 33, 85, 1 }), F.record(t2, 1, { 33 }) } + local bs = fakeBs({ { kind = "move", slot = 2, locked = true }, { kind = "forfeit" } }) + local facade = Gen2Facade.new(game, bs, {}) + facade:translate({ { kind = "ready", parties = { [0] = recs, [1] = { recs[2] } } }, + { kind = "sendout", side = 0, index = 1, reason = "start" }, { kind = "sendout", side = 1, index = 1, reason = "start" }, + { kind = "prompt", what = "move", turn = 1 } }) + T.eq(facade:lockedInMove(facade.player), "MOVE085", "a locked action names its move for auto-submit") + local act = facade:actionFor({ kind = "move", move = "MOVE033" }) + T.check(act and act.slot == 2, "any submit maps to the locked action") + T.eq(facade:switchLocked(), true, "no switch actions means switching is locked") + + bs = fakeBs({ { kind = "move", slot = 0 }, { kind = "forfeit" } }) + facade = Gen2Facade.new(game, bs, {}) + facade:translate({ { kind = "ready", parties = { [0] = recs, [1] = { recs[2] } } }, + { kind = "prompt", what = "move", turn = 1 } }) + T.eq(facade:hasUsableMoves(facade.player), false, "only Struggle left") + act = facade:actionFor({ kind = "move", move = Battle.STRUGGLE }) + T.check(act and act.slot == 0, "STRUGGLE submits slot 0") + + local ev = facade:translate({ + { kind = "msg", id = "STRINGID_USEDMOVE", fill = { atk = { side = 1, index = 1 }, currentMove = { move = 33 } } }, + { kind = "msg", id = "STRINGID_ATTACKMISSED", fill = { atk = { side = 1, index = 1 } } }, + { kind = "hp", side = 0, from = facade.player.hp, to = facade.player.hp - 5, max = facade.player.maxHp }, + { kind = "hp", side = 0, from = facade.player.hp - 5, to = facade.player.hp - 2, max = facade.player.maxHp }, + { kind = "status", side = 0, status = "PSN" }, + { kind = "anim", anim = "status", name = "POISON", user = 0, target = 0 }, + { kind = "status", side = 1, status = "NONE" }, + { kind = "weather", weather = "RAINY", turns = 5 }, + { kind = "msg", id = "STRINGID_STARTEDTORAIN", fill = {} }, + { kind = "msg", id = "STRINGID_DEFENDERSSTATFELL", fill = { def = { side = 1, index = 1 }, stat = "spAtk", delta = -2 } }, + { kind = "msg", id = "STRINGID_NOT_A_REAL_ID", fill = { atk = { side = 7 } } }, + { kind = "msg", id = "STRINGID_PKMNHURTBY", fill = { atk = "nonsense" } }, + { kind = "withdraw", side = 1, index = 1, reason = "switch" }, + { kind = "sendout", side = 1, index = 1, reason = "switch" }, + { kind = "faint", side = 1 }, + { kind = "msg", id = "STRINGID_TARGETFAINTED", fill = { def = { side = 1, index = 1 } } }, + { kind = "need_replacement", side = 1, reason = "faint" }, + { kind = "end", result = { winner = 0, why = "faint" } }, + }) + local miss = ev[1] + T.check(miss.kind == "move" and miss.missed and miss.text:find("MAY") == nil and miss.text:find("MON001"), + "USEDMOVE with no move anim is a missed move line") + T.check(ev[2].kind == "message" and ev[2].text:find("missed"), "ATTACKMISSED text") + T.check(ev[3].kind == "damage" and ev[3].anim == false and ev[3].amount == 5, "non-hit damage has no shake") + T.check(ev[4].kind == "heal" and ev[4].amount == 3, "hp up is a heal") + T.check(ev[5].kind == "status" and ev[5].status == "poison" and ev[5].side == "player", "PSN -> poison") + T.check(ev[6].kind == "damage" and ev[6].anim == "ANIM_PSN" and ev[6].amount == 0 and ev[6].animSide == "player", + "status anim -> Gen 2 ANIM_PSN on the afflicted side") + T.check(ev[7].kind == "status" and ev[7].status == false, "NONE clears the status") + T.eq(facade.weather, "rain", "weather event sets the Gen 2 weather") + T.eq(ev[8].text, Strings(Effects.WEATHER_START_TEXT.rain), "weather line is the Gen 2 game's own") + T.check(ev[9].text:find("SPCL.ATK") and ev[9].text:find("sharply fell"), "stat text uses Gen 2 stat names") + T.check(ev[10].kind == "message" and ev[10].text:find("?", 1, true), "unknown ids drop, broken fills print ?") + T.check(ev[11].kind == "message" and ev[11].text:find("withdrew"), "enemy withdraw line") + T.check(ev[12].kind == "send" and ev[12].side == "enemy" and ev[12].text:find("sent out"), "enemy sendout line") + T.check(ev[13].kind == "faint" and ev[13].side == "enemy", "faint event") + T.check(ev[14].kind == "message" and ev[14].text:find("defeated"), "win line") + T.check(ev[15].kind == "trainer-return", "the trainer slides back in on a win") + T.eq(#ev, 15, "no extra events") + T.check(facade.over and facade.outcome == "win", "end sets over and outcome") + + facade = Gen2Facade.new(game, bs, {}) + facade:translate({ { kind = "ready", parties = { [0] = recs, [1] = { recs[2] } } } }) + local d = facade:translate({ { kind = "over", outcome = "draw", why = "desync", detail = 3 } }) + T.check(d[1] and d[1].text == Strings(Gen2Facade.TEXT.desync), "desync shows an original line") + T.check(facade.over and facade.outcome == "draw", "desync ends as a draw") + facade = Gen2Facade.new(game, bs, { names = { foe = "MAY" } }) + facade:translate({ { kind = "ready", parties = { [0] = recs, [1] = { recs[2] } } } }) + d = facade:translate({ { kind = "over", outcome = "draw", why = "disconnect" } }) + T.check(d[1] and d[1].text:find("MAY"), "disconnect names the peer") + local b = Gen2Facade.banner() + T.check(b[1].text == "UNION RULES!" and b[2].text:find("GEN 3 battle rules") and b[2].text:find("held items"), + "union rules banner wording") +end + +T.finish("g3u gen2 facade") diff --git a/tests/engine/g3u_gen3_presenter_test.lua b/tests/engine/g3u_gen3_presenter_test.lua new file mode 100644 index 00000000..425cfe7b --- /dev/null +++ b/tests/engine/g3u_gen3_presenter_test.lua @@ -0,0 +1,232 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local L = require("tests.support.g3u_loopback") +local Table = require("src.battle.g3u.Table") +local Scope = require("src.battle.g3u.Scope") +local BS = require("src.online.union.BattleSession") +local P = require("src.ui.g3u.Gen3Presenter") + +T.eq(type(P.start), "function", "Gen3Presenter.start exists") +T.eq(type(P.startNative), "function", "Gen3Presenter.startNative exists") + +do + T.eq(P.sideOf(1, 1), "player", "my seat shows on the player side") + T.eq(P.sideOf(0, 1), "enemy", "the peer seat shows on the enemy side") + T.eq(P.idOf(0, 0), 0, "seat 0 as seat 0 is battler 0") + T.eq(P.idOf(0, 1), 1, "seat 0 as seat 1 is battler 1") + + local mv = P.map({ kind = "move", moveId = 33, user = 0, target = 1, turn = 0 }, 1) + T.eq(mv.act, "anim", "move maps to an anim") + T.eq(mv.ev.kind, "move", "move anim keeps its kind") + T.eq(mv.ev.attacker, "enemy", "seat 0 attacking seat 1 is the enemy from seat 1") + T.eq(mv.ev.targetId, 0, "the target battler id is mine") + T.eq(mv.ev.moveId, 33, "move id passes through") + + local hit = P.map({ kind = "hp", side = 1, from = 50, to = 20, max = 60, hit = true }, 1) + T.eq(hit.act, "hp", "hp maps to hp") + T.eq(hit.ev.kind, "hit", "a hit hp event plays the hit flash") + T.eq(hit.ev.side, "player", "hp on my seat is the player side") + T.eq(hit.ev.maxHp, 60, "hp carries max") + T.eq(hit.to, 20, "hp target value kept for the display state") + local drain = P.map({ kind = "hp", side = 0, from = 5, to = 10, max = 60 }, 1) + T.eq(drain.ev.kind, "hp", "a plain hp event has no hit flash") + + local st = P.map({ kind = "status", side = 0, status = "PAR" }, 0) + T.eq(st.ev.kind, "status_apply", "status maps to status_apply") + T.eq(st.status, "PAR", "status value kept") + local cl = P.map({ kind = "status", side = 0, status = "NONE" }, 0) + T.eq(cl.ev.kind, "status_clear", "NONE clears the status") + T.eq(cl.status, nil, "cleared status is nil") + + T.eq(P.map({ kind = "stage", side = 0, stat = "attack", delta = 2 }, 0).act, "stage", "stage maps to stage") + local f = P.map({ kind = "faint", side = 0 }, 1) + T.eq(f.ev.kind, "faint", "faint plays faint") + T.eq(f.ev.battler, 1, "seat 0 fainting is battler 1 from seat 1") + T.eq(P.map({ kind = "withdraw", side = 0, index = 1, reason = "switch" }, 0).act, "withdraw", "withdraw") + local so = P.map({ kind = "sendout", side = 1, index = 2, reason = "switch" }, 0) + T.eq(so.act, "sendout", "sendout") + T.eq(so.index, 2, "sendout index") + T.eq(P.map({ kind = "weather", weather = "RAIN", turns = 5 }, 0).act, "weather", "weather") + local an = P.map({ kind = "anim", anim = "status", name = "SLEEP", user = 1, target = 1 }, 1) + T.eq(an.ev.kind, "anim", "anim maps to anim") + T.eq(an.ev.attacker, "player", "anim user resolved by seat") + T.eq(an.ev.name, "SLEEP", "anim name kept") + T.eq(P.map({ kind = "need_replacement", side = 0 }, 0).act, "none", "need_replacement draws nothing") + T.eq(P.map({ kind = "end", result = { winner = 0 } }, 0).act, "end", "end") + T.eq(P.map({ kind = "ready" }, 0).act, "ready", "ready") + T.eq(P.map({ kind = "prompt", what = "move" }, 0).act, "prompt", "prompt") + T.eq(P.map({ kind = "waiting", what = "move" }, 0).act, "wait", "waiting") + T.eq(P.map({ kind = "over", outcome = "win", why = "faint" }, 0).why, "faint", "over keeps why") + local msg = P.map({ kind = "msg", id = "STRINGID_CRITICALHIT", fill = {} }, 0) + T.eq(msg.act, "text", "msg maps to text") + T.eq(msg.id, "STRINGID_CRITICALHIT", "msg id kept") +end + +do + local m = P.menu({ { kind = "move", slot = 1 }, { kind = "move", slot = 3 }, { kind = "switch", index = 2 }, + { kind = "switch", index = 4 }, { kind = "forfeit" } }) + T.check(m.moves[1] and m.moves[3] and not m.moves[2] and not m.moves[4], "only legal move slots are offered") + T.check(m.switches[2] and m.switches[4] and not m.switches[1], "only legal party slots are offered") + T.eq(m.count, 2, "two legal switches") + T.check(m.forfeit ~= nil, "forfeit offered") + T.eq(m.locked, nil, "nothing locked") + local s = P.menu({ { kind = "move", slot = 0 }, { kind = "forfeit" } }) + T.check(s.struggle ~= nil and next(s.moves) == nil, "Struggle only when no move is usable") + local lk = P.menu({ { kind = "move", slot = 2, locked = true }, { kind = "forfeit" } }) + T.eq(lk.locked and lk.locked.slot, 2, "a locked move is picked out for auto-submit") + T.eq(next(lk.switches), nil, "a locked turn offers no switch") + local r = P.menu({ { kind = "switch", index = 3 } }) + T.check(r.switches[3] and r.count == 1 and r.forfeit == nil, "replacement menu is the legal indices only") +end + +local R = { + mon = function(seat, index) return "S" .. tostring(seat) .. "I" .. tostring(index) end, + move = function(n) return "MOVE" .. n end, + species = function(n) return "SPECIES" .. n end, + ability = function(n) return "ABILITY" .. n end, + type = function(n) return "TYPE" .. n end, + stat = function(k) return "STAT_" .. k end, + change = function(d) return d > 0 and "rose!" or "fell!" end, +} + +do + local fill = { atk = { side = 0, index = 2 }, def = { side = 1, index = 1 }, currentMove = 33, + buff1 = { move = 30 } } + local a = P.resolveFill(fill, 1, R) + T.eq(a.atk.side, "enemy", "attacker on seat 0 resolves to the enemy side for seat 1") + T.eq(a.atk.name, "S0I2", "attacker name comes from seat 0 party slot 2") + T.eq(a.def.side, "player", "defender on my seat resolves to the player side") + T.eq(a.def.name, "S1I1", "defender name from my party") + T.eq(a.currentMove, 33, "numeric move ids pass through") + T.eq(a.buff1, "MOVE30", "move tokens become names") + local b = P.resolveFill(fill, 0, R) + T.eq(b.atk.side, "player", "same fill from seat 0: attacker is mine") + T.eq(b.def.side, "enemy", "same fill from seat 0: defender is the foe") + local s = P.resolveFill({ atk = { side = 0, index = 1 }, def = { side = 0, index = 1 }, stat = "defense", + delta = -2 }, 0, R) + T.eq(s.buff1, "STAT_defense", "stat messages carry the stat name") + T.eq(s.buff2, "fell!", "stat messages carry the change text") + T.eq(s.stat, nil, "stat key removed") + local w = P.resolveFill({ buff1 = "attack", def = { side = 1, index = 3 } }, 0, R) + T.eq(w.buff1, "STAT_attack", "bare stat keys in buff1 become names") + local e = P.resolveFill({ atk = "enemy", buff1 = { move = 113 } }, 1, R) + T.eq(e.atk, "player", "an engine side name on seat 1 is my side") + local e0 = P.resolveFill({ atk = "enemy" }, 0, R) + T.eq(e0.atk, "enemy", "an engine side name on seat 0 stays") + local sp = P.resolveFill({ buff1 = { species = 87 }, side = 1 }, 1, R) + T.eq(sp.buff1, "SPECIES87", "species tokens become names") + T.eq(sp.side, "player", "a numeric side resolves by seat") + T.eq(P.resolveFill({ buff1 = { type = 12 } }, 0, R).buff1, "TYPE12", "type tokens become names") +end + +do + local s = P.newScript(1) + s:push({ + { kind = "ready" }, + { kind = "sendout", side = 0, index = 1, reason = "start" }, + { kind = "sendout", side = 1, index = 1, reason = "start" }, + { kind = "prompt", what = "move", turn = 1 }, + }) + T.eq(s:next().kind, "ready", "ready first") + local seg = s:next() + T.eq(seg.kind, "segment", "start sendouts form a segment") + T.eq(#seg.ops, 2, "both sendouts in one segment") + T.eq(s:next().kind, "prompt", "then the prompt") + T.eq(s:next(), nil, "queue drained") + s:push({ + { kind = "withdraw", side = 1, index = 1, reason = "switch" }, + { kind = "sendout", side = 1, index = 2, reason = "switch" }, + { kind = "msg", id = "STRINGID_USEDMOVE", fill = {} }, + { kind = "withdraw", side = 1, index = 2, reason = "roar" }, + { kind = "sendout", side = 1, index = 3, reason = "roar" }, + { kind = "msg", id = "STRINGID_PKMNWASDRAGGEDOUT", fill = {} }, + { kind = "prompt", what = "move", turn = 2 }, + }) + local a = s:next() + T.eq(#a.ops, 4, "a second sendout of one side starts a new segment") + local b = s:next() + T.eq(b.ops[1].act, "sendout", "the second segment starts with the roar switch") + T.eq(b.ops[1].index, 3, "roar sendout index") + T.eq(s:next().kind, "prompt", "prompt after both segments") +end + +local function live() + local M = Scope.modules() + local Pokemon, Moves = M["src.core.game3.pokemon"], M["src.core.game3.battle.moves"] + local BattleText, Adapter = M["src.core.game3.battle.battle_text"], M["src.core.game3.battle.adapter"] + return { + rawget(Pokemon, "_names"), rawget(Pokemon, "_types"), rawget(Pokemon, "_stats"), rawget(Pokemon, "_moveNames"), + rawget(Pokemon, "install"), rawget(Moves, "_rom"), rawget(Moves, "BY_NUM"), rawget(Moves, "_romLoaded"), + rawget(Moves, "loadRomPack"), rawget(BattleText, "get"), rawget(Adapter, "textSink"), math.random, + } +end + +do + local data = F.gen1() + local t = assert(Table.build(data, 1)) + local before = live() + local seen, bad, prompts, overs, refs, readyFirst = {}, 0, 0, 0, 0, nil + local okSeat = true + for seed = 1, 12 do + local rnd = L.lcg(seed) + local na, nb = L.pair() + local go = { seed = seed * 7, size = 3 } + local gens = { [0] = 1, [1] = 3 } + local a = BS.new({ net = na, seat = 0, go = go, gens = gens, data = data, records = F.randomParty(t, rnd, 3), + names = { [0] = "RED", [1] = "MAY" } }) + local b = BS.new({ net = nb, seat = 1, go = go, gens = gens, records = F.randomParty(t, rnd, 3), + names = { [0] = "RED", [1] = "MAY" } }) + local ba = L.bot(a, { seed = seed, switches = true }) + local bb = L.bot(b, { seed = seed + 3, switches = true }) + local script = P.newScript(1) + local first = true + for _ = 1, 4000 do + ba() bb() + script:push(b:events()) + while true do + local beat = script:next() + if not beat then break end + if first then + readyFirst = (readyFirst == nil or readyFirst) and beat.kind == "ready" + first = false + end + if beat.kind == "prompt" then prompts = prompts + 1 end + if beat.kind == "over" then overs = overs + 1 end + for _, op in ipairs(beat.ops or {}) do + seen[op.act] = true + if op.act == "text" then + local fill = P.resolveFill(op.fill, 1, R) + for k, v in pairs(op.fill) do + if type(v) == "table" and type(v.side) == "number" and v.index then + refs = refs + 1 + local r = fill[k] + if not (type(r) == "table" and r.side == P.sideOf(v.side, 1) + and r.name == R.mon(v.side, v.index)) then okSeat = false end + end + end + for _, v in pairs(fill) do + if type(v) == "table" and type(v.side) == "number" then bad = bad + 1 end + end + end + end + end + if a.result and b.result then break end + end + end + T.check(readyFirst, "every presented battle starts with ready") + T.eq(overs, 12, "every presented battle ends with exactly one over") + T.check(prompts > 12, "prompts reach the presenter") + T.check(refs > 0 and okSeat, "every battler ref in a message resolves to the seat's own side and party slot") + T.eq(bad, 0, "no raw seat refs reach BattleText") + for _, k in ipairs({ "text", "anim", "hp", "faint", "withdraw", "sendout" }) do + T.check(seen[k], "a real battle produces " .. k .. " ops") + end + local after = live() + local same = true + for i = 1, #before do if before[i] ~= after[i] then same = false end end + T.check(same, "live Gen 3 data is the same table set before and after presented battles") +end + +T.finish("g3u_gen3_presenter") diff --git a/tests/engine/g3u_lockstep_test.lua b/tests/engine/g3u_lockstep_test.lua new file mode 100644 index 00000000..0f48c881 --- /dev/null +++ b/tests/engine/g3u_lockstep_test.lua @@ -0,0 +1,127 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local Match = require("src.battle.g3u.Match") +local Scope = require("src.battle.g3u.Scope") + +local function deep(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not deep(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +local function legalSame(a, b) + for seat = 0, 1 do + if not deep(a:legalActions(seat), b:legalActions(seat)) then return false end + end + return true +end + +local function battle(t, seed) + local rnd = F.lcg(seed) + local p0, p1 = F.randomParty(t, rnd, rnd(4)), F.randomParty(t, rnd, rnd(4)) + local opts = { table = t, parties = { [0] = p0, [1] = p1 }, seed = seed * 7919 } + local a, b = Match.new(opts), Match.new(opts) + local ea, eb = a:start(), b:start() + if not deep(ea, eb) then return false, "start events differ" end + local steps, hashes = 0, 0 + while a.phase ~= "over" do + steps = steps + 1 + if steps > 600 then return false, "battle did not end" end + if a.phase ~= b.phase then return false, "phase differs" end + if not legalSame(a, b) then return false, "legal actions differ at turn " .. a.turn end + if a.phase == "choose" then + local acts = { [0] = F.pick(a, 0, rnd), [1] = F.pick(a, 1, rnd) } + ea, eb = a:submit(acts), b:submit(acts) + else + local picks = {} + for seat = 0, 1 do + if a:needs(seat) then + local l = a:legalActions(seat) + picks[seat] = l[rnd(#l)].index + end + end + ea, eb = a:replace(picks), b:replace(picks) + end + if not deep(ea, eb) then return false, "events differ at turn " .. a.turn end + if a:hash() ~= b:hash() then return false, "hash differs at turn " .. a.turn end + hashes = hashes + 1 + end + if b.phase ~= "over" or not deep(a.result, b.result) then return false, "result differs" end + for turn = 0, a.turn do + if a:hash(turn) == nil or a:hash(turn) ~= b:hash(turn) then return false, "turn hash " .. turn end + end + return true, a.turn +end + +local function run(label, t, count, base) + Scope.resetTrips() + local ok, turns, failure = 0, 0, nil + for i = 1, count do + local good, info = battle(t, base + i) + if good then + ok = ok + 1 + turns = turns + info + elseif not failure then + failure = ("seed %d: %s"):format(base + i, tostring(info)) + end + end + T.eq(ok, count, label .. ": every battle stays in lockstep " .. tostring(failure or "")) + T.check(turns > count * 3, label .. ": battles run real turns (" .. turns .. ")") + T.eq(#Scope.trips, 0, label .. ": no cache, text or math.random read during a match " .. tostring(Scope.trips[1])) +end + +local t1 = assert(Table.build(F.gen1())) +local t2 = assert(Table.build(F.gen2())) +run("gen1 fixture", t1, 120, 1000) +run("gen2 fixture", t2, 80, 5000) + +local function solo(t, seed, script) + local rnd = F.lcg(seed) + local p0, p1 = F.randomParty(t, rnd, 3), F.randomParty(t, rnd, 3) + local m = Match.new({ table = t, parties = { [0] = p0, [1] = p1 }, seed = script and script.seed or seed }) + m:start() + local log, i = { steps = {}, seed = seed }, 0 + while m.phase ~= "over" and i < 400 do + i = i + 1 + local step = script and script.steps[i] + if m.phase == "choose" then + step = step or { [0] = F.pick(m, 0, rnd), [1] = F.pick(m, 1, rnd) } + if script and not (m:isLegal(0, step[0]) and m:isLegal(1, step[1])) then break end + m:submit(step) + else + if not step then + step = {} + for s = 0, 1 do if m:needs(s) then step[s] = m:legalActions(s)[1].index end end + end + local okR = pcall(m.replace, m, step) + if not okR then break end + end + log.steps[i] = step + end + local hashes = {} + for turn = 0, m.turn do hashes[turn] = m:hash(turn) end + return log, hashes +end + +local log, first = solo(t1, 77) +local _, again = solo(t1, 77, log) +T.same(again, first, "a replay run after the first match finished reproduces every turn hash") +local _, other = solo(t1, 77, { seed = 78, steps = log.steps }) +T.check(other[1] ~= first[1] or other[2] ~= first[2] or other[3] ~= first[3], + "a different seed changes the turn hashes") + +for _, pair in ipairs({ { "red", "g1r-red" }, { "gold", "g1r-gold" } }) do + local data = F.real(pair[1], pair[2]) + if data then + run(pair[1] .. " cache", assert(Table.build(data)), 60, 9000) + else + print("[skip] " .. pair[1] .. " cache not imported") + end +end + +T.finish("g3u lockstep") diff --git a/tests/engine/g3u_mechanics_test.lua b/tests/engine/g3u_mechanics_test.lua new file mode 100644 index 00000000..28d8ffe1 --- /dev/null +++ b/tests/engine/g3u_mechanics_test.lua @@ -0,0 +1,193 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local Match = require("src.battle.g3u.Match") +local Scope = require("src.battle.g3u.Scope") +local Wire = require("src.battle.g3u.Wire") +local E = require("src.core.game3.battle.effect_ids") + +local t1 = assert(Table.build(F.gen1())) +local t2 = assert(Table.build(F.gen2())) +local M = Scope.modules() +local Moves, Damage, Types = M["src.core.game3.battle.moves"], M["src.core.game3.battle.damage"], M["src.core.game3.battle.types"] + +Scope.resetTrips() + +local function match(t, p0, p1, seed) + local m = Match.new({ table = t, parties = { [0] = p0, [1] = p1 }, seed = seed or 1 }) + return m, m:start() +end + +local function find(evs, pred) + for _, e in ipairs(evs) do if pred(e) then return e end end + return nil +end + +local function act(slot) return { kind = "move", slot = slot } end + +local bite = Scope.run(t1, function() return Moves.get(44) end) +T.eq(bite.type, 0, "Gen 1 Bite runs as a Normal move") +T.eq(bite.category, "physical", "Gen 1 Bite is physical") +T.eq(bite.effect, E.FLINCH_HIT, "Gen 1 Bite flinches") +T.eq(bite.secondaryChance, 10, "Gen 1 Bite flinches 10 percent") +local bite2 = Scope.run(t2, function() return Moves.get(44) end) +T.eq(bite2.type, 17, "Gen 2 Bite runs as a Dark move") + +do + local m = match(t1, { F.record(t1, 81, { 33 }) }, { F.record(t1, 95, { 89 }) }) + T.eq(m.st.player.type1, 13, "Gen 1 Magnemite battles as Electric") + T.eq(m.st.player.type2, nil, "Gen 1 Magnemite has no second type") + local eff = Scope.run(t1, function() return Types.typeCalc(4, m.st.player.type1, m.st.player.type2, 40) end) + T.eq(eff, 80, "Ground hits Gen 1 Magnemite for double, not quadruple") + local m2 = match(t2, { F.record(t2, 81, { 33 }) }, { F.record(t2, 95, { 89 }) }) + local eff2 = Scope.run(t2, function() return Types.typeCalc(4, m2.st.player.type1, m2.st.player.type2, 40) end) + T.eq(eff2, 160, "Ground hits Gen 2 Magnemite for quadruple") +end + +do + local special = 120 + local a = F.record(t1, 65, { 94, 44 }, 50, { spAtk = special, spDef = special, atk = 80, def = 70 }) + local d = F.record(t1, 143, { 33 }, 50, { spAtk = 90, spDef = 90, atk = 100, def = 75 }) + local m = match(t1, { a }, { d }) + local st, ad = m.st, m.ad + local dmg = Scope.run(t1, function() + local mv = Moves.get(94) + return Damage.base(st.player, st.enemy, mv, { adapter = ad }) + end) + local lf = math.floor(2 * 50 / 5) + 2 + local want = math.floor(math.floor(special * 90 * lf / 90) / 50) + 2 + T.eq(dmg, want, "a Gen 1 Special move uses the attacker's Special as Sp. Atk and the target's as Sp. Def") + st.enemy.mon.spAtk = 999 + local same = Scope.run(t1, function() return Damage.base(st.player, st.enemy, Moves.get(94), { adapter = ad }) end) + T.eq(same, want, "the defender's Sp. Atk does not enter Special damage") + st.enemy.mon.spDef = 45 + local more = Scope.run(t1, function() return Damage.base(st.player, st.enemy, Moves.get(94), { adapter = ad }) end) + T.eq(more, math.floor(math.floor(special * 90 * lf / 45) / 50) + 2, "the defender's Sp. Def does") + local phys = Scope.run(t1, function() return Damage.base(st.player, st.enemy, Moves.get(44), { adapter = ad }) end) + T.eq(phys, math.floor(math.floor(80 * 60 * lf / 75) / 50) + 2, "Gen 1 Bite uses Attack against Defense") +end + +do + local bad = assert(Table.build(F.gen1({ [5] = { id = 5, type = "NORMAL", power = 0, accuracy = 100, pp = 20, + effect = "SPEED_UP1_EFFECT" } }))) + local rec = F.record(bad, 25, { 33, 5 }) + local ok, why = Wire.record(rec, bad) + T.check(not ok and why == "move_unsupported", "a party carrying an unsupported move is refused (" .. tostring(why) .. ")") + local m = match(bad, { rec }, { F.record(bad, 25, { 33 }) }) + local offered = false + for _, a in ipairs(m:legalActions(0)) do if a.kind == "move" and a.slot == 2 then offered = true end end + T.check(not offered, "legalActions never offers the unsupported move") + T.raises(function() m:submit({ [0] = act(2), [1] = act(1) }) end, "illegal", "submitting it raises") +end + +do + local slow = F.record(t1, 143, { 98 }, 50, { speed = 20 }) + local fast = F.record(t1, 25, { 33 }, 50, { speed = 200 }) + local m = match(t1, { slow }, { fast }) + local evs = m:submit({ [0] = act(1), [1] = act(1) }) + local first = find(evs, function(e) return e.kind == "move" end) + T.eq(first and first.user, 0, "Gen 1 Quick Attack moves before a faster foe") + local m2 = match(t1, { F.record(t1, 143, { 68 }, 50, { speed = 200 }) }, { F.record(t1, 25, { 33 }, 50, { speed = 20 }) }) + local evs2 = m2:submit({ [0] = act(1), [1] = act(1) }) + local first2 = find(evs2, function(e) return e.kind == "move" end) + T.eq(first2 and first2.user, 1, "Gen 1 Counter moves after a slower foe") +end + +do + local user = F.record(t1, 143, { 118 }, 50) + user.moves[1].pp = 60 + local foe = F.record(t1, 143, { 150 }, 100) + foe.moves[1].pp = 60 + foe.hp, foe.maxHp, foe.def, foe.spDef = 700, 700, 600, 600 + local m = match(t1, { user }, { foe }, 77) + local called, over = {}, 0 + for _ = 1, 50 do + if m.phase ~= "choose" then break end + local evs = m:submit({ [0] = act(1), [1] = act(1) }) + for _, e in ipairs(evs) do + if e.kind == "move" and e.user == 0 and e.moveId ~= 118 then + called[#called + 1] = e.moveId + if e.moveId > t1.moveMax then over = over + 1 end + end + end + end + T.check(#called >= 5, "Metronome called moves (" .. #called .. ")") + T.eq(over, 0, "Metronome only calls moves inside the Gen 1 table") +end + +do + local m = match(t1, { F.record(t1, 143, { 63, 33 }, 50) }, { F.record(t1, 143, { 150 }, 100, { def = 600 }) }, 3) + local hb = m:submit({ [0] = act(1), [1] = act(1) }) + if find(hb, function(e) return e.kind == "hp" and e.side == 1 end) then + local l = m:legalActions(0) + T.eq(#l, 2, "a recharging mon has one locked action and forfeit") + T.check(l[1].locked == true, "the action is marked locked") + else + T.check(true, "Hyper Beam missed under this seed") + end +end + +do + local rec = F.record(t1, 143, { 33, 45 }) + rec.moves[1].pp, rec.moves[2].pp = 0, 0 + local m = match(t1, { rec }, { F.record(t1, 143, { 150 }) }) + local l = m:legalActions(0) + T.same(l[1], { kind = "move", slot = 0 }, "a mon with no PP is offered Struggle") + local evs = m:submit({ [0] = act(0), [1] = act(1) }) + T.check(find(evs, function(e) return e.kind == "move" and e.moveId == 165 end) ~= nil, "slot 0 uses Struggle") +end + +do + local m = match(t1, { F.record(t1, 143, { 14 }) }, { F.record(t1, 143, { 150 }) }) + local evs = m:submit({ [0] = act(1), [1] = act(1) }) + local stage = find(evs, function(e) return e.kind == "stage" end) + T.check(stage ~= nil and stage.side == 0 and stage.stat == "attack" and stage.delta == 2, + "Swords Dance reports a structured +2 Attack stage event") + local msg = find(evs, function(e) return e.kind == "msg" and e.id == "STRINGID_ATTACKERSSTATROSE" end) + T.check(msg ~= nil and msg.fill.atk and msg.fill.atk.side == 0 and msg.fill.stat == "attack", + "the stat message carries a key and seat fill, no rendered text") + local used = find(evs, function(e) return e.kind == "msg" and e.id == "STRINGID_USEDMOVE" end) + T.check(used ~= nil and used.fill.currentMove and used.fill.currentMove.move == 14, + "the used-move message names the move by id") +end + +do + local p0 = { F.record(t2, 25, { 226, 33 }), F.record(t2, 143, { 33 }) } + local m = match(t2, p0, { F.record(t2, 143, { 150 }) }) + m:submit({ [0] = act(1), [1] = act(1) }) + T.eq(m.phase, "replace", "Baton Pass waits for the passer's pick") + T.check(m:needs(0) and not m:needs(1), "only the passer picks") + T.same(m:legalActions(0), { { kind = "switch", index = 2 } }, "the bench mon is the only pick") + local evs = m:replace({ [0] = 2 }) + T.check(find(evs, function(e) return e.kind == "sendout" and e.side == 0 and e.index == 2 end) ~= nil, + "the picked mon comes out") + T.eq(m.phase, "choose", "the turn finishes after the pass") +end + +do + local p0 = { F.record(t1, 143, { 33 }, 5), F.record(t1, 25, { 33 }) } + local p1 = { F.record(t1, 143, { 33 }, 100, { atk = 500 }) } + local m = match(t1, p0, p1, 9) + local evs = m:submit({ [0] = act(1), [1] = act(1) }) + T.check(find(evs, function(e) return e.kind == "faint" and e.side == 0 end) ~= nil, "the level 5 lead faints") + T.eq(m.phase, "replace", "a faint with a bench waits for a replacement") + T.check(find(evs, function(e) return e.kind == "need_replacement" and e.side == 0 end) ~= nil, + "need_replacement names the seat") + T.raises(function() m:replace({ [0] = 1 }) end, "illegal", "the fainted mon cannot be sent back") + m:replace({ [0] = 2 }) + T.eq(m:active(0), 2, "the replacement is active") +end + +do + local m = match(t1, { F.record(t1, 143, { 33 }) }, { F.record(t1, 143, { 33 }) }) + local evs = m:submit({ [0] = { kind = "forfeit" }, [1] = act(1) }) + local fin = find(evs, function(e) return e.kind == "end" end) + T.check(fin and fin.result.winner == 1 and fin.result.why == "forfeit", "a forfeit ends the match for the other seat") + T.eq(m.phase, "over", "the match is over") +end + +T.eq(#Scope.trips, 0, "no cache, text or math.random read during these matches " .. tostring(Scope.trips[1])) + +T.finish("g3u mechanics") diff --git a/tests/engine/g3u_scope_test.lua b/tests/engine/g3u_scope_test.lua new file mode 100644 index 00000000..f23a83c2 --- /dev/null +++ b/tests/engine/g3u_scope_test.lua @@ -0,0 +1,131 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local Match = require("src.battle.g3u.Match") +local Scope = require("src.battle.g3u.Scope") + +local M = Scope.modules() +local Pokemon, Moves = M["src.core.game3.pokemon"], M["src.core.game3.battle.moves"] +local Guard = M["src.core.game3.battle.link_guard"] + +Pokemon._types = { [81] = { 13, 8 }, [143] = { 0, 0 } } +Pokemon._names = { [81] = "MAGNEMITE", [143] = "SNORLAX" } +Pokemon._cache = { read = function() return nil end } +Moves._rom = { [33] = { power = 40, type = 0, accuracy = 100, pp = 35, effect = 0, secondaryChance = 0, + target = 0, priority = 0, flags = 51 } } +Moves._romLoaded = true +Moves.BY_NUM[33] = "TACKLE" +Moves._numByName = { TACKLE = 33 } + +local WATCH = { + "src.core.game3.pokemon", "src.core.game3.battle.moves", "src.core.game3.battle.battle_text", + "src.core.game3.battle.types", "src.core.game3.battle.profile", "src.mods.Runtime", + "src.core.game3.battle.adapter", "src.core.game3.rom_text", "src.import.CacheFs", "src.core.game3.dataset", + "src.core.game3.battle.link_guard", "src.core.game3.rng", "src.core.game3.battle.effect_ctx", + "src.core.game3.battle.switch_seq", +} + +local function snap(v, depth, seen) + if type(v) ~= "table" then return v end + if depth > 3 then return v end + if seen[v] then return seen[v] end + local out = { __ref = v, __mt = getmetatable(v) } + seen[v] = out + for k, x in pairs(v) do out[k] = snap(x, depth + 1, seen) end + return out +end + +local function snapshot() + local s = {} + for _, name in ipairs(WATCH) do s[name] = snap(M[name], 0, {}) end + s.math = { random = math.random, randomseed = math.randomseed } + return s +end + +local function deep(a, b, path, seen) + seen = seen or {} + if type(a) ~= type(b) then return false, path end + if type(a) ~= "table" then + if a ~= b and not (a ~= a and b ~= b) then return false, path end + return true + end + if seen[a] then return true end + seen[a] = true + for k, v in pairs(a) do + local ok, where = deep(v, b[k], path .. "." .. tostring(k), seen) + if not ok then return false, where end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true +end + +local t1 = assert(Table.build(F.gen1())) +local before = snapshot() + +local m = Match.new({ table = t1, parties = { [0] = { F.record(t1, 81, { 33, 84 }), F.record(t1, 25, { 33 }) }, + [1] = { F.record(t1, 143, { 33, 89 }) } }, seed = 5 }) +m:start() +T.eq(m.st.player.type1, 13, "Magnemite battles as Electric") +T.eq(m.st.player.type2, nil, "the live Gen 3 Electric/Steel row is not used") +local rnd = F.lcg(3) +local guard = 0 +while m.phase ~= "over" and guard < 200 do + guard = guard + 1 + if m.phase == "choose" then + m:submit({ [0] = F.pick(m, 0, rnd), [1] = F.pick(m, 1, rnd) }) + else + local p = {} + for s = 0, 1 do if m:needs(s) then p[s] = m:legalActions(s)[1].index end end + m:replace(p) + end +end +T.eq(m.phase, "over", "the match finished") + +local after = snapshot() +local ok, where = deep(before, after, "") +T.check(ok, "every swapped module is restored after a match (first difference " .. tostring(where) .. ")") +T.same(Pokemon.types(81), { 13, 8 }, "the live Gen 3 typing is back after the match") +T.eq(Moves.constName(33), "TACKLE", "the live move names are back after the match") +T.eq(Pokemon.name(81), "MAGNEMITE", "the live species names are back after the match") + +local okErr, err = pcall(Scope.run, t1, function() error("boom") end) +T.check(not okErr and tostring(err):find("boom", 1, true), "an error inside the scope propagates") +local ok2, where2 = deep(before, snapshot(), "") +T.check(ok2, "an error inside the scope still restores everything (" .. tostring(where2) .. ")") + +local Engine = M["src.core.game3.battle.engine"] +local real = Engine.planTurnFromActions +local m2 = Match.new({ table = t1, parties = { [0] = { F.record(t1, 81, { 33 }) }, [1] = { F.record(t1, 143, { 33 }) } }, + seed = 1 }) +m2:start() +Engine.planTurnFromActions = function() error("mid-turn failure") end +local okMid = pcall(m2.submit, m2, { [0] = { kind = "move", slot = 1 }, [1] = { kind = "move", slot = 1 } }) +Engine.planTurnFromActions = real +T.check(not okMid, "a failure in the middle of a turn raises") +local ok3, where3 = deep(before, snapshot(), "") +T.check(ok3, "a failure in the middle of a turn still restores everything (" .. tostring(where3) .. ")") + +Scope.resetTrips() +local RomText = M["src.core.game3.rom_text"] +T.raises(function() Scope.run(t1, function() return RomText.plain("sText_FoePkmnPrefix") end) end, + "g3u scope", "a RomText read during a match raises") +T.raises(function() Scope.run(t1, function() return math.random(1, 6) end) end, "math.random", + "math.random during a match raises") +T.raises(function() Scope.run(t1, function() return M["src.import.CacheFs"].readAt("red/data/generated/moves.lua") end) end, + "CacheFs", "a cache file read during a match raises") +T.raises(function() Scope.run(t1, function() return M["src.core.game3.dataset"].cache() end) end, + "Dataset.cache", "a dataset cache lookup during a match raises") +T.raises(function() Scope.run(t1, function() return Pokemon._cache:read("x") end) end, + "cache:read", "a species pack read during a match raises") +local caught = Scope.run(t1, function() return (pcall(RomText.plain, "sText_FoePkmnPrefix")) end) +T.eq(caught, false, "a guarded read stays an error under pcall") +T.check(#Scope.trips >= 6, "every guarded read is recorded even when a pcall swallows it") +T.eq(Scope.run(t1, function() return Guard.active end), true, "link_guard is armed inside a match") +T.eq(Guard.active, false, "link_guard is disarmed again afterwards") +T.check(type(math.random()) == "number", "math.random works again after the scope") + +T.finish("g3u scope") diff --git a/tests/engine/g3u_table_test.lua b/tests/engine/g3u_table_test.lua new file mode 100644 index 00000000..3d422320 --- /dev/null +++ b/tests/engine/g3u_table_test.lua @@ -0,0 +1,250 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local Map1 = require("src.battle.g3u.EffectMap1") +local Map2 = require("src.battle.g3u.EffectMap2") +local E = require("src.core.game3.battle.effect_ids") +local Effects = require("src.core.game3.battle.effects") +local Json = require("src.link.Json") +local Wire = require("src.battle.g3u.Wire") + +local GEN1_CONSTANTS = { + "NO_ADDITIONAL_EFFECT", "EFFECT_01", "POISON_SIDE_EFFECT1", "DRAIN_HP_EFFECT", "BURN_SIDE_EFFECT1", + "FREEZE_SIDE_EFFECT1", "PARALYZE_SIDE_EFFECT1", "EXPLODE_EFFECT", "DREAM_EATER_EFFECT", "MIRROR_MOVE_EFFECT", + "ATTACK_UP1_EFFECT", "DEFENSE_UP1_EFFECT", "SPEED_UP1_EFFECT", "SPECIAL_UP1_EFFECT", "ACCURACY_UP1_EFFECT", + "EVASION_UP1_EFFECT", "PAY_DAY_EFFECT", "SWIFT_EFFECT", "ATTACK_DOWN1_EFFECT", "DEFENSE_DOWN1_EFFECT", + "SPEED_DOWN1_EFFECT", "SPECIAL_DOWN1_EFFECT", "ACCURACY_DOWN1_EFFECT", "EVASION_DOWN1_EFFECT", + "CONVERSION_EFFECT", "HAZE_EFFECT", "BIDE_EFFECT", "THRASH_PETAL_DANCE_EFFECT", "SWITCH_AND_TELEPORT_EFFECT", + "TWO_TO_FIVE_ATTACKS_EFFECT", "EFFECT_1E", "FLINCH_SIDE_EFFECT1", "SLEEP_EFFECT", "POISON_SIDE_EFFECT2", + "BURN_SIDE_EFFECT2", "FREEZE_SIDE_EFFECT2", "PARALYZE_SIDE_EFFECT2", "FLINCH_SIDE_EFFECT2", "OHKO_EFFECT", + "CHARGE_EFFECT", "SUPER_FANG_EFFECT", "SPECIAL_DAMAGE_EFFECT", "TRAPPING_EFFECT", "FLY_EFFECT", + "ATTACK_TWICE_EFFECT", "JUMP_KICK_EFFECT", "MIST_EFFECT", "FOCUS_ENERGY_EFFECT", "RECOIL_EFFECT", + "CONFUSION_EFFECT", "ATTACK_UP2_EFFECT", "DEFENSE_UP2_EFFECT", "SPEED_UP2_EFFECT", "SPECIAL_UP2_EFFECT", + "ACCURACY_UP2_EFFECT", "EVASION_UP2_EFFECT", "HEAL_EFFECT", "TRANSFORM_EFFECT", "ATTACK_DOWN2_EFFECT", + "DEFENSE_DOWN2_EFFECT", "SPEED_DOWN2_EFFECT", "SPECIAL_DOWN2_EFFECT", "ACCURACY_DOWN2_EFFECT", + "EVASION_DOWN2_EFFECT", "LIGHT_SCREEN_EFFECT", "REFLECT_EFFECT", "POISON_EFFECT", "PARALYZE_EFFECT", + "ATTACK_DOWN_SIDE_EFFECT", "DEFENSE_DOWN_SIDE_EFFECT", "SPEED_DOWN_SIDE_EFFECT", "SPECIAL_DOWN_SIDE_EFFECT", + "CONFUSION_SIDE_EFFECT", "TWINEEDLE_EFFECT", "SUBSTITUTE_EFFECT", "HYPER_BEAM_EFFECT", "RAGE_EFFECT", + "MIMIC_EFFECT", "METRONOME_EFFECT", "LEECH_SEED_EFFECT", "SPLASH_EFFECT", "DISABLE_EFFECT", +} + +local GEN2_CONSTANTS = { + "EFFECT_NORMAL_HIT", "EFFECT_SLEEP", "EFFECT_POISON_HIT", "EFFECT_LEECH_HIT", "EFFECT_BURN_HIT", + "EFFECT_FREEZE_HIT", "EFFECT_PARALYZE_HIT", "EFFECT_SELFDESTRUCT", "EFFECT_DREAM_EATER", "EFFECT_MIRROR_MOVE", + "EFFECT_ATTACK_UP", "EFFECT_DEFENSE_UP", "EFFECT_SPEED_UP", "EFFECT_SP_ATK_UP", "EFFECT_SP_DEF_UP", + "EFFECT_ACCURACY_UP", "EFFECT_EVASION_UP", "EFFECT_ALWAYS_HIT", "EFFECT_ATTACK_DOWN", "EFFECT_DEFENSE_DOWN", + "EFFECT_SPEED_DOWN", "EFFECT_SP_ATK_DOWN", "EFFECT_SP_DEF_DOWN", "EFFECT_ACCURACY_DOWN", "EFFECT_EVASION_DOWN", + "EFFECT_RESET_STATS", "EFFECT_BIDE", "EFFECT_RAMPAGE", "EFFECT_FORCE_SWITCH", "EFFECT_MULTI_HIT", + "EFFECT_CONVERSION", "EFFECT_FLINCH_HIT", "EFFECT_HEAL", "EFFECT_TOXIC", "EFFECT_PAY_DAY", "EFFECT_LIGHT_SCREEN", + "EFFECT_TRI_ATTACK", "EFFECT_UNUSED_25", "EFFECT_OHKO", "EFFECT_RAZOR_WIND", "EFFECT_SUPER_FANG", + "EFFECT_STATIC_DAMAGE", "EFFECT_TRAP_TARGET", "EFFECT_UNUSED_2B", "EFFECT_DOUBLE_HIT", "EFFECT_JUMP_KICK", + "EFFECT_MIST", "EFFECT_FOCUS_ENERGY", "EFFECT_RECOIL_HIT", "EFFECT_CONFUSE", "EFFECT_ATTACK_UP_2", + "EFFECT_DEFENSE_UP_2", "EFFECT_SPEED_UP_2", "EFFECT_SP_ATK_UP_2", "EFFECT_SP_DEF_UP_2", "EFFECT_ACCURACY_UP_2", + "EFFECT_EVASION_UP_2", "EFFECT_TRANSFORM", "EFFECT_ATTACK_DOWN_2", "EFFECT_DEFENSE_DOWN_2", + "EFFECT_SPEED_DOWN_2", "EFFECT_SP_ATK_DOWN_2", "EFFECT_SP_DEF_DOWN_2", "EFFECT_ACCURACY_DOWN_2", + "EFFECT_EVASION_DOWN_2", "EFFECT_REFLECT", "EFFECT_POISON", "EFFECT_PARALYZE", "EFFECT_ATTACK_DOWN_HIT", + "EFFECT_DEFENSE_DOWN_HIT", "EFFECT_SPEED_DOWN_HIT", "EFFECT_SP_ATK_DOWN_HIT", "EFFECT_SP_DEF_DOWN_HIT", + "EFFECT_ACCURACY_DOWN_HIT", "EFFECT_EVASION_DOWN_HIT", "EFFECT_SKY_ATTACK", "EFFECT_CONFUSE_HIT", + "EFFECT_POISON_MULTI_HIT", "EFFECT_UNUSED_4E", "EFFECT_SUBSTITUTE", "EFFECT_HYPER_BEAM", "EFFECT_RAGE", + "EFFECT_MIMIC", "EFFECT_METRONOME", "EFFECT_LEECH_SEED", "EFFECT_SPLASH", "EFFECT_DISABLE", + "EFFECT_LEVEL_DAMAGE", "EFFECT_PSYWAVE", "EFFECT_COUNTER", "EFFECT_ENCORE", "EFFECT_PAIN_SPLIT", "EFFECT_SNORE", + "EFFECT_CONVERSION2", "EFFECT_LOCK_ON", "EFFECT_SKETCH", "EFFECT_DEFROST_OPPONENT", "EFFECT_SLEEP_TALK", + "EFFECT_DESTINY_BOND", "EFFECT_REVERSAL", "EFFECT_SPITE", "EFFECT_FALSE_SWIPE", "EFFECT_HEAL_BELL", + "EFFECT_PRIORITY_HIT", "EFFECT_TRIPLE_KICK", "EFFECT_THIEF", "EFFECT_MEAN_LOOK", "EFFECT_NIGHTMARE", + "EFFECT_FLAME_WHEEL", "EFFECT_CURSE", "EFFECT_UNUSED_6E", "EFFECT_PROTECT", "EFFECT_SPIKES", "EFFECT_FORESIGHT", + "EFFECT_PERISH_SONG", "EFFECT_SANDSTORM", "EFFECT_ENDURE", "EFFECT_ROLLOUT", "EFFECT_SWAGGER", + "EFFECT_FURY_CUTTER", "EFFECT_ATTRACT", "EFFECT_RETURN", "EFFECT_PRESENT", "EFFECT_FRUSTRATION", + "EFFECT_SAFEGUARD", "EFFECT_SACRED_FIRE", "EFFECT_MAGNITUDE", "EFFECT_BATON_PASS", "EFFECT_PURSUIT", + "EFFECT_RAPID_SPIN", "EFFECT_UNUSED_82", "EFFECT_UNUSED_83", "EFFECT_MORNING_SUN", "EFFECT_SYNTHESIS", + "EFFECT_MOONLIGHT", "EFFECT_HIDDEN_POWER", "EFFECT_RAIN_DANCE", "EFFECT_SUNNY_DAY", "EFFECT_DEFENSE_UP_HIT", + "EFFECT_ATTACK_UP_HIT", "EFFECT_ALL_UP_HIT", "EFFECT_FAKE_OUT", "EFFECT_BELLY_DRUM", "EFFECT_PSYCH_UP", + "EFFECT_MIRROR_COAT", "EFFECT_SKULL_BASH", "EFFECT_TWISTER", "EFFECT_EARTHQUAKE", "EFFECT_FUTURE_SIGHT", + "EFFECT_GUST", "EFFECT_STOMP", "EFFECT_SOLARBEAM", "EFFECT_THUNDER", "EFFECT_TELEPORT", "EFFECT_BEAT_UP", + "EFFECT_FLY", "EFFECT_DEFENSE_CURL", +} + +local function pretConstants(path, pattern) + local f = io.open(path, "rb") + if not f then return nil end + local out = {} + for line in f:lines() do + local name = line:match("^%s*const%s+(" .. pattern .. ")") + if name then out[#out + 1] = name end + end + f:close() + return out +end + +local function coverage(label, list, map) + local mapped, unsupported = 0, 0 + for _, name in ipairs(list) do + local m, u = map.EFFECTS[name] ~= nil, map.UNSUPPORTED[name] ~= nil + T.check(m ~= u, label .. " " .. name .. " is mapped or listed unsupported, not both") + if m then mapped = mapped + 1 end + if u then unsupported = unsupported + 1 end + end + local known = {} + for _, name in ipairs(list) do known[name] = true end + for name in pairs(map.EFFECTS) do T.check(known[name], label .. " map key " .. name .. " is a real constant") end + for name in pairs(map.UNSUPPORTED) do T.check(known[name], label .. " unsupported " .. name .. " is a real constant") end + return mapped, unsupported +end + +local m1, u1 = coverage("gen1", GEN1_CONSTANTS, Map1) +local m2, u2 = coverage("gen2", GEN2_CONSTANTS, Map2) +T.eq(m1 + u1, #GEN1_CONSTANTS, "every Gen 1 effect constant is accounted for") +T.eq(m2 + u2, #GEN2_CONSTANTS, "every Gen 2 effect constant is accounted for") + +local red = pretConstants("../pokered/constants/move_effect_constants.asm", "[%w_]+") +if red then T.same(red, GEN1_CONSTANTS, "Gen 1 constant list matches pokered") else print("[skip] ../pokered not checked out") end +local crystal = pretConstants("../pokecrystal/constants/move_effect_constants.asm", "EFFECT_[%w_]+") +if crystal then T.same(crystal, GEN2_CONSTANTS, "Gen 2 constant list matches pokecrystal") else print("[skip] ../pokecrystal not checked out") end + +local function engineHandles(effect) + local setup = E.STATUS_SETUP[effect] + if setup == "EXP_STAT_FROM_EFFECT" then return E.STAT_CHANGES[effect] ~= nil end + if setup then return Effects.get(setup) ~= nil end + return true +end + +local STATUS_ONLY = { + [E.SPEED_UP] = true, [E.SPECIAL_DEFENSE_UP] = true, [E.ACCURACY_UP] = true, [E.SPECIAL_ATTACK_DOWN] = true, + [E.SPECIAL_DEFENSE_DOWN] = true, [E.ACCURACY_UP_2] = true, [E.EVASION_UP_2] = true, + [E.SPECIAL_ATTACK_DOWN_2] = true, [E.ACCURACY_DOWN_2] = true, [E.EVASION_DOWN_2] = true, +} +for _, map in ipairs({ Map1, Map2 }) do + for name, row in pairs(map.EFFECTS) do + T.check(engineHandles(row.effect) and not STATUS_ONLY[row.effect], + "gen" .. map.GEN .. " " .. name .. " maps to an effect the engine runs (" .. row.effect .. ")") + end + for id, row in pairs(map.MOVES) do + T.check(engineHandles(row.effect), "gen" .. map.GEN .. " move " .. id .. " exception maps to a handled effect") + end +end +for id in pairs(STATUS_ONLY) do + T.check(E.STATUS_SETUP[id] == nil, "Gen 3 status effect " .. id .. " has no engine handler, so it stays unsupported") +end + +local d1 = F.gen1() +local t1, why = Table.build(d1) +T.check(t1 ~= nil, "Gen 1 fixture builds a table " .. tostring(why)) +T.eq(#t1.moves, 165, "Gen 1 table has 165 move rows") +T.eq(#t1.species, 151, "Gen 1 table has 151 species rows") +T.same(t1.moves[44], { 60, 0, 100, 25, E.FLINCH_HIT, 10, 0, 0, 18 }, "Bite: Normal, 60 power, FLINCH_HIT, 10 percent") +T.eq(t1.moves[40][6], 20, "Poison Sting poisons 20 percent (pokered effects.asm:101)") +T.eq(t1.moves[123][6], 40, "Smog poisons 40 percent (pokered effects.asm:104)") +T.eq(t1.moves[34][6], 30, "Body Slam paralyzes 30 percent") +T.eq(t1.moves[51][6], 33, "Acid lowers Defense 33 percent (pokered effects.asm:562)") +T.eq(t1.moves[94][5], E.SPECIAL_DEFENSE_DOWN_HIT, "Psychic's Special drop lowers Sp. Def") +T.eq(t1.moves[41][6], 20, "Twineedle poisons 20 percent (pokered effects.asm:967)") +T.eq(t1.moves[20][6], 100, "Bind traps every hit, as Gen 3 trapping moves do") +T.eq(t1.moves[98][8], 1, "Gen 1 Quick Attack moves first") +T.eq(t1.moves[68][5], E.COUNTER, "Gen 1 Counter is a Counter, not a plain hit") +T.eq(t1.moves[68][8], -1, "Gen 1 Counter moves last") +T.eq(t1.moves[2][5], E.HIGH_CRITICAL, "Karate Chop keeps its high critical ratio") +T.eq(t1.moves[91][5], E.SEMI_INVULNERABLE, "Dig digs") +T.eq(t1.moves[156][5], E.REST, "Rest is Rest") +T.eq(t1.moves[92][5], E.TOXIC, "Toxic badly poisons") +T.eq(t1.moves[14][7], 16, "Swords Dance targets the user") +T.same(t1.species[81], { 13, 13, t1.species[81][3], t1.species[81][4], t1.species[81][5], t1.species[81][6], + t1.species[81][7], t1.species[81][7] }, "Gen 1 Magnemite is pure Electric with one Special") +T.eq(#t1.illegal, 0, "the Gen 1 fixture has no unsupported moves") +T.check(Table.validate(t1, 1), "the Gen 1 fixture table validates") + +local t2 = assert(Table.build(F.gen2())) +T.same(Table.speciesTypes(t2, 81), { 13, 8 }, "Gen 2 Magnemite is Electric/Steel") +T.eq(t2.moves[44][2], 17, "Gen 2 Bite is Dark") +T.eq(t2.moves[44][6], 30, "Gen 2 Bite flinches 30 percent") +T.eq(t2.moves[182][8], 2, "Gen 2 Protect priority 3 is Gen 3 +2") +T.eq(t2.moves[98][8], 1, "Gen 2 Quick Attack priority 2 is Gen 3 +1") +T.eq(t2.moves[18][8], -1, "Gen 2 Whirlwind priority 0 is Gen 3 -1") +T.eq(t2.moves[233][8], -1, "Gen 2 Vital Throw goes last") +T.eq(t2.moves[107][5], E.MINIMIZE, "Gen 2 Minimize sets the minimize flag") +T.eq(t2.moves[23][5], E.FLINCH_MINIMIZE_HIT, "Gen 2 Stomp punishes Minimize") +T.eq(t2.moves[174][2], 9, "Gen 2 Curse is the ??? type") +T.eq(t2.moves[20][6], 100, "Gen 2 Bind traps every hit") +T.check(Table.validate(t2, 2), "the Gen 2 fixture table validates") +T.check(not Table.validate(t2, 1), "a Gen 2 table is refused where Gen 1 is expected") + +local withBad = F.gen1({ [5] = { id = 5, type = "NORMAL", power = 0, accuracy = 100, pp = 20, effect = "SPEED_UP1_EFFECT" } }) +local tb = assert(Table.build(withBad)) +T.same(tb.illegal, { 5 }, "a move with an unsupported effect is listed illegal") +local un = Table.unsupportedMoves(withBad) +T.eq(#un, 1, "unsupportedMoves reports it") +T.eq(un[1] and un[1].id, 5, "unsupportedMoves names the move id") +T.eq(un[1] and un[1].effect, "SPEED_UP1_EFFECT", "unsupportedMoves names the effect") +local unknown = F.gen1({ [5] = { id = 5, type = "NORMAL", power = 0, accuracy = 100, pp = 20, effect = "MADE_UP" } }) +T.check(Table.build(unknown) == nil, "an unknown effect constant refuses the table") + +local function copy(v) + if type(v) ~= "table" then return v end + local o = {} + for k, x in pairs(v) do o[k] = copy(x) end + return o +end + +local MUTATIONS = { + { "wrong version", function(t) t.v = 2 end }, + { "unknown key", function(t) t.extra = 1 end }, + { "short move list", function(t) t.moves[165] = nil end }, + { "extra move row", function(t) t.moves[166] = { 1, 0, 100, 10, 0, 0, 0, 0, 0 } end }, + { "power out of range", function(t) t.moves[1][1] = 300 end }, + { "fractional accuracy", function(t) t.moves[1][3] = 99.5 end }, + { "NaN pp", function(t) t.moves[1][4] = 0 / 0 end }, + { "zero pp", function(t) t.moves[1][4] = 0 end }, + { "Dark type in a Gen 1 table", function(t) t.moves[1][2] = 17 end }, + { "unknown effect id", function(t) t.moves[1][5] = 213 end }, + { "chance over 100", function(t) t.moves[1][6] = 101 end }, + { "unknown target", function(t) t.moves[1][7] = 32 end }, + { "priority out of range", function(t) t.moves[1][8] = 7 end }, + { "short row", function(t) t.moves[1][9] = nil end }, + { "string in a row", function(t) t.moves[1][1] = "40" end }, + { "short species list", function(t) t.species[151] = nil end }, + { "zero base stat", function(t) t.species[1][3] = 0 end }, + { "species type out of set", function(t) t.species[1][1] = 8 end }, + { "unsorted illegal list", function(t) t.illegal = { 5, 4 } end }, + { "illegal id out of range", function(t) t.illegal = { 200 } end }, + { "wrong moveMax", function(t) t.moveMax = 251 end }, + { "bad gen", function(t) t.gen = 3 end }, +} +for _, mu in ipairs(MUTATIONS) do + local t = copy(t1) + mu[2](t) + local ok = Table.validate(t, 1) + T.check(not ok, "validate rejects a table with " .. mu[1]) +end +T.check(not Table.validate("x"), "validate rejects a non-table") + +local bytes = #Json.encode(Wire.table(t2)) +T.check(bytes <= Wire.MAX_BYTES.g3u_table, "a Gen 2 table fits the g3u_table byte cap (" .. bytes .. ")") +local round = Json.decode(Json.encode(t1)) +T.check(Table.validate(round, 1), "a Gen 1 table survives a JSON round trip") + +for _, pair in ipairs({ { "red", "g1r-red", 1 }, { "gold", "g1r-gold", 2 } }) do + local data = F.real(pair[1], pair[2]) + if data then + local t, err = Table.build(data) + T.check(t ~= nil, pair[1] .. " cache builds a table " .. tostring(err)) + if t then + T.check(Table.validate(t, pair[3]), pair[1] .. " table validates") + T.eq(#t.illegal, 0, pair[1] .. " has no unsupported moves") + local bite = t.moves[44] + if pair[3] == 1 then + T.same({ bite[2], bite[5], bite[6] }, { 0, E.FLINCH_HIT, 10 }, "red Bite is Normal with a 10 percent flinch") + T.same(Table.speciesTypes(t, 81), { 13, 13 }, "red Magnemite is pure Electric") + T.eq(t.species[65][7], t.species[65][8], "red Alakazam has one Special for both sides") + else + T.same({ bite[2], bite[5], bite[6] }, { 17, E.FLINCH_HIT, 30 }, "gold Bite is Dark with a 30 percent flinch") + T.same(Table.speciesTypes(t, 81), { 13, 8 }, "gold Magnemite is Electric/Steel") + end + local size = #Json.encode(Wire.table(t)) + T.check(size <= Wire.MAX_BYTES.g3u_table, pair[1] .. " table fits the byte cap (" .. size .. ")") + end + else + print("[skip] " .. pair[1] .. " cache not imported") + end +end + +T.finish("g3u table") diff --git a/tests/engine/g3u_wire_test.lua b/tests/engine/g3u_wire_test.lua new file mode 100644 index 00000000..95d78116 --- /dev/null +++ b/tests/engine/g3u_wire_test.lua @@ -0,0 +1,104 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local Table = require("src.battle.g3u.Table") +local Wire = require("src.battle.g3u.Wire") +local Rng = require("src.battle.g3u.Rng") +local Hash = require("src.battle.g3u.Hash") +local Json = require("src.link.Json") + +local t1 = assert(Table.build(F.gen1())) + +local function rt(m) return Json.decode(Json.encode(m)) end +local function valid(m, ctx) + local raw = Json.encode(m) + ctx = ctx or {} + ctx.bytes = #raw + return Wire.validate(Json.decode(raw), ctx) +end + +T.check(valid(Wire.table(t1), { gen = 1 }), "a built table passes g3u_table") +T.check(not valid(Wire.table(t1), { gen = 2 }), "g3u_table refuses a table of the wrong gen") +local big = Wire.table(t1) +T.check(not Wire.validate(rt(big), { gen = 1, bytes = Wire.MAX_BYTES.g3u_table + 1 }), "g3u_table over the byte cap is refused") + +local party = { F.record(t1, 25, { 84, 85 }, 50, { nickname = "SPARKY" }), F.record(t1, 143, { 33 }) } +T.check(valid(Wire.party(party), { table = t1 }), "a sane party passes g3u_party") +T.check(Json.encode(Wire.party(party)):len() < Wire.MAX_BYTES.g3u_party, "a party fits its byte cap") +local six = {} +for i = 1, 6 do six[i] = F.record(t1, 150, { 94, 105, 86, 85 }, 100, { nickname = "MEWTWO" .. i }) end +T.check(#Json.encode(Wire.party(six)) <= Wire.MAX_BYTES.g3u_party, "a full six-mon party fits its byte cap") + +local function bad(label, mutate) + local p = rt(Wire.party(party)) + mutate(p.records[1], p) + T.check(not valid(p, { table = t1 }), "g3u_party refuses " .. label) +end +bad("species 0", function(r) r.species = 0 end) +bad("a species past the dex", function(r) r.species = 152 end) +bad("level 101", function(r) r.level = 101 end) +bad("hp above max", function(r) r.hp = r.maxHp + 1 end) +bad("a stat past the level cap", function(r) r.atk = 9999 end) +bad("fractional speed", function(r) r.speed = 10.5 end) +bad("no moves", function(r) r.moves = {} end) +bad("five moves", function(r) for i = 1, 5 do r.moves[i] = { id = i, pp = 1, ppUps = 0 } end end) +bad("a move past the table", function(r) r.moves[1].id = 166 end) +bad("a duplicate move", function(r) r.moves[2] = { id = r.moves[1].id, pp = 1, ppUps = 0 } end) +bad("too much pp", function(r) r.moves[1].pp = 64 end) +bad("four pp ups", function(r) r.moves[1].ppUps = 4 end) +bad("a long nickname", function(r) r.nickname = "ABCDEFGHIJK" end) +bad("an unknown key", function(r) r.item = 3 end) +bad("an iv of 32", function(r) r.ivs.hp = 32 end) +bad("gender 3", function(r) r.gender = 3 end) +bad("seven mons", function(_, p) for i = 3, 7 do p.records[i] = p.records[1] end end) +T.check(not Wire.validate(rt(Wire.party(party)), {}), "g3u_party needs the match table to check against") + +local cases = { + { Wire.action(3, { kind = "move", slot = 2 }), true, "a move action" }, + { Wire.action(3, { kind = "move", slot = 0 }), true, "a Struggle action" }, + { Wire.action(3, { kind = "switch", index = 4 }), true, "a switch action" }, + { Wire.action(3, { kind = "forfeit" }), true, "a forfeit" }, + { { type = "g3u_action", turn = 3, kind = "move", slot = 5 }, false, "slot 5" }, + { { type = "g3u_action", turn = 0, kind = "move", slot = 1 }, false, "turn 0" }, + { { type = "g3u_action", turn = 3, kind = "switch", index = 7 }, false, "switch index 7" }, + { { type = "g3u_action", turn = 3, kind = "run" }, false, "a run action" }, + { { type = "g3u_action", turn = 3, kind = "move", slot = 1, index = 2 }, false, "a move with an index" }, + { Wire.replace(4, 2), true, "a replacement" }, + { { type = "g3u_replace", turn = 4, index = 0 }, false, "replacement index 0" }, + { Wire.hash(4, "0a1b2c3d"), true, "a hash" }, + { { type = "g3u_hash", turn = 4, hash = "xyz" }, false, "a malformed hash" }, + { Wire.bye("desync"), true, "a desync bye" }, + { { type = "g3u_bye", why = "because" }, false, "an unknown bye reason" }, + { { type = "g3u_other" }, false, "an unknown inner type" }, +} +for _, c in ipairs(cases) do + local got = valid(c[1]) and true or false + T.eq(got, c[2], "validator on " .. c[3]) + if c[2] then + T.check(#Json.encode(c[1]) <= Wire.MAX_BYTES[c[1].type], c[3] .. " fits its byte cap") + end +end +T.same(Wire.toAction(Wire.action(2, { kind = "switch", index = 3 })), { kind = "switch", index = 3 }, + "toAction turns a wire action back into a Match action") +T.same(Wire.TYPES, { "g3u_table", "g3u_party", "g3u_action", "g3u_replace", "g3u_hash", "g3u_bye" }, + "the inner message list the relay must allow") + +local LB = require("src.core.game3.link.battle") +T.eq(LB.makeRng, Rng.make, "link battles still use the same LCG") +local a, b = Rng.make(0xC0FFEE), LB.makeRng(0xC0FFEE) +local seq = {} +for i = 1, 8 do seq[i] = a(0, 99) end +local seqB = {} +for i = 1, 8 do seqB[i] = b(0, 99) end +T.same(seq, seqB, "the extracted LCG draws the same sequence") +T.same(seq, { 27, 84, 66, 52, 16, 87, 54, 47 }, "ISO_RANDOMIZE1 from 0xC0FFEE keeps the draws HEAD's LB.makeRng made") +local counter = { n = 0 } +local c = Rng.make(1, counter) +c(1, 6); c(); c(10) +T.eq(counter.n, 3, "the draw counter counts every draw") +T.eq(LB.fnv, Hash.fnv, "link battles hash with the shared fnv") +T.eq(Hash.fnv("abc"), LB.fnv("abc"), "fnv agrees") +T.same(LB.HASH_PARTS, Hash.PARTS, "link battles keep the same hash parts") + +T.finish("g3u wire") diff --git a/tests/engine/game3_union_rs_trainer_pic_test.lua b/tests/engine/game3_union_rs_trainer_pic_test.lua new file mode 100644 index 00000000..fc37e8be --- /dev/null +++ b/tests/engine/game3_union_rs_trainer_pic_test.lua @@ -0,0 +1,27 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") + +local files = {} +package.loaded["src.core.game3.dataset"] = { + cache = function() + return { read = function(_, rel) return files[rel] end } + end, +} + +local LB = require("src.core.game3.link.battle") +LB._unionRoomClasses = nil +LB.unionRoom = true + +local ok, pic = pcall(LB.peerPicId, { gender = 1, trainerId = 3 }) +T.check(ok, "a union battle on a cart with no union room classes picks a picture (" .. tostring(not ok and pic or "") .. ")") +T.eq(ok and pic, LB.TRAINER_PIC_LEAF, "it uses the cable club link picture") +T.check(not LB.hasUnionRoomClasses(), "the cart has no union room classes") + +files["data/generated/gba/trainers/union_room_classes.lua"] = + "return { trainerPic = { [11] = 77 }, trainerClass = { [11] = 5 } }" +T.check(LB.hasUnionRoomClasses(), "a cart with union room classes is seen") +T.eq(LB.peerPicId({ gender = 1, trainerId = 3 }), 77, "and its union room picture is used") + +LB.unionRoom = false +T.finish() diff --git a/tests/engine/rs_link_records_visible_test.lua b/tests/engine/rs_link_records_visible_test.lua new file mode 100644 index 00000000..3f9effa9 --- /dev/null +++ b/tests/engine/rs_link_records_visible_test.lua @@ -0,0 +1,22 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Records = require("src.ui.game3.rs.link_records") +local vm = {} +package.loaded["src.core.game3.scripting.space"] = { vm = vm } + +check(type(Records.isVisible) == "function", "ui_pass can ask the link records window if it is up") +eq(Records.isVisible(), false, "hidden before the 2F record machine is read") +Records.show({ gameStats = {}, linkBattleRecords = {} }) +eq(Records.isVisible(), true, "shown while the machine's script runs") +package.loaded["src.core.game3.scripting.space"] = { vm = {} } +eq(Records.isVisible(), false, "a new script owner closes it") +Records.show({}) +Records.eraseBox(0, 0, 29, 19) +eq(Records.isVisible(), false, "erasing the box closes it") + +package.loaded["src.core.game3.scripting.space"] = nil +T.finish("rs_link_records_visible") diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index 7cd10e51..686181e4 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -137,7 +137,7 @@ local SaveConvert = require("src.save_convert.SaveConvert") -- double-quoted registry name anywhere in the test corpus as that registry's -- unit test, and this suite is not the mod audio registry's. local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio', 'encounters', - 'field', 'trainerHeaders' } + 'field', 'trainerHeaders', 'text_pointers' } local function prefixes() local seen = {} diff --git a/tests/engine/save_slot_migrate_trade_journal_test.lua b/tests/engine/save_slot_migrate_trade_journal_test.lua new file mode 100644 index 00000000..8ca04af9 --- /dev/null +++ b/tests/engine/save_slot_migrate_trade_journal_test.lua @@ -0,0 +1,56 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local Txn = require("src.online.union.TradeTxn") + +local realFS = love.filesystem + +local function fresh(version) + local files = {} + love.filesystem = { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + createDirectory = function() return true end, + getSaveDirectory = function() return "/fake/save" end, + } + SaveData.resetSlotState() + GameVersion.set(version) + return files +end + +for _, version in ipairs({ "red", "firered" }) do + local files = fresh(version) + local flat = SaveData.saveFilename(version) + local base = flat:gsub("%.lua$", "") + files[flat] = SaveSerializer.encode({ version = version, engine = version == "firered" and "game3" or nil, + player = { name = "RED" }, party = {} }) + files[base .. "_xtrade.lua"] = SaveSerializer.encode({ v = 1, entries = { { key = "r1:1:abcd", state = "committed" } } }) + files[base .. "_trade.lua"] = SaveSerializer.encode({ v = 1, entries = { { room = "r2", digest = "ff" } } }) + SaveData.resetSlotState() + local main = SaveData.saveFilename(version) + T.check(main ~= flat, version .. ": the flat save moved into a slot (" .. tostring(main) .. ")") + local pending = Txn.readJournal(version) + T.eq(#pending, 1, version .. ": the committed cross-gen trade journal follows the save") + T.eq(pending[1] and pending[1].key, "r1:1:abcd", version .. ": with its entry") + T.eq(files[base .. "_xtrade.lua"], nil, version .. ": no orphan journal stays at the old path") + local nbase = main:gsub("%.lua$", "") + T.check(files[nbase .. "_trade.lua"] ~= nil, version .. ": the link trade journal follows too") + T.eq(files[base .. "_trade.lua"], nil, version .. ": and leaves the old path") +end + +love.filesystem = realFS +T.finish() diff --git a/tests/engine/union_battle_launch_test.lua b/tests/engine/union_battle_launch_test.lua new file mode 100644 index 00000000..bd969883 --- /dev/null +++ b/tests/engine/union_battle_launch_test.lua @@ -0,0 +1,158 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local L = require("tests.support.g3u_loopback") +local Table = require("src.battle.g3u.Table") +local BS = require("src.online.union.BattleSession") + +local data = F.gen1() +local t = assert(Table.build(data, 1)) + +local function stack() + local s = { states = {} } + function s:push(st) self.states[#self.states + 1] = st if st.enter then st:enter() end end + function s:pop() return table.remove(self.states) end + function s:top() return self.states[#self.states] end + return s +end + +local started = {} +package.loaded["src.ui.g3u.Gen1Screen"] = { + start = function(game, bs, opts) + started[#started + 1] = { game = game, bs = bs, opts = opts } + return { screen = true } + end, +} + +local Launch = require("src.ui.g3u.Launch") + +T.eq(select(2, Launch.start({}, 4, { net = {} })), "bad_gen", "unknown gen is refused") +T.eq(select(2, Launch.start({}, 1, {})), "no_session", "missing net is refused") +T.eq(select(2, Launch.start({}, 1, { net = {}, ruleset = "x" })), "bad_ruleset", "unknown ruleset is refused") + +do + local na, nb = L.pair() + local results = {} + local h, why = Launch.start({ stack = stack() }, 1, { + ruleset = "g3u", net = na, seat = 0, go = { seed = 5, size = 6 }, gens = { [0] = 1, [1] = 3 }, + data = data, records = F.randomParty(t, L.lcg(2), 2), names = { [0] = "RED", [1] = "MAY" }, + onDone = function(r) results[#results + 1] = r end, + }) + T.check(h ~= nil, "g3u launch returns a handle (" .. tostring(why) .. ")") + T.eq(h and h.kind, "g3u", "handle kind g3u") + local call = started[1] + T.check(call and getmetatable(call.bs) == BS, "the gen 1 presenter receives a BattleSession") + T.eq(call and call.opts.names.me, "RED", "presenter gets my name") + T.eq(call and call.opts.names.foe, "MAY", "presenter gets the foe name") + T.eq(nb.inbox[1] and require("src.link.Json").decode(nb.inbox[1]).type, "g3u_table", + "the lower seat's session sent the table") + call.opts.onDone({ outcome = "win" }) + call.opts.onDone({ outcome = "lose" }) + T.eq(#results, 1, "onDone fires once") +end + +do + local na = L.pair() + local h, why = Launch.start({ stack = stack(), save = { version = "red" } }, 1, { + ruleset = "g3u", net = na, seat = 1, go = { seed = 5 }, gens = { [0] = 1, [1] = 3 }, + records = F.randomParty(t, L.lcg(2), 1), + }) + T.check(h ~= nil, "higher seat needs no dataset (" .. tostring(why) .. ")") +end + +do + local na = L.pair() + local finished = {} + local act = { finish = function(self, kind, text) finished[#finished + 1] = { self = self, kind = kind, text = text } end } + local results = {} + Launch.start({ stack = stack() }, 1, { + ruleset = "g3u", net = na, seat = 0, go = { seed = 5 }, gens = { [0] = 1, [1] = 2 }, data = data, + records = F.randomParty(t, L.lcg(3), 1), onDone = function(r) results[#results + 1] = r end, + }, act) + local call = started[#started] + call.opts.onDone({ outcome = "win", why = "faint" }) + T.eq(#results, 1, "session onDone still fires with an act") + T.eq(finished[1] and finished[1].kind, "battle_end", "the act is finished with battle_end") + T.check(finished[1] and finished[1].self == act, "act:finish is a method call") + T.eq(finished[1] and finished[1].text, Launch.resultText(1, { outcome = "win" }), "act gets the result line") + T.eq(Launch.resultText(3, { outcome = "draw", why = "desync" }), Launch.RESULT_TEXT[3].desync, "desync line") + T.eq(Launch.resultText(2, "lose"), Launch.RESULT_TEXT.gb.lose, "native word result line") +end + +do + local client = { room = function() return nil end, state = function() return "online" end } + T.eq(Launch.linkState(client, "r1")(), "gone", "no room means gone") + client.room = function() return { room = "r2" } end + T.eq(Launch.linkState(client, "r1")(), "gone", "another room means gone") + client.room = function() return { room = "r1" } end + T.eq(Launch.linkState(client, "r1")(), "ok", "same room online is ok") + client.state = function() return "reconnecting" end + T.eq(Launch.linkState(client, "r1")(), "resuming", "reconnecting is resuming") +end + +local linkCalls = {} +local function fakeLinkBattle(name) + local function make(role) + return function(game, net, opts) + local battle = { role = role, opts = opts, net = net } + linkCalls[#linkCalls + 1] = { mod = name, role = role, opts = opts } + return battle + end + end + package.loaded[name] = { newHost = make("host"), newGuest = make("guest") } +end +fakeLinkBattle("src.link.LinkBattle") +fakeLinkBattle("src.link.LinkBattle2") +package.loaded["src.link.Protocol"] = { + packParty = function(party, idx) + local out = {} + for i, j in ipairs(idx) do out[i] = { species = party[j].species } end + return out + end, + packParty2 = function(party, idx) + local out = {} + for i, j in ipairs(idx) do out[i] = { species = party[j].species, gen = 2 } end + return out + end, +} + +for _, gen in ipairs({ 1, 2 }) do + local na, nb = L.pair() + local st = stack() + local reports, results = {}, {} + local save = { party = { { species = "PIKACHU" }, { species = "ONIX" }, { species = "MEW" } } } + local game = { stack = st, save = save } + local h = Launch.start(game, gen, { + ruleset = "native", net = na, seat = 1, go = { seed = 777, size = 2 }, team = { 3, 1, 2 }, + names = { [0] = "BLUE", [1] = "RED" }, client = { report = function(w) reports[#reports + 1] = w end }, + onDone = function(r) results[#results + 1] = r end, + }) + T.eq(h and h.kind, "native", "gen " .. gen .. " native launch pushes the party exchange") + local sent = require("src.link.Json").decode(nb.inbox[1]) + T.eq(sent.type, "party", "gen " .. gen .. " native sends its party") + T.eq(#sent.mons, 2, "gen " .. gen .. " native trims the team to go.size") + T.eq(sent.mons[1].species, "MEW", "gen " .. gen .. " native keeps the prep team order") + local state = st:top() + state:update(0.1) + T.eq(#linkCalls, (gen - 1) * 1, "gen " .. gen .. " waits for the peer party") + nb:send({ type = "party", mons = { { species = "GENGAR" } } }) + state:update(0.1) + local call = linkCalls[#linkCalls] + T.eq(call.mod, gen == 2 and "src.link.LinkBattle2" or "src.link.LinkBattle", "gen " .. gen .. " uses its link battle") + T.eq(call.role, "guest", "seat 1 plays the guest role") + T.eq(call.opts.seed, 777, "seed comes from xg_go") + T.eq(call.opts.verdict, "full", "fingerprint already agreed by the relay") + T.check(call.opts.keepNetOpen, "the room session stays open for the room") + T.eq(call.opts.theirName, "BLUE", "peer name from the room") + T.eq(#st.states, 2, "link battle pushed over the party exchange") + local battle = st:pop() + battle.result = "win" + state:update(0.1) + T.eq(#st.states, 0, "the exchange state pops itself after the battle") + T.eq(reports[1], "win", "native result is reported") + T.eq(results[1], "win", "onDone gets the native result") + T.eq(save.party[1].species, "PIKACHU", "the real party is untouched") +end + +T.finish("union_battle_launch") diff --git a/tests/engine/union_battle_prep_cache_test.lua b/tests/engine/union_battle_prep_cache_test.lua new file mode 100644 index 00000000..352dabe6 --- /dev/null +++ b/tests/engine/union_battle_prep_cache_test.lua @@ -0,0 +1,100 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Model = require("src.online.union.BattlePrepModel") +local Identity = require("src.online.xgen.Identity") + +local real, list = F.allReal() +if #list == 0 then + print("[skip] union_battle_prep_cache_test: no imported game cache") + os.exit(0) +end + +local function rulesFor(moveGen, ownGen) + local lim = { [1] = { 151, 165 }, [2] = { 251, 251 } } + return { ruleset = "g3u", dexMax = lim[moveGen][1], moveMax = lim[moveGen][2], moveGen = moveGen, + gens = { moveGen, ownGen == moveGen and 3 or ownGen } } +end + +local function sampleMon(data, national, level) + local gen = data.generation + local sp = data.species[national] + local moves = {} + for _, row in ipairs(sp.levelMoves) do + if row.level <= level and #moves < 4 then moves[#moves + 1] = row.move end + end + if gen == 3 then + return { species = sp.localKey, level = level, personality = 0, otId = 1, ivs = {}, evs = {}, moves = moves } + end + local rows = {} + for i, id in ipairs(moves) do rows[i] = { id = data.moveToLocal[id], pp = data.moves[id].pp } end + return { species = sp.localKey, level = level, dvs = { attack = 1, defense = 2, speed = 3, special = 4 }, + statExp = {}, moves = rows } +end + +for _, version in ipairs(list) do + local data = real[version] + local gen = data.generation + for moveGen = 1, math.min(gen, 2) do + local m = Model.new({ version = version, gen = gen, data = data, rules = rulesFor(moveGen, gen), + owned = { party = { sampleMon(data, 25, 30) }, generation = gen } }) + local cov = m:rentalCoverage() + local want = moveGen == 1 and #Identity.GEN1_TYPES or #Identity.TYPES + T.eq(#cov, want, version .. " g3u-gen" .. moveGen .. " lists every ordinary type") + local missing = {} + for _, row in ipairs(cov) do + if not row.rental then missing[#missing + 1] = row.type end + if row.rental then + T.check(row.rental.record.rental == true, version .. " " .. row.type .. " rental record is tagged") + T.eq(row.rental.level, 50, version .. " " .. row.type .. " rental is level 50") + end + end + T.eq(table.concat(missing, ","), "", version .. " g3u-gen" .. moveGen .. " has a rental for every type") + T.eq(#m.rentalSet.excluded, 0, version .. " g3u-gen" .. moveGen .. " excludes no rental") + end +end + +local em = real.emerald or real.firered or real.ruby +if em then + local treecko = sampleMon(em, 252, 5) + local pika = sampleMon(em, 25, 30) + local before = F.copy({ treecko, pika }) + local m = Model.new({ version = em.version, gen = 3, data = em, rules = rulesFor(1, 3), + owned = { party = { treecko, pika }, generation = 3 }, opponent = { name = "RED", version = "red" } }) + m:input("a") + T.eq(m.step, "problems", em.version .. ": rules then problems") + m:input("a") + T.eq(m.step, "substitute", em.version .. ": Treecko needs a substitute under Gen 1 rules") + local pg = m:page() + local rentals = 0 + for _, it in ipairs(pg.items) do if it.id == "swap_rental" then rentals = rentals + 1 end end + T.eq(rentals, 15, em.version .. ": all 15 Gen 1 rentals are offered") + T.eq(pg.items[1].id, "swap_rental", em.version .. ": a grass rental ranks first when no owned mon fits") + T.check(pg.items[1].label:find("VENUSAUR", 1, true) ~= nil, em.version .. ": the grass rental shares Treecko's type") + m.cursor = 1 + m:input("a") + T.check(m.step == "moves" or m.step == "size", em.version .. ": the rental needs no move changes") + T.check(F.deepEqual({ treecko, pika }, before), em.version .. ": the save records are untouched") +end + +local Gen1 = require("src.ui.union.prep.Gen1BattlePrep") +for _, version in ipairs(list) do + local data = real[version] + if data.generation <= 2 then + local m = Model.new({ version = version, gen = data.generation, data = data, rules = rulesFor(1, data.generation), + owned = { party = { sampleMon(data, 25, 30), sampleMon(data, 1, 12) }, generation = data.generation }, + opponent = { name = "MAY", version = "emerald" } }) + local worst = 0 + for _ = 1, 6 do + local pg = m:page() + local L = Gen1.layout(pg, m) + T.check(#L.lines <= L.infoH - 3, version .. " " .. pg.step .. ": text fits its box") + for _, line in ipairs(L.lines) do worst = math.max(worst, #line) end + m:input("a") + end + T.check(worst <= 19, version .. ": no wrapped line is wider than the box (" .. worst .. ")") + end +end + +T.finish() diff --git a/tests/engine/union_battle_prep_flow_test.lua b/tests/engine/union_battle_prep_flow_test.lua new file mode 100644 index 00000000..37e696a1 --- /dev/null +++ b/tests/engine/union_battle_prep_flow_test.lua @@ -0,0 +1,375 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local P = require("tests.engine._union_prep_pair") +local Model = require("src.online.union.BattlePrepModel") +local Rentals = require("src.online.xgen.Rentals") + +local emerald = F.data("emerald") +local red = F.data("red") + +local savedDefs = Rentals.DEFINITIONS["g3u-gen1"] +Rentals.DEFINITIONS["g3u-gen1"] = { + { type = "GRASS", species = 1, moves = { 33, 45 } }, + { type = "ELECTRIC", species = 25, moves = { 84, 45 } }, + { type = "PSYCHIC", species = 150, moves = { 94 } }, +} + +local function mon3(national, level, moves, extra) + local m = { species = emerald.nationalToLocal[national], level = level, personality = 7, otId = 1, otSecretId = 0, + otName = "MAY", ivs = { hp = 1, atk = 1, def = 1, spe = 1, spa = 1, spd = 1 }, evs = {}, moves = moves } + for k, v in pairs(extra or {}) do m[k] = v end + return m +end + +local function source() + return { + generation = 3, + party = { mon3(252, 20, { 33, 345 }), mon3(25, 20, { 84, 45, 98, 57 }, { ppBonuses = 2 }), mon3(1, 10, { 33 }) }, + boxes = { { mon3(197, 30, { 33 }), mon3(25, 18, { 84 }), mon3(152, 25, { 33 }), mon3(1, 22, { 33 }), + mon3(2, 21, { 33 }), mon3(150, 60, { 94 }) } }, + } +end + +local function labels(pg) + local out = {} + for _, it in ipairs(pg.items) do out[#out + 1] = it.label end + return out +end + +local function find(pg, id, label) + for i, it in ipairs(pg.items) do + if it.id == id and (label == nil or it.label:find(label, 1, true)) then return i, it end + end + return nil +end + +local function pick(m, id, label) + local pg = m:page() + local i = find(pg, id, label) + assert(i, "no item " .. id .. " " .. tostring(label) .. " on " .. tostring(pg.step) .. ": " .. table.concat(labels(pg), ",")) + m.cursor = i + m:input("a") +end + +local function joined(lines) + return table.concat(lines or {}, " | ") +end + +local function pumpBoth(w, ma, pb) + for _ = 1, 3 do + w:pump() + ma:poll() + if pb then pb:poll() end + end +end + +do + local w, pa, pb = P.pair("emerald", "red") + local src = source() + local before = F.copy(src) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, prep = pa, owned = src, + opponent = { name = "BOB", version = "red", gen = 1 } }) + pumpBoth(w, m, pb) + T.eq(m.step, "rules", "the prep opens on the opponent and rules") + local pg = m:page() + T.check(joined(pg.lines):find("BOB wants to battle", 1, true) ~= nil, "the rules page names the opponent") + T.check(joined(pg.lines):find("Pokémon Red", 1, true) ~= nil, "the rules page names the opponent's game") + T.check(joined(pg.lines):find("No. 1 to 151", 1, true) ~= nil, "the rules page shows the dex limit") + T.check(find(pg, "changes") ~= nil and find(pg, "rentals") ~= nil, "rules and rentals can be opened") + + pick(m, "changes") + local chg = joined(m:page().lines) + T.check(chg:find("Held items are off", 1, true) and chg:find("Abilities are off", 1, true) + and chg:find("Natures are neutral", 1, true) and chg:find("Gen 1 ones", 1, true) + and chg:find("IVs and EVs stay", 1, true), "the rules view lists items, abilities, natures, typing and IV policy") + m:input("b") + T.eq(m.view, nil, "B closes the rules view") + + pick(m, "rentals") + local rp = m:page() + T.eq(#rp.items, 4, "three valid rentals plus back") + T.check(rp.items[1].label:find("GRASS BULBASAUR", 1, true) ~= nil, "rentals are listed by type") + local rinfo = joined(rp.info) + T.check(rinfo:find("RENTAL", 1, true) and rinfo:find("Lv50", 1, true) and rinfo:find("HP ", 1, true) + and rinfo:find("TACKLE 35/35", 1, true), "a rental discloses its tag, level, stats and moves") + m:input("b") + + pick(m, "continue") + T.eq(m.step, "problems", "continue moves to team compatibility") + local probs = joined(m:page().lines) + T.check(probs:find("TREECKO can't join this battle. Only No. 1 to 151 can.", 1, true) ~= nil, "Treecko's block is explained") + T.check(probs:find("PIKACHU can't learn SURF", 1, true) ~= nil, "Pikachu's Surf is explained as not learnable") + + pick(m, "continue") + T.eq(m.step, "substitute", "the substitution step follows") + local sp = m:page() + T.check(sp.items[1].label:find("IVYSAUR", 1, true) and sp.items[2].label:find("BULBASAUR", 1, true), + "owned grass types rank first, closest level first (" .. joined(labels(sp)) .. ")") + local again = m:page() + T.eq(joined(labels(again)), joined(labels(sp)), "the ranking is stable across rebuilds") + T.check(joined(sp.items[1].info or sp.items[1].detail):find("Shares a type", 1, true) ~= nil, "the ranking reason is shown") + local sawChikorita = false + for _, it in ipairs(sp.items) do if it.label:find("CHIKORITA", 1, true) then sawChikorita = true end end + T.check(not sawChikorita, "species outside the dex are never offered") + T.check(find(sp, "swap_rental", "RENTAL BULBASAUR") ~= nil, "rentals are offered next to owned Pokemon") + T.check(find(sp, "leave") ~= nil, "a slot can be left out") + pick(m, "swap_rental", "RENTAL BULBASAUR") + + T.eq(m.step, "moves", "move adjustments follow substitutions") + local mp = m:page() + T.check(joined(mp.lines):find("PIKACHU can't learn SURF", 1, true) ~= nil, "the move problem is explained by itself") + T.check(joined(mp.lines):find("PIKACHU: PIKACHU", 1, true) == nil, "the name is not repeated") + T.eq(mp.items[1].label, "THUNDERBOLT", "a similar legal move is suggested first") + for _, it in ipairs(mp.items) do + T.check(it.arg ~= 84 and it.arg ~= 45 and it.arg ~= 98, "a move the mon already knows is not suggested: " .. it.label) + end + local _, empty = find(mp, "empty") + T.check(empty and not empty.disabled, "an empty slot is allowed when other legal moves remain") + pick(m, "move", "THUNDERBOLT") + + T.eq(m.step, "size", "team size follows moves") + pumpBoth(w, m, pb) + T.eq(pa.mine.roster and pa.mine.roster.size, 3, "the roster size reaches the relay") + pb:roster(2, "00000000000000b2") + pumpBoth(w, m, pb) + T.eq(pa.size, 2, "the relay agrees the smaller size") + local zp = m:page() + local _, cont = find(zp, "continue") + T.check(cont and cont.disabled, "the larger roster must choose who sits out before continuing") + T.check(joined(zp.lines):find("Choose 1 to sit out", 1, true) ~= nil, "the sit-out count is shown") + T.check(joined(zp.lines):find("BOB brings 2", 1, true) ~= nil, "the opponent's size is shown") + m.cursor = find(zp, "continue") + m:input("a") + T.eq(m.step, "size", "a disabled continue does nothing") + local toggles = 0 + for _, it in ipairs(zp.items) do if it.id == "toggle" then toggles = toggles + 1 end end + T.eq(toggles, 3, "every battler can be chosen to sit out") + pick(m, "toggle", "PIKACHU") + pick(m, "continue") + T.eq(m.step, "confirm", "final confirmation follows the size") + local cp = m:page() + T.eq(#cp.items, 6, "two battlers, confirm, rules, back, cancel") + T.check(cp.items[1].label:find("RENTAL BULBASAUR", 1, true) ~= nil, "a rental is clearly marked") + local ready = select(2, find(cp, "ready")) + T.check(ready and not ready.disabled, "confirm is available") + pick(m, "ready") + T.eq(m.step, "waiting", "confirm waits for the opponent") + T.check(pa.mine.ready ~= nil, "ready is sent to the relay") + pumpBoth(w, m, pb) + + pb:roster(2, "00000000000000c3") + pumpBoth(w, m, pb) + T.eq(m.step, "confirm", "a later roster change sends the player back to confirm") + T.check(joined(m:page().lines):find("Something changed", 1, true) ~= nil, "the invalidation is explained") + T.eq(pa.mine.ready, nil, "readiness is cleared") + pick(m, "ready") + pumpBoth(w, m, pb) + pb:ready("00000000000000c3") + pumpBoth(w, m, pb) + T.check(m.done and m.outcome == "go", "both confirmations start the battle") + local res = m:result() + T.eq(res and #res.records, 2, "the result carries the chosen team") + T.eq(res.size, 2, "the result carries the size") + T.eq(res.ruleset.id, "g3u", "the result carries the ruleset") + T.eq(res.ruleset.rulesetId, "g3u-gen1", "the result names the policy ruleset") + T.eq(res.seed, pa.go.seed, "the result carries the relay seed") + T.check(res.records[1].rental == true and res.records[1].species == 1, "the rental stays flagged in the result") + T.eq(res.records[2].species, 1, "Bulbasaur fills the second place in party order") + T.check(F.deepEqual(src, before), "the active save's party and PC are untouched") + local nick = false + local function walk(t, seen) + if type(t) ~= "table" or seen[t] then return end + seen[t] = true + for k, v in pairs(t) do + if k ~= "prep" and k ~= "data" then + if v == "ZAPZAP" then nick = true end + walk(v, seen) + end + end + end + walk(m, {}) + T.check(not nick, "no opponent record is ever in the model") + for k in pairs(pa.peer) do + T.check(k == "roster" or k == "sizeReq" or k == "ready" or k == "caps" or k == "capsSent" or k == "counter", + "the peer view holds sizes and flags only: " .. tostring(k)) + end +end + +do + local w, pa, pb = P.pair("emerald", "red") + local src = source() + local m = Model.new({ version = "emerald", gen = 3, data = emerald, prep = pa, owned = src, + opponent = { name = "BOB", version = "red", gen = 1 } }) + pumpBoth(w, m, pb) + pick(m, "continue") + pick(m, "continue") + pick(m, "swap_owned", "IVYSAUR") + pick(m, "empty") + T.eq(m.step, "size", "an emptied slot with other legal moves passes") + pumpBoth(w, m, pb) + pb:roster(3, "00000000000000b2") + pumpBoth(w, m, pb) + T.eq(pa.size, 3, "equal rosters agree on 3") + pb:sizeRequest(1) + pumpBoth(w, m, pb) + local zp = m:page() + T.check(joined(zp.lines):find("BOB asks for 1 each", 1, true) ~= nil, "the opponent's size request is shown") + T.eq(pa.size, 3, "one request alone does not change the size") + pick(m, "agree") + pumpBoth(w, m, pb) + T.eq(pa.size, 1, "both requests agree on another equal size") + T.check(joined(m:page().lines):find("Choose 2 to sit out", 1, true) ~= nil, "the agreed size asks for two to sit out") + pick(m, "toggle", "PIKACHU") + pick(m, "toggle", "BULBASAUR") + pick(m, "continue") + T.eq(m.step, "confirm", "the agreed size reaches confirm") + local cp = m:page() + T.check(cp.items[1].label:find("IVYSAUR", 1, true) ~= nil, "the substitute battles alone") + local detail = joined(cp.items[1].full) + T.check(detail:find("TACKLE 35/35", 1, true) ~= nil, "the battler's moves and PP are shown") + T.check(detail:find("IV ", 1, true) and detail:find("EV ", 1, true), "IVs and EVs are disclosed") + pick(m, "cancel") + T.eq(m.step, "closed", "cancel closes the prep") + T.eq(#m.team, 0, "cancel discards the temporary team") + T.eq(m:result(), nil, "a cancelled prep has no result") + pumpBoth(w, m, pb) + T.eq(pb.state, "closed", "the opponent's prep closes") + m:input("a") + T.check(m.done and m.outcome == "cancel", "OK finishes a closed prep") +end + +do + local w, pa, pb = P.pair("emerald", "red") + local src = source() + src.party[2] = mon3(25, 20, { 84, 45, 98, 57 }, { ppBonuses = 2 }) + src.party[3] = mon3(1, 10, { 345 }) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, prep = pa, owned = src, + opponent = { name = "BOB" } }) + pumpBoth(w, m, pb) + pick(m, "continue") + local probs = joined(m:page().lines) + T.check(probs:find("MAGICAL LEAF isn't used in this battle", 1, true) ~= nil, "a move outside the ruleset is told apart") + T.check(probs:find("can't learn SURF", 1, true) ~= nil, "from a move the species can't learn") + pick(m, "continue") + pick(m, "leave") + T.eq(m.step, "moves", "leaving Treecko out goes to moves") + pick(m, "move", "THUNDERBOLT") + local mp = m:page() + T.check(joined(mp.lines):find("BULBASAUR: MAGICAL LEAF", 1, true) ~= nil, "each illegal move gets its own screen") + local _, empty = find(mp, "empty") + T.check(empty and empty.disabled, "the only move can't be emptied") + m.cursor = find(mp, "empty") + m:input("a") + T.eq(m.step, "moves", "an empty choice that leaves no move is refused") + T.check(joined(m:page().lines):find("needs at least one move", 1, true) ~= nil, "the refusal is explained") + local legal = {} + for _, it in ipairs(mp.items) do if it.id == "move" then legal[#legal + 1] = it.arg end end + for _, id in ipairs(legal) do + T.check(id == 33 or id == 45 or id == 22 or id == 34 or id == 92 or id == 75, "replacement is legal for Bulbasaur at 10: " .. id) + end + pick(m, "move", "TACKLE") + T.eq(m.step, "size", "all moves resolved") + pumpBoth(w, m, pb) + pb:roster(2, "00000000000000b2") + pumpBoth(w, m, pb) + pick(m, "continue") + T.eq(m.step, "confirm", "equal sizes need no sit-out") + local r = m:finalReport() + T.check(r.ok, "the final team is legal") + local pika = r.result.team[1] + T.eq(pika.moves[1].ppUps, 2, "kept moves keep their PP Ups") + T.eq(pika.moves[1].pp, 42, "kept move PP includes PP Ups (30 + 2 x 6)") + T.eq(pika.moves[4].id, 85, "the replacement stays in the slot it replaced") + T.eq(pika.moves[4].pp, 15, "the replacement starts at its full base PP") + T.eq(pika.moves[4].ppUps, 0, "the replacement has no PP Ups") + T.eq(#r.result.team[2].moves, 1, "Bulbasaur has its one legal move") +end + +do + local w, pa, pb = P.pair("red", "emerald") + local party = { + { species = "PIKACHU", level = 20, dvs = { attack = 9, defense = 8, speed = 7, special = 6 }, + statExp = { hp = 100, attack = 400, defense = 0, speed = 0, special = 900 }, + moves = { { id = "THUNDERSHOCK", pp = 30 }, { id = "GROWL", pp = 40 } } }, + } + local m = Model.new({ version = "red", gen = 1, data = red, prep = pa, owned = { party = party, generation = 1 }, + opponent = { name = "BOB" } }) + pumpBoth(w, m, pb) + pick(m, "changes") + local chg = joined(m:page().lines) + T.check(chg:find("Special counts as both", 1, true) and chg:find("DVs become IVs", 1, true) + and chg:find("Stat Exp. becomes EVs", 1, true), "a Gen 1 player sees the Special, DV and Stat Exp. policy") + m:input("b") + local gb = m:formatLines({ "Every Pokémon can battle." }) + T.eq(gb[1], "EVERY POKéMON CAN", "Gen 1 text is upper case and wrapped to 18") +end + +do + local m = Model.new({ version = "emerald", gen = 3, data = emerald, owned = source(), gameplayMods = true, + rules = { ruleset = "g3u", dexMax = 151, moveMax = 165, moveGen = 1, gens = { 1, 3 } } }) + local pg = m:page() + T.check(joined(pg.lines):find("Gameplay mods are on", 1, true) ~= nil, "gameplay mods refuse g3u with an explanation") + T.eq(#pg.items, 1, "only cancel is offered") + local n = Model.new({ version = "emerald", gen = 3, data = emerald, owned = source(), gameplayMods = true, + rules = { ruleset = "native", gen = 3, gens = { 3, 3 } } }) + T.check(joined(n:page().lines):find("Gameplay mods are on", 1, true) ~= nil, "gameplay mods refuse native battles too") + local d = Model.new({ version = "emerald", gen = 3, data = false, owned = source(), + rules = { ruleset = "g3u", dexMax = 151, moveMax = 165, moveGen = 1, gens = { 1, 3 } } }) + T.check(joined(d:page().lines):find("Import it again", 1, true) ~= nil, "a missing dataset gives actionable text") +end + +do + local w, pa, pb = P.pair("red", "red") + local m = Model.new({ version = "red", gen = 1, data = red, prep = pa, owned = { party = { + { species = "PIKACHU", level = 20, dvs = { attack = 9, defense = 8, speed = 7, special = 6 }, statExp = {}, + moves = { { id = "THUNDERSHOCK", pp = 30 } } } }, generation = 1 }, opponent = { name = "BOB" } }) + pumpBoth(w, m, pb) + local pg = m:page() + T.check(joined(pg.lines):find("rules of Gen 1", 1, true) ~= nil, "same-gen pairs use the native rules") + T.eq(find(pg, "rentals"), nil, "native battles have no rentals") + pick(m, "continue") + pick(m, "continue") + T.eq(m.step, "size", "a native team needs no substitutions or moves") +end + +do + local w, pa, pb = P.pair("emerald", "red") + pb:cancel("bye") + local m = Model.new({ version = "emerald", gen = 3, data = emerald, prep = pa, owned = source(), + opponent = { name = "BOB" } }) + pumpBoth(w, m, pb) + T.eq(m.step, "closed", "a peer cancel closes the prep") + T.check(joined(m:page().lines):find("BOB cancelled", 1, true) ~= nil, "the close names the opponent") + T.eq(#m.team, 0, "the temporary team is discarded") +end + +do + local w, pa, pb = P.pair("emerald", "gold") + local gold = F.data("gold") + local party = { { species = "CHIKORITA", level = 12, dvs = { attack = 9, defense = 8, speed = 7, special = 6 }, + statExp = {}, moves = { { id = "TACKLE", pp = 35 } } } } + local mb = Model.new({ version = "gold", gen = 2, data = gold, prep = pb, owned = { party = party, generation = 2 }, + opponent = { name = "ALICE" } }) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, prep = pa, owned = source(), + opponent = { name = "BOB" } }) + pumpBoth(w, m, nil) + mb:poll() + T.eq(pa.rules.dexMax, 251, "Gen 2 vs Gen 3 starts at dex 251") + pick(m, "continue") + pick(m, "continue") + T.eq(m.step, "substitute", "Treecko (No. 252) is outside dex 251") + pb:counter(1) + pumpBoth(w, m, nil) + mb:poll() + T.eq(pa.rules.dexMax, 151, "the counter narrows the rules") + T.eq(m.step, "rules", "a rules change sends the player back to the rules") + T.check(joined(m:page().lines):find("The rules changed", 1, true) ~= nil, "the rules change is explained") + T.eq(mb.step, "rules", "the proposing side restarts too") + pick(mb, "continue") + T.check(joined(mb:page().lines):find("CHIKORITA can't join", 1, true) ~= nil, "the new rules re-check the team") +end + +Rentals.DEFINITIONS["g3u-gen1"] = savedDefs +T.finish() diff --git a/tests/engine/union_battle_prep_native_test.lua b/tests/engine/union_battle_prep_native_test.lua new file mode 100644 index 00000000..bca20ddb --- /dev/null +++ b/tests/engine/union_battle_prep_native_test.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Model = require("src.online.union.BattlePrepModel") + +local real, list = F.allReal() +if #list == 0 then + print("[skip] union_battle_prep_native_test: no imported game cache") + os.exit(0) +end + +local function sampleMon(data, national, level) + local sp = data.species[national] + local moves = {} + for _, row in ipairs(sp.levelMoves) do + if row.level <= level and #moves < 4 then moves[#moves + 1] = row.move end + end + if data.generation == 3 then + return { species = sp.localKey, level = level, personality = 0, otId = 1, ivs = {}, evs = {}, moves = moves, + pp = { 35, 35, 35, 35 }, maxHp = 40, attack = 20, defense = 20, spAtk = 20, spDef = 20, speed = 20 } + end + local rows = {} + for i, id in ipairs(moves) do rows[i] = { id = data.moveToLocal[id], pp = data.moves[id].pp } end + return { species = sp.localKey, level = level, dvs = { attack = 1, defense = 2, speed = 3, special = 4 }, + statExp = {}, moves = rows, maxHp = 40 } +end + +for _, version in ipairs(list) do + local data = real[version] + local gen = data.generation + local rec = sampleMon(data, 25, 20) + local m = Model.new({ version = version, gen = gen, data = data, rules = { ruleset = "native", gen = gen }, + owned = { party = { rec }, generation = gen } }) + local ok, lines = pcall(m.recordLines, m, rec, nil, false) + T.check(ok, version .. " native record lines build (" .. tostring(not ok and lines or "") .. ")") + if ok then + local text = table.concat(lines, " ") + T.check(text:upper():find(data.species[25].name:upper(), 1, true) ~= nil, version .. " names the species") + T.check(text:find("No. ", 1, true) == nil, version .. " no raw ids in the native record lines: " .. text) + end +end + +T.finish() diff --git a/tests/engine/union_battle_session_test.lua b/tests/engine/union_battle_session_test.lua new file mode 100644 index 00000000..2552985d --- /dev/null +++ b/tests/engine/union_battle_session_test.lua @@ -0,0 +1,437 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._g3u_fixture") +local L = require("tests.support.g3u_loopback") +local Table = require("src.battle.g3u.Table") +local BS = require("src.online.union.BattleSession") + +local function deep(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not deep(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +local function copy(v) + if type(v) ~= "table" then return v end + local o = {} + for k, x in pairs(v) do o[k] = copy(x) end + return o +end + +local DATA = { [1] = F.gen1(), [2] = F.gen2() } +local TABLES = { [1] = assert(Table.build(DATA[1], 1)), [2] = assert(Table.build(DATA[2], 2)) } + +local function clockFn() + local c = { t = 0 } + c.fn = function() return c.t end + return c +end + +local function setup(o) + o = o or {} + local gen = o.gen or 1 + local t = TABLES[gen] + local rnd = L.lcg(o.seed or 1) + local recs = { [0] = o.p0 or F.randomParty(t, rnd, o.size0 or 3), [1] = o.p1 or F.randomParty(t, rnd, o.size1 or 3) } + local gens = o.gens or { [0] = gen, [1] = 3 } + local lower = BS.lowerSeat(gens) + local na, nb = L.pair() + local clock = o.clock or clockFn() + local reports = { [0] = {}, [1] = {} } + local function client(seat) + return { report = function(w) reports[seat][#reports[seat] + 1] = w end } + end + if o.beforeNew then o.beforeNew(na, nb) end + local go = { seed = o.goSeed or 4242, size = o.goSize or 6 } + local a = BS.new({ net = na, seat = 0, go = go, gens = gens, data = lower == 0 and DATA[gen] or nil, + records = recs[0], names = { [0] = "RED", [1] = "MAY" }, client = client(0), now = clock.fn }) + local b = BS.new({ net = nb, seat = 1, go = go, gens = gens, data = lower == 1 and DATA[gen] or nil, + records = recs[1], names = { [0] = "RED", [1] = "MAY" }, client = client(1), now = clock.fn }) + return { a = a, b = b, na = na, nb = nb, recs = recs, clock = clock, reports = reports, t = t } +end + +local function run(s, o) + o = o or {} + local ba = L.bot(s.a, { seed = (o.seed or 1) * 3 + 1, switches = o.switches }) + local bb = L.bot(s.b, { seed = (o.seed or 1) * 5 + 2, switches = o.switches }) + local order = L.lcg((o.seed or 1) + 77) + for _ = 1, o.steps or 4000 do + if order(2) == 1 then ba() bb() else bb() ba() end + if o.each then o.each(s) end + if s.a.result and s.b.result then return true end + s.clock.t = s.clock.t + (o.dt or 0.01) + end + return false +end + +local MIRROR = { win = "lose", lose = "win", draw = "draw" } + +do + local ok, turns = 0, 0 + for seed = 1, 24 do + local gen = (seed % 3 == 0) and 2 or 1 + local s = setup({ gen = gen, seed = seed, size0 = 1 + seed % 3, size1 = 1 + (seed * 7) % 3 }) + local done = run(s, { seed = seed, switches = seed % 2 == 0 }) + local ra, rb = s.a.result, s.b.result + local good = done and ra and rb and MIRROR[ra.outcome] == rb.outcome and ra.why == rb.why + and ra.why == "faint" and s.a.match.turn == s.b.match.turn + for turn = 0, s.a.match.turn do + if s.a.myHashes[turn] ~= s.b.myHashes[turn] then good = false end + end + good = good and #s.reports[0] == 1 and #s.reports[1] == 1 + and MIRROR[s.reports[0][1]] == s.reports[1][1] + if good then ok = ok + 1 end + turns = turns + (s.a.match and s.a.match.turn or 0) + if not good then + T.check(false, ("battle seed %d: done=%s a=%s/%s b=%s/%s"):format(seed, tostring(done), + tostring(ra and ra.outcome), tostring(ra and ra.why), tostring(rb and rb.outcome), + tostring(rb and rb.why))) + end + end + T.eq(ok, 24, "24 loopback battles end on faint with mirrored results, hashes and reports") + T.check(turns > 24 * 3, "battles run several turns each (" .. turns .. ")") +end + +do + local s = setup({ gen = 2, seed = 9, gens = { [0] = 3, [1] = 2 } }) + T.check(s.b:isLower() and not s.a:isLower(), "seat with the lower gen builds the table") + T.eq(#s.nb.sent >= 2 and s.nb.sent[1].type, "g3u_table", "lower seat sends g3u_table first") + T.check(s.na.sent[1].type == "g3u_party", "higher seat sends only its party") + T.check(run(s, { seed = 9 }), "battle with the table from seat 1 finishes") + T.eq(s.a.result.why, "faint", "seat 1 table battle ends on faint") +end + +do + local s = setup({ seed = 3 }) + run(s, { seed = 3 }) + local evs = s.a:events() + local kinds, firstReady, lastOver = {}, nil, nil + for i, e in ipairs(evs) do + kinds[e.kind] = (kinds[e.kind] or 0) + 1 + if e.kind == "ready" and not firstReady then firstReady = i end + if e.kind == "over" then lastOver = i end + end + T.eq(firstReady, 1, "ready is the first event") + T.eq(lastOver, #evs, "over is the last event") + T.eq(kinds.over, 1, "exactly one over event") + for _, k in ipairs({ "sendout", "move", "hp", "msg", "faint", "prompt", "end" }) do + T.check((kinds[k] or 0) > 0, "event stream carries " .. k) + end + T.eq(s.a:side(0), "me", "side(mySeat) is me") + T.eq(s.a:side(1), "foe", "side(peer) is foe") + T.eq(#s.a:legal(), 0, "no legal actions once over") +end + +do + local saw = false + for seed = 1, 10 do + local s = setup({ seed = seed, size0 = 3, size1 = 3 }) + run(s, { seed = seed, each = function(x) + for _, side in ipairs({ x.a, x.b }) do + if side.phase == "replace" then saw = true end + end + end }) + if saw then break end + end + T.check(saw, "a faint with mons left reaches the replace phase") +end + +do + local s = setup({ seed = 5 }) + for _ = 1, 20 do s.a:update() s.b:update() end + T.eq(s.a.phase, "choose", "seat 0 reaches choose") + T.check(s.a:forfeit(), "forfeit in choose is accepted") + local legal = s.b:legal() + s.b:choose(legal[1]) + for _ = 1, 20 do s.a:update() s.b:update() s.clock.t = s.clock.t + 1 end + T.eq(s.a.result and s.a.result.outcome, "lose", "forfeiter loses") + T.eq(s.b.result and s.b.result.outcome, "win", "opponent wins on forfeit") + T.eq(s.a.result and s.a.result.why, "forfeit", "why forfeit") + T.eq(s.reports[0][1], "lose", "forfeiter reports lose") + T.eq(s.reports[1][1], "win", "opponent reports win") +end + +do + local s = setup({ seed = 6 }) + for _ = 1, 20 do s.a:update() s.b:update() end + s.a:choose(s.a:legal()[1]) + T.eq(s.a.phase, "wait", "seat 0 waits for the peer") + T.check(s.a:forfeit(), "forfeit while waiting") + s.b:update() + T.eq(s.a.result.outcome, "lose", "waiting forfeiter loses") + T.eq(s.b.result and s.b.result.outcome, "win", "bye forfeit gives the peer the win") +end + +do + local s = setup({ seed = 7 }) + s.b.tamperHash = function(turn, h) + if turn == 2 then return (h:sub(1, 7) .. (h:sub(8, 8) == "0" and "1" or "0")) end + return h + end + run(s, { seed = 7 }) + T.eq(s.a.result.why, "desync", "tampered hash ends seat 0 with desync") + T.eq(s.b.result.why, "desync", "tampered hash ends seat 1 with desync") + T.eq(s.a.result.outcome, "draw", "desync is a draw") + T.eq(s.a.result.detail, 2, "desync names the turn") + T.eq(s.reports[0][1], "draw", "desync reports draw") +end + +local function tableTamper(fn) + return function(na) + na.tamper = function(m) + if m.type == "g3u_table" then fn(m.table) end + return m + end + end +end + +for label, fn in pairs({ + effect = function(t) t.moves[1][5] = 250 end, + power = function(t) t.moves[33][1] = 999 end, + extra = function(t) t.extra = 1 end, + species_type = function(t) t.species[25][1] = 17 end, + short = function(t) t.species[151] = nil end, + version = function(t) t.v = 2 end, +}) do + local s = setup({ seed = 11, beforeNew = tableTamper(fn) }) + for _ = 1, 10 do s.a:update() s.b:update() end + T.eq(s.b.result and s.b.result.why, "bad_table", "receiver rejects table (" .. label .. ")") + T.eq(s.a.result and s.a.result.why, "bad_table", "sender learns the table was rejected (" .. label .. ")") + T.check(s.b.match == nil, "no match starts on a bad table (" .. label .. ")") +end + +do + local s = setup({ seed = 12, beforeNew = function(na) + na.tamper = function(m) + if m.type == "g3u_table" then m.table = copy(TABLES[2]) end + return m + end + end }) + for _ = 1, 10 do s.a:update() s.b:update() end + T.eq(s.b.result and s.b.result.why, "bad_table", "a Gen 2 table in a Gen 1 match is refused") +end + +do + local s = setup({ seed = 13 }) + s.nb:send({ type = "g3u_table", table = TABLES[1] }) + for _ = 1, 10 do s.a:update() s.b:update() end + T.eq(s.a.result and s.a.result.why, "bad_table", "a table from the higher seat is refused") +end + +local function partyCase(label, mutate, extraOpts) + local o = { seed = 14 } + for k, v in pairs(extraOpts or {}) do o[k] = v end + o.beforeNew = function(_, nb) + nb.tamper = function(m) + if m.type == "g3u_party" then mutate(m.records) end + return m + end + end + local s = setup(o) + for _ = 1, 10 do s.a:update() s.b:update() end + T.eq(s.a.result and s.a.result.why, "bad_party", "receiver rejects party (" .. label .. ")") + T.eq(s.b.result and s.b.result.why, "bad_party", "sender learns the party was rejected (" .. label .. ")") +end + +partyCase("atk above level range", function(r) r[1].atk = r[1].atk + 200 end) +partyCase("speed below level range", function(r) r[1].speed = 1 end) +partyCase("maxHp above range", function(r) r[1].maxHp = r[1].maxHp + 150 r[1].hp = r[1].maxHp end) +partyCase("hp not full", function(r) r[1].hp = r[1].maxHp - 1 end) +partyCase("species out of dex", function(r) r[1].species = 152 end) +partyCase("unknown key", function(r) r[1].ability = 5 end) +partyCase("pp above max", function(r) r[1].moves[1].pp = 63 end) +partyCase("duplicate move", function(r) r[1].moves[2] = copy(r[1].moves[1]) end) +partyCase("bad level", function(r) r[1].level = 101 end) +partyCase("too many for go.size", function(r) r[#r + 1] = copy(r[1]) end, { goSize = 3, size1 = 3 }) +partyCase("empty", function(r) for i = #r, 1, -1 do r[i] = nil end end) + +do + local t = TABLES[1] + local rec = F.record(t, 25, { 84, 85 }, 50) + T.check(BS.checkParty({ rec }, t), "a table-derived record passes the bounds check") + local hi = copy(rec) + hi.spAtk = rec.spAtk + 60 + T.check(not BS.checkParty({ hi }, t, { senderGen = 1 }), "Gen 1 sender SpA is held to the Special base") + T.check(BS.checkParty({ hi }, t, { senderGen = 2 }), "Gen 2 sender SpA vs a Gen 1 table uses the loose cap") + hi.atk = rec.atk + 100 + T.check(not BS.checkParty({ hi }, t, { senderGen = 2 }), "non-special stats stay bounded for every sender") +end + +do + local t = TABLES[1] + local rec = F.record(t, 25, { 84, 85 }, 50) + local mon = copy(rec) + mon.national, mon.ability, mon.item, mon.nature, mon.shiny, mon.sourceGen = 25, 0, 0, 0, false, 1 + mon.evs, mon.gender, mon.unownLetter = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, "female", nil + mon.moves[1].name, mon.moves[1].maxPp = "THUNDERSHOCK", rec.moves[1].pp + local w = BS.wireRecord(mon) + T.check(BS.checkParty({ w }, t), "a prep battleMon record converts to a valid wire record") + T.eq(w.gender, 1, "string gender maps to the wire code") + T.check(w.ability == nil and w.evs == nil and w.national == nil, "prep-only fields stay off the wire") +end + +do + local t = TABLES[1] + local own = F.record(t, 65, { 94 }, 50) + own.spDef = own.spDef - 40 + local s = setup({ seed = 26, p1 = { own } }) + for _ = 1, 10 do s.a:update() s.b:update() end + T.check(s.b.result == nil and s.b.match ~= nil, "a Gen 3 seat's SpD below the Gen 1 Special range is not refused by itself") + T.check(s.a.result == nil and s.a.match ~= nil, "and the Gen 1 receiver accepts it") +end + +do + local t = TABLES[1] + local bad = { F.record(t, 25, { 84 }, 50, { atk = 999 }) } + local s = setup({ seed = 15, p0 = bad }) + T.eq(s.a.result and s.a.result.why, "bad_party", "own invalid party never starts") + T.check(#s.na.sent == 1 and s.na.sent[1].type == "g3u_bye", "only a bye goes out for an invalid own party") +end + +do + local t = TABLES[1] + local s = setup({ seed = 16, p0 = { F.record(t, 25, { 84, 85 }, 50) }, p1 = { F.record(t, 6, { 33 }, 50) } }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.nb:send({ type = "g3u_action", turn = 1, kind = "move", slot = 4 }) + s.a:update() + T.eq(s.a.result and s.a.result.why, "illegal", "a move slot the mon does not have is illegal") + s.b:update() + T.eq(s.b.result and s.b.result.why, "illegal", "peer learns its action was refused") +end + +do + local s = setup({ seed = 17 }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.nb:send({ type = "g3u_action", turn = 3, kind = "move", slot = 1 }) + s.a:update() + T.eq(s.a.result and s.a.result.why, "illegal", "an action for a future turn is illegal") +end + +do + local s = setup({ seed = 18 }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.nb:send({ type = "g3u_action", turn = 1, kind = "move", slot = 1, extra = true }) + s.a:update() + T.eq(s.a.result and s.a.result.why, "illegal", "unknown keys in an action are refused") +end + +do + local s = setup({ seed = 19 }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.na.closed = true + s.a:update() + T.eq(s.a.result and s.a.result.why, "disconnect", "closed session ends as disconnect") + T.eq(#s.reports[0], 0, "a disconnect sends no report (relay settles it)") +end + +do + local s = setup({ seed = 20 }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.nb.online = false + s.a:update() + s.clock.t = s.clock.t + BS.PEER_GONE - 1 + s.a:update() + T.check(s.a.result == nil, "a short peer outage keeps the battle") + s.nb.online = true + s.a:update() + s.clock.t = s.clock.t + BS.PEER_GONE + 5 + s.a:update() + T.check(s.a.result == nil, "peer back online resets the outage clock") + s.nb.online = false + s.a:update() + s.clock.t = s.clock.t + BS.PEER_GONE + 1 + s.a:update() + T.eq(s.a.result and s.a.result.why, "disconnect", "peer gone past the window ends as disconnect") +end + +do + local s = setup({ seed = 21 }) + local state = "ok" + s.a.linkState = function() return state end + for _ = 1, 10 do s.a:update() s.b:update() end + s.na.hold = true + s.a:choose(s.a:legal()[1]) + state = "resuming" + s.a:update() + s.clock.t = s.clock.t + 30 + s.a:update() + T.check(s.a.result == nil, "own resume inside the window keeps the battle") + state = "ok" + s.na.hold = false + s.na:flush() + run(s, { seed = 21 }) + T.eq(s.a.result.why, "faint", "battle resumes after the replayed messages") + T.eq(s.b.result.why, "faint", "peer finishes after the replay") +end + +do + local s = setup({ seed = 22 }) + local state = "ok" + s.a.linkState = function() return state end + for _ = 1, 10 do s.a:update() s.b:update() end + state = "resuming" + s.a:update() + s.clock.t = s.clock.t + BS.RESUME_WINDOW + 1 + s.a:update() + T.eq(s.a.result and s.a.result.why, "disconnect", "resume past the window ends as disconnect") +end + +do + local na = L.pair() + local clock = clockFn() + local a = BS.new({ net = na, seat = 0, go = { seed = 1 }, gens = { [0] = 1, [1] = 3 }, data = DATA[1], + records = F.randomParty(TABLES[1], L.lcg(1), 2), now = clock.fn }) + a:update() + clock.t = BS.SETUP_TIMEOUT + 1 + a:update() + T.eq(a.result and a.result.why, "disconnect", "no party from the peer ends the setup as disconnect") +end + +do + local s = setup({ seed = 23 }) + for _ = 1, 10 do s.a:update() s.b:update() end + s.nb:send({ type = "xg_closed", why = "gone" }) + s.a:update() + T.eq(s.a.result and s.a.result.why, "disconnect", "xg_closed during a battle ends as disconnect") +end + +do + local s = setup({ seed = 24 }) + local state = "ok" + s.a.linkState = function() return state end + s.nb.dropAll = false + run(s, { seed = 24, each = function(x) + if x.b.result and not x.a.result and x.a.phase == "ending" then state = "gone" end + end }) + T.check(s.a.result and s.a.result.why ~= "disconnect", "room closing after the last turn keeps the faint result") +end + +do + local writes = 0 + local realOpen = io.open + io.open = function(path, mode) + if mode and mode:find("[wa+]") then writes = writes + 1 end + return realOpen(path, mode) + end + local fs = love.filesystem + local realWrite, realAppend = fs.write, fs.append + fs.write = function(...) writes = writes + 1 return realWrite(...) end + fs.append = function(...) writes = writes + 1 return realAppend and realAppend(...) end + local okSD, SaveData = pcall(require, "src.core.SaveData") + local realSave = okSD and SaveData.save or nil + if realSave then SaveData.save = function(...) writes = writes + 1 return realSave(...) end end + local s = setup({ seed = 25, size0 = 3, size1 = 3 }) + local before = { copy(s.recs[0]), copy(s.recs[1]) } + local done = run(s, { seed = 25 }) + io.open = realOpen + fs.write, fs.append = realWrite, realAppend + if realSave then SaveData.save = realSave end + T.check(done, "save-watch battle finishes") + T.eq(writes, 0, "a full g3u battle writes no file and no save") + T.check(deep(before[1], s.recs[0]) and deep(before[2], s.recs[1]), "battle records are untouched by the match") +end + +T.finish("union_battle_session") diff --git a/tests/engine/union_export_safety.lua b/tests/engine/union_export_safety.lua new file mode 100644 index 00000000..5736c575 --- /dev/null +++ b/tests/engine/union_export_safety.lua @@ -0,0 +1,149 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GenSave = require("src.save_convert.GenSave") +local Gen2Save = require("src.save_convert.Gen2Save") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local Origin = require("src.online.union.Origin") + +local function versionRoot(version) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + local ids = {} + local env = os.getenv("POKEPORT_IDENTITY") + if env and env ~= "" then ids[#ids + 1] = env end + ids[#ids + 1] = "g1r-" .. version + ids[#ids + 1] = "pokeport-test-caches" + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs(ids) do + local root = base .. "/" .. id .. "/" .. version + local f = io.open(root .. "/data/generated/maps.lua", "rb") + if f then f:close() return root end + end + end + return nil +end + +local function loadTable(root, name) + local chunk = loadfile(root .. "/data/generated/" .. name .. ".lua") + local ok, mod = pcall(chunk or error) + return ok and type(mod) == "table" and mod or nil +end + +local function captured(module, fn) + local seen + local real = module.encode + module.encode = function(save, ...) + seen = save + return real(save, ...) + end + local bytes, err = fn() + module.encode = real + return seen, bytes, err +end + +local ran = 0 + +do + local root = versionRoot("red") + local stub = root and SaveConvert.gen1DataFromDir(root) + if stub and stub.maps.MT_MOON_POKECENTER then + ran = ran + 1 + SaveConvert.setGen1DataStub(stub, "red") + local function onMap(map, x, y, origin) + local s = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + s.player.map, s.player.x, s.player.y, s.player.facing = map, x, y, "left" + s.lastOutdoor = { id = "PALLET_TOWN", x = 5, y = 6 } + if origin then Origin.record(s, origin) end + return s + end + local mtMoon = { gen = 1, version = "red", map = "MT_MOON_POKECENTER", warp = 3, x = 13, y = 1 } + local cases = { + { "2F", onMap("POKECENTER_2F", 13, 2, mtMoon), "MT_MOON_POKECENTER", "ROUTE_4" }, + { "Union Room", onMap("UNION_ROOM", 12, 20, mtMoon), "MT_MOON_POKECENTER", "ROUTE_4" }, + { "1F stairs alcove", onMap("VIRIDIAN_POKECENTER", 12, 1), "VIRIDIAN_POKECENTER", "VIRIDIAN_CITY" }, + } + for _, c in ipairs(cases) do + local label, live, center, town = c[1], c[2], c[3], c[4] + local livePlayer = live.player + local seen, bytes, err = captured(GenSave, function() return SaveConvert.exportSav(live, "red") end) + check(seen ~= nil, "red " .. label .. ": the encoder ran") + eq(seen and seen.player.map, center, "red " .. label .. ": the cart save stands in the origin center") + eq(seen and seen.player.facing, "up", "red " .. label .. ": facing the nurse") + eq(seen and seen.lastOutdoor and seen.lastOutdoor.id, town, "red " .. label .. ": LAST_MAP is the center's town") + check(live.player == livePlayer, "red " .. label .. ": the live save is not moved") + check(bytes ~= nil, "red " .. label .. ": exported -- " .. tostring(err)) + if bytes then + local back = SaveConvert.importSav(bytes, "red", "red") + eq(back and back.player.map, center, "red " .. label .. ": the .sav reads back in the center") + end + end + local plain = onMap("VIRIDIAN_POKECENTER", 11, 3) + local seen = captured(GenSave, function() return SaveConvert.exportSav(plain, "red") end) + check(seen == plain, "red: a vanilla cell encodes the save untouched") + SaveConvert.setGen1DataStub(nil) + end +end + +do + local root = versionRoot("gold") + local stub = {} + for _, name in ipairs({ "pokemon", "moves", "items", "maps", "scripts", "sprites", "constants" }) do + stub[name] = root and loadTable(root, name) + end + local center = "CHERRYGROVE_POKECENTER_1F" + if stub.maps and stub.maps.POKECENTER_2F and stub.maps[center] then + ran = ran + 1 + stub.tilesets, stub.landmarks = loadTable(root, "tilesets"), loadTable(root, "landmarks") + SaveConvert.setGen2DataStub(stub) + local width = stub.maps.POKECENTER_2F.width * 2 + local function at(map, x, y) + local s = { + generation = 2, + player = { name = "GOLD", id = 12345, money = 3000, gender = "male" }, + rival = { name = "SILVER" }, mom = { name = "MOM" }, + position = { map = map, x = x, y = y, facing = "left" }, + party = {}, boxes = {}, boxNames = {}, currentBox = 1, spawn = 2, + } + Origin.record(s, { gen = 2, version = "gold", map = center, warp = 3, x = 0, y = 7, facing = "left" }) + return s + end + for _, c in ipairs({ { "Union Room", at("UNION_ROOM", 4, 4) }, + { "2F added column", at("POKECENTER_2F", width + 1, 3) } }) do + local label, live = c[1], c[2] + local livePos = live.position + local seen, bytes, err = captured(Gen2Save, function() return SaveConvert.exportSav(live, "gold") end) + check(seen ~= nil, "gold " .. label .. ": the encoder ran") + eq(seen and seen.position.map, center, "gold " .. label .. ": the cart save stands in the origin 1F") + eq(seen and seen.position.facing, "up", "gold " .. label .. ": facing the nurse") + eq(seen and seen.backupWarp and seen.backupWarp.map, center, "gold " .. label .. ": the backup warp is the 1F") + check(live.position == livePos, "gold " .. label .. ": the live save is not moved") + check(bytes ~= nil, "gold " .. label .. ": exported -- " .. tostring(err)) + end + local plain = at("POKECENTER_2F", 2, 3) + local seen = captured(Gen2Save, function() return SaveConvert.exportSav(plain, "gold") end) + check(seen == plain, "gold: a vanilla 2F cell encodes the save untouched") + SaveConvert.setGen2DataStub(nil) + end +end + +do + SaveConvert.setGen1DataStub({ pokemon = {}, moves = {}, items = {}, maps = {}, encounters = {} }, "red") + local s = { player = { map = "UNION_ROOM", x = 4, y = 4, facing = "up" } } + local seen, bytes, why = captured(GenSave, function() return SaveConvert.exportSav(s, "red") end) + check(seen == nil and bytes == nil, "red: a Union Room save is never encoded without the text pointer table") + check(type(why) == "string" and why:find("text_pointers", 1, true) ~= nil, "and the refusal names it: " .. tostring(why)) + SaveConvert.setGen1DataStub(nil) + ran = ran + 1 +end + +if ran == 0 then + print("[skip] union_export_safety: no red or gold cache") + os.exit(0) +end + +T.finish("union_export_safety") diff --git a/tests/engine/union_flow_launch_test.lua b/tests/engine/union_flow_launch_test.lua new file mode 100644 index 00000000..ae222ae7 --- /dev/null +++ b/tests/engine/union_flow_launch_test.lua @@ -0,0 +1,127 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Flow = require("src.ui.union.Flow") + +local function fakeClient(gens) + local rs = { left = false, closed = false } + function rs:send() return true end + function rs:close() self.left = true end + local C = { + rs = rs, + room = function() return { room = "r1", intent = "xg", xg = { gens = gens } } end, + seat = function() return 1 end, + roomSession = function() return rs end, + } + return C +end + +local launches = {} +Flow.seams.launch = { + start = function(game, gen, session, act) + launches[#launches + 1] = { game = game, gen = gen, session = session, act = act } + return { kind = "fake" } + end, +} + +local function activity(Activity, extra) + local marks = { dropped = 0, left = 0, done = 0 } + local act = setmetatable({ + game = { save = { player = { name = "RED" } } }, + room = { dropPrep = function() marks.dropped = marks.dropped + 1 end }, + prep = { go = { rev = 3, seed = 77, match = "m1", ruleset = "g3u", size = 2 }, + leave = function() marks.left = marks.left + 1 end, open = function() return true end }, + peer = { name = "GOLD" }, + battlePrep = { records = { { species = 25 }, { species = 1 } }, team = { 3, 1 }, size = 2, + ruleset = { id = "g3u", gens = { 2, 1 } }, seed = 77, match = "m1", rev = 3 }, + opts = { onDone = function() marks.done = marks.done + 1 end }, + state = "ready", done = false, + }, Activity) + for k, v in pairs(extra or {}) do act[k] = v end + return act, marks +end + +do + Flow.seams.client = fakeClient({ 2, 1 }) + local Activity = require("src.ui.union.gen1.Activity") + local act, marks = activity(Activity) + act:finish("go") + T.eq(#launches, 1, "gen 1: go starts the battle") + local l = launches[1] + T.eq(l and l.gen, 1, "gen 1: in the Gen 1 presenter") + T.eq(l and l.act, act, "gen 1: with the activity") + local s = l and l.session or {} + T.eq(s.ruleset, "g3u", "gen 1: ruleset from the relay go") + T.eq(s.seat, 1, "gen 1: my relay seat") + T.eq(s.gens and s.gens[0], 2, "gen 1: seat 0 gen from the room") + T.eq(s.gens and s.gens[1], 1, "gen 1: seat 1 gen from the room") + T.eq(s.names and s.names[1], "RED", "gen 1: my name on my seat") + T.eq(s.names and s.names[0], "GOLD", "gen 1: the peer on the other seat") + T.eq(s.go and s.go.seed, 77, "gen 1: go seed") + T.eq(s.go and s.go.size, 2, "gen 1: agreed size") + T.eq(s.records and #s.records, 2, "gen 1: prepared records") + T.eq(s.team and s.team[1], 3, "gen 1: prepared party order") + T.eq(s.net, Flow.seams.client.rs, "gen 1: the room session is the battle net") + T.check(not act.done, "gen 1: the activity stays open during the battle") + T.eq(marks.left, 0, "gen 1: the xg room is kept for the battle") + T.eq(marks.done, 0, "gen 1: the room presence stays busy") + act:finish("battle_end") + T.check(act.done, "gen 1: the battle end closes the activity") + T.eq(marks.left, 1, "gen 1: the xg room is left after the battle") + T.eq(marks.dropped, 1, "gen 1: the prep is dropped") + T.eq(marks.done, 1, "gen 1: the presence is handed back") + act:finish("battle_end") + T.eq(marks.done, 1, "gen 1: a second finish is ignored") +end + +do + launches = {} + Flow.seams.client = fakeClient({ 1, 2 }) + local Activity = require("src.ui.gen2.union.Activity") + local ui = { done = 0 } + local act, marks = activity(Activity, { session = { uiDone = function() ui.done = ui.done + 1 end } }) + act:finish("go") + T.eq(#launches, 1, "gen 2: go starts the battle") + T.eq(launches[1] and launches[1].gen, 2, "gen 2: in the Gen 2 presenter") + T.eq(ui.done, 0, "gen 2: the room ui stays held during the battle") + T.eq(marks.left, 0, "gen 2: the xg room is kept for the battle") + act:finish("battle_end") + T.eq(ui.done, 1, "gen 2: the room ui is released after the battle") + T.eq(marks.left, 1, "gen 2: the xg room is left after the battle") +end + +do + launches = {} + Flow.seams.launch = { start = function() return nil, "presenter_failed" end } + Flow.seams.client = fakeClient({ 1, 2 }) + local Activity = require("src.ui.gen2.union.Activity") + local ui = { done = 0 } + local act = activity(Activity, { session = { uiDone = function() ui.done = ui.done + 1 end }, + game = { save = { player = { name = "GOLD" } }, stack = { push = function() end } } }) + act.closeUi = function() end + local said + package.loaded["src.ui.gen2.union.Dialog"].say = function(_, text, after) said = text if after then after() end end + act:finish("go") + T.check(act.done and act.why == "error", "a failed launch ends the activity with an error") + T.check(said ~= nil, "and says so") + T.eq(ui.done, 1, "and hands the room back") +end + +do + local Model = require("src.online.union.BattlePrepModel") + local m = setmetatable({ owned = { + { ref = { where = "party", index = 1 } }, { ref = { where = "party", index = 2 } }, + { ref = { where = "box", box = 1, index = 4 } }, { ref = { where = "party", index = 3 } } } }, Model) + local list = { { base = 1 }, { base = 2 }, { base = 4 } } + local idx = m:partyIndices({ 3, 1 }, list) + T.eq(#idx, 2, "sit-outs leave the party list") + T.eq(idx[1], 3, "party order follows the prepared slots") + T.eq(idx[2], 1, "second slot") + local swapped = m:partyIndices({ 1 }, { { base = 1, swap = { owned = 4 } } }) + T.eq(swapped[1], 3, "an owned swap uses the swapped mon's party index") +end + +T.eq(Flow.gens({ 3, 1 })[1], 1, "relay gens list maps to seats") +T.eq(Flow.gens(nil), nil, "no gens list") + +T.finish() diff --git a/tests/engine/union_gen1_centers.lua b/tests/engine/union_gen1_centers.lua new file mode 100644 index 00000000..58f31991 --- /dev/null +++ b/tests/engine/union_gen1_centers.lua @@ -0,0 +1,414 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Map = require("src.world.Map") +local UnionCenters = require("src.world.gen1.UnionCenters") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") +local UnionSafety = require("src.world.gen1.UnionSafety") +local Origin = require("src.online.union.Origin") + +local function deepCopy(t) + if type(t) ~= "table" then return t end + local out = {} + for k, v in pairs(t) do out[k] = deepCopy(v) end + return out +end + +local function deepEq(a, b, path) + path = path or "data" + if type(a) ~= type(b) then return false, path end + if type(a) ~= "table" then return a == b, path end + for k, v in pairs(a) do + local ok, at = deepEq(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, at end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true +end + +local QUADS = { + floor = { 1, 2, 3, 4 }, wall = { 5, 5, 5, 5 }, pillar = { 6, 7, 6, 7 }, + cap = { 8, 9, 10, 11 }, counter = { 12, 12, 13, 14 }, gate = { 15, 16, 17, 18 }, + pc = { 19, 20, 21, 22 }, door = { 23, 24, 25, 26 }, other = { 27, 28, 29, 30 }, +} + +local function blockOf(tl, tr, bl, br) + return { + tl[1], tl[2], tr[1], tr[2], tl[3], tl[4], tr[3], tr[4], + bl[1], bl[2], br[1], br[2], bl[3], bl[4], br[3], br[4], + } +end + +local function centerTileset(id) + local q = QUADS + local blocks = {} + for i = 0, 36 do blocks[i + 1] = blockOf(q.other, q.other, q.floor, q.floor) end + blocks[1 + 1] = blockOf(q.wall, q.pillar, q.floor, q.pillar) + blocks[5 + 1] = blockOf(q.counter, q.cap, q.floor, q.floor) + blocks[6 + 1] = blockOf(q.counter, q.counter, q.floor, q.floor) + blocks[7 + 1] = blockOf(q.counter, q.counter, q.floor, q.floor) + blocks[12 + 1] = blockOf(q.wall, q.wall, q.floor, q.floor) + blocks[13 + 1] = blockOf(q.door, q.wall, q.floor, q.floor) + blocks[14 + 1] = blockOf(q.other, q.other, q.other, q.other) + blocks[15 + 1] = blockOf(q.floor, q.floor, q.floor, q.floor) + blocks[34 + 1] = blockOf(q.gate, q.floor, q.floor, q.floor) + blocks[35 + 1] = blockOf(q.gate, q.counter, q.floor, q.pc) + return { + id = id, image = "assets/generated/tilesets/pokecenter.png", + imageWidth = 128, imageHeight = 48, tilesPerRow = 16, + blocks = blocks, walkable = { 3, 25 }, warpTiles = { 25 }, + counterTiles = { 13, 17 }, + } +end + +local function clubTileset() + local blocks = {} + for i = 0, 35 do + local b = {} + for j = 1, 16 do b[j] = 40 end + blocks[i + 1] = b + end + for _, id in ipairs({ 3, 19, 23, 27 }) do + for j = 1, 8 do blocks[id + 1][j] = 6 end + end + return { id = "CLUB", image = "assets/generated/tilesets/club.png", tilesPerRow = 16, + imageWidth = 128, imageHeight = 40, blocks = blocks, walkable = { 40 } } +end + +local function center(id, label, extra) + local def = { + id = id, label = label, tileset = "POKECENTER", width = 7, height = 4, borderBlock = 0, + blocks = { 32, 16, 1, 2, 12, 13, 13, 33, 4, 5, 7, 7, 34, 35, + 8, 15, 15, 15, 15, 15, 27, 14, 10, 11, 14, 15, 15, 14 }, + warps = { { x = 3, y = 7, destMap = "LAST_MAP", destWarp = 1 }, + { x = 4, y = 7, destMap = "LAST_MAP", destWarp = 1 } }, + objects = { + { index = 1, sprite = "SPRITE_NURSE", x = 3, y = 1, name = label .. "_NURSE", + text = "TEXT_" .. label:upper() .. "_NURSE" }, + { index = 2, sprite = "SPRITE_GENTLEMAN", x = 10, y = 5, name = label .. "_GENTLEMAN", + text = "TEXT_" .. label:upper() .. "_GENTLEMAN" }, + { index = 3, sprite = "SPRITE_LINK_RECEPTIONIST", x = 11, y = 2, name = label .. "_LINK", + text = "TEXT_" .. label:upper() .. "_LINK_RECEPTIONIST" }, + }, + } + for k, v in pairs(extra or {}) do def[k] = v end + return def +end + +local function indigo() + return { + id = "INDIGO_PLATEAU_LOBBY", label = "IndigoPlateauLobby", tileset = "MART", + width = 8, height = 6, borderBlock = 0, + blocks = { 19, 18, 12, 12, 13, 0, 0, 0, + 22, 15, 30, 31, 31, 36, 36, 36, + 24, 15, 32, 16, 1, 2, 13, 13, + 23, 15, 33, 4, 5, 7, 34, 35, + 29, 29, 15, 15, 15, 15, 15, 27, + 25, 15, 15, 10, 11, 15, 14, 14 }, + warps = { { x = 7, y = 11, destMap = "LAST_MAP", destWarp = 1 }, + { x = 8, y = 11, destMap = "LAST_MAP", destWarp = 2 }, + { x = 8, y = 0, destMap = "LORELEIS_ROOM", destWarp = 1 } }, + objects = { + { index = 1, sprite = "SPRITE_NURSE", x = 7, y = 5, name = "INDIGO_NURSE", + text = "TEXT_INDIGOPLATEAULOBBY_NURSE" }, + { index = 5, sprite = "SPRITE_LINK_RECEPTIONIST", x = 13, y = 6, name = "INDIGO_LINK", + text = "TEXT_INDIGOPLATEAULOBBY_LINK_RECEPTIONIST" }, + }, + } +end + +local function source() + local mart = centerTileset("MART") + return { + maps = { + VIRIDIAN_POKECENTER = center("VIRIDIAN_POKECENTER", "ViridianPokecenter"), + MT_MOON_POKECENTER = center("MT_MOON_POKECENTER", "MtMoonPokecenter"), + INDIGO_PLATEAU_LOBBY = indigo(), + TRADE_CENTER = { id = "TRADE_CENTER", borderBlock = 14 }, + VIRIDIAN_CITY = { id = "VIRIDIAN_CITY", tileset = "OVERWORLD", width = 1, height = 1, blocks = { 0 }, + warps = { { x = 23, y = 25, destMap = "VIRIDIAN_POKECENTER", destWarp = 1 } } }, + ROUTE_4 = { id = "ROUTE_4", tileset = "OVERWORLD", width = 1, height = 1, blocks = { 0 }, + warps = { { x = 11, y = 5, destMap = "MT_MOON_POKECENTER", destWarp = 1 } } }, + INDIGO_PLATEAU = { id = "INDIGO_PLATEAU", tileset = "PLATEAU", width = 1, height = 1, blocks = { 0 }, + warps = { { x = 9, y = 5, destMap = "INDIGO_PLATEAU_LOBBY", destWarp = 1 } } }, + }, + tilesets = { + POKECENTER = centerTileset("POKECENTER"), MART = mart, + REDS_HOUSE_1 = { id = "REDS_HOUSE_1", image = "assets/generated/tilesets/reds_house.png", tilesPerRow = 16 }, + CLUB = clubTileset(), + }, + text_pointers = { + ViridianPokecenter = { TEXT_VIRIDIANPOKECENTER_LINK_RECEPTIONIST = { cableClub = true, label = "L" } }, + MtMoonPokecenter = { TEXT_MTMOONPOKECENTER_LINK_RECEPTIONIST = { cableClub = true, label = "M" } }, + IndigoPlateauLobby = { TEXT_INDIGOPLATEAULOBBY_LINK_RECEPTIONIST = { cableClub = true, label = "I" } }, + }, + field = { hiddenExtras = { pcTiles = { + VIRIDIAN_POKECENTER = { { x = 13, y = 3, facing = "up" } }, + MT_MOON_POKECENTER = { { x = 13, y = 3, facing = "up" } }, + INDIGO_PLATEAU_LOBBY = { { x = 15, y = 7, facing = "up" } }, + } }, boot = { startMap = "VIRIDIAN_CITY", startX = 0, startY = 0 } }, + audio = { mapSongs = { VIRIDIAN_POKECENTER = "Music_Pokecenter", TRADE_CENTER = "Music_Celadon" } }, + } +end + +local function load(src) + local data = {} + for k, v in pairs(src) do data[k] = v end + return data +end + +local src = source() +local pristine = deepCopy(src) +local plan, why = UnionCenters.plan(src, "VIRIDIAN_POKECENTER") +check(plan ~= nil, "Viridian plan: " .. tostring(why)) +eq(plan and plan.stairs.x .. "," .. plan.stairs.y, "13,1", "Viridian stairs cell") +eq(plan and plan.front.x .. "," .. plan.front.y, "11,3", "Viridian front of the desk") +eq(plan and plan.desk.bx .. "," .. plan.desk.by, "5,1", "Viridian desk block") +local ip = UnionCenters.plan(src, "INDIGO_PLATEAU_LOBBY") +check(ip ~= nil, "Indigo Plateau gets its own plan") +eq(ip and ip.stairs.x .. "," .. ip.stairs.y, "15,5", "Indigo stairs cell") +eq(ip and ip.front.x .. "," .. ip.front.y, "13,7", "Indigo front of the desk") +eq(ip and ip.desk.bx .. "," .. ip.desk.by, "6,3", "Indigo desk block") +eq(ip and ip.tileset, "MART", "Indigo plan keeps the MART tileset") + +local edited = source() +edited.maps.MT_MOON_POKECENTER.blocks[13] = 15 +local _, reason = UnionCenters.plan(edited, "MT_MOON_POKECENTER") +check(reason and reason:find("expected 34", 1, true), "a non-vanilla desk is refused with a reason: " .. tostring(reason)) +local blocked = source() +table.insert(blocked.maps.VIRIDIAN_POKECENTER.objects, { index = 9, sprite = "SPRITE_GIRL", x = 12, y = 1, name = "IN_THE_WAY" }) +local _, reason2 = UnionCenters.plan(blocked, "VIRIDIAN_POKECENTER") +check(reason2 and reason2:find("IN_THE_WAY", 1, true), "an object in the stairs area refuses the plan") + +local data = load(src) +local r = UnionCenters.apply(data) +check(r ~= nil, "apply patches the fixture") +eq(#r.order, 3, "three fixture centers patched") +check(deepEq(src, pristine), "apply never writes into the source tables") +local maps, pc = data.maps, data.tilesets.POKECENTER +local floor1, floor2, room = maps.VIRIDIAN_POKECENTER, maps[UnionCenters.FLOOR_2F], maps[UnionCenters.UNION_ROOM] +check(floor2 and room, "2F and the union room are registered") +eq(#floor2.blocks, floor2.width * floor2.height, "2F block count") +eq(#room.blocks, room.width * room.height, "union room block count") +for _, id in ipairs({ "VIRIDIAN_POKECENTER", "MT_MOON_POKECENTER", "INDIGO_PLATEAU_LOBBY" }) do + local p = r.plans[id] + check(p and p.verified, id .. " patched layout verified") + for _, o in ipairs(maps[id].objects) do + check(o.sprite ~= "SPRITE_LINK_RECEPTIONIST", id .. " no longer has the link receptionist") + end + local w = maps[id].warps[p.warp] + eq(w and w.destMap, UnionCenters.FLOOR_2F, id .. " stairs warp leads to the 2F") + eq(#maps[id].warps, #pristine.maps[id].warps + 1, id .. " gains exactly one warp") +end +eq(r.plans.VIRIDIAN_POKECENTER.warp, 3, "center stairs warp index") +eq(r.plans.INDIGO_PLATEAU_LOBBY.warp, 4, "Indigo stairs warp index") + +local m1 = Map.new(floor1, pc) +check(m1:isWarpTileCell(13, 1) and m1:isWalkableCell(13, 1), "1F stair cell is a walkable warp tile") +check(not m1:isWalkableCell(10, 1), "1F pillar seals the nurse side") +check(not m1:isWalkableCell(10, 2), "1F counter ends in a cap under the pillar") +eq(m1:blockAt(4, 1), 7, "1F counter block next to the cap stays the vanilla counter") +eq(m1:tileAt(20, 4), QUADS.cap[1], "cap tile sits right under the pillar column") +eq(m1:tileAt(20, 2), QUADS.pillar[1], "pillar tile above the cap") +check(m1:isWalkableCell(11, 1) and m1:isWalkableCell(12, 1) and m1:isWalkableCell(11, 2), "stairs alcove walkable") +check(not m1:isWalkableCell(13, 3), "PC cell stays solid") +eq(m1:tileAt(26, 7), QUADS.pc[3], "PC graphic stays at 13,3") + +local mi = Map.new(maps.INDIGO_PLATEAU_LOBBY, data.tilesets.MART) +check(mi:isWarpTileCell(15, 5) and mi:isWalkableCell(15, 5), "Indigo stair cell is a walkable warp tile") +check(not mi:isWalkableCell(12, 5) and not mi:isWalkableCell(12, 6), "Indigo nurse side sealed") +local lorelei = maps.INDIGO_PLATEAU_LOBBY.warps[3] +eq(lorelei.destMap .. "@" .. lorelei.x .. "," .. lorelei.y, "LORELEIS_ROOM@8,0", "Lorelei warp untouched") + +local m2 = Map.new(floor2, pc) +check(m2:isWarpTileCell(13, 1) and m2:isWalkableCell(13, 1), "2F stair cell is a walkable warp tile") +check(not m2:isWalkableCell(6, 2), "2F union gate starts closed") +check(m2:isCounterCell(7, 2) and m2:isCounterCell(11, 2), "both 2F receptionists are reachable across the counter") +check(not m2:isWalkableCell(12, 1), "2F pillar seals the area behind the counter") +eq(m2:blockAt(UnionCenters.GATE_2F.bx, UnionCenters.GATE_2F.by), r.gateClosed, "gate block id is exported") +local sprites = {} +for _, o in ipairs(floor2.objects) do sprites[o.x .. "," .. o.y] = o.text end +eq(sprites["11,1"], UnionCenters.TEXT_LINK, "cable club receptionist on the 2F") +eq(sprites["7,1"], UnionCenters.TEXT_UNION, "union receptionist on the 2F") +eq(#floor2.objects, 2, "2F has only the two receptionists") +check(data.text_pointers[UnionCenters.LABEL_2F][UnionCenters.TEXT_LINK].cableClub, + "2F cable desk keeps the cable club text entry") +eq(floor2.warps[2].destMap, UnionCenters.UNION_ROOM, "2F door leads to the union room") +eq(room.warps[1].destMap .. "#" .. room.warps[1].destWarp, UnionCenters.FLOOR_2F .. "#2", "room exit leads to the 2F door") +eq(data.audio.mapSongs[UnionCenters.FLOOR_2F], "Music_Pokecenter", "2F plays the center music") +eq(data.audio.mapSongs[UnionCenters.UNION_ROOM], "Music_Celadon", "room plays the link room music") +check(pristine.audio.mapSongs[UnionCenters.FLOOR_2F] == nil and src.audio.mapSongs[UnionCenters.FLOOR_2F] == nil, + "source song table untouched") + +for id = 96, 103 do + check(pc.tileSources[id] and pc.tileSources[id].tileset == "REDS_HOUSE_1", + ("tile %d is sourced from Red's house"):format(id)) +end +eq(pc.imageHeight, 56, "composed atlas grows by one tile row") +eq(pc.image, UnionCenters.IMAGE, "POKECENTER draws from the composed atlas") +eq(data.tilesets.MART.image, UnionCenters.IMAGE, "MART draws from the composed atlas for Indigo") + +local snapshot = deepCopy(data) +local again, note = UnionCenters.apply(data) +eq(note, "already applied", "a second apply reports a no-op") +check(again == r, "a second apply returns the first registry") +local same, where = deepEq(snapshot, data) +check(same, "a second apply changes nothing: " .. tostring(where)) + +local onA = load(src) +check(UnionCenters.seed(onA, { unionRoom = true }) ~= nil, "seed applies with the setting on") +local off = load(src) +local none, offWhy = UnionCenters.seed(off, { unionRoom = false }) +check(none == nil and offWhy == "setting off", "seed skips with the setting off") +local vanilla, at = deepEq(off, pristine) +check(vanilla, "setting off leaves vanilla data: " .. tostring(at)) +check(UnionCenters.forData(off) == nil, "no registry with the setting off") +local onB = load(src) +UnionCenters.seed(onB, { unionRoom = true }) +local equal, diff = deepEq(onA, onB) +check(equal, "ON/OFF/ON reloads give identical map tables: " .. tostring(diff)) +local receptionists = 0 +for _, o in ipairs(onB.maps[UnionCenters.FLOOR_2F].objects) do + if o.sprite == "SPRITE_LINK_RECEPTIONIST" then receptionists = receptionists + 1 end +end +eq(receptionists, 2, "no duplicate 2F NPCs after reloads") +eq(#onB.maps.VIRIDIAN_POKECENTER.warps, 3, "no duplicate stairs warps after reloads") +eq(#onB.tilesets.POKECENTER.blocks, 37 + 7, "no duplicate appended blocks after reloads") + +local Plaza = require("src.core.game3.link.union_plaza_map") +local seen = {} +local mr = Map.new(room, data.tilesets.CLUB) +local gridOk, distinct = true, true +for slot = 1, UnionRoomMap.CAP do + local x, y = UnionRoomMap.cellFor(slot) + local px, py = Plaza.cellFor(slot) + if not (x == px and y == py) then gridOk = false end + check(x and mr:isWalkableCell(x, y), ("slot %d cell %s,%s walkable"):format(slot, tostring(x), tostring(y))) + eq(UnionRoomMap.slotAt(x, y), slot, ("slotAt inverts cellFor for slot %d"):format(slot)) + local key = tostring(x) .. "," .. tostring(y) + if seen[key] then distinct = false end + seen[key] = true + for _, e in ipairs(UnionRoomMap.EXITS) do + check(not (e.x == x and e.y == y), ("slot %d is not an exit cell"):format(slot)) + end +end +check(gridOk, "Gen 1 slot N is the Gen 3 plaza slot N cell") +check(distinct, "40 participant cells never overlap") +check(UnionRoomMap.cellFor(41) == nil, "no slot 41") +for _, e in ipairs(UnionRoomMap.EXITS) do + check(mr:isWalkableCell(e.x, e.y) and e.y == mr.heightCells - 1, "exit carpet on the bottom edge") +end + +local scripts = require("data.scripts.pokecenter_upstairs")[UnionCenters.FLOOR_2F] +local function fakeGame(save) + return { data = data, save = save } +end +local function fakeOw(mapDef) + local ow = { calls = {}, map = Map.new(mapDef, pc) } + function ow:takeWarp(w) self.calls[#self.calls + 1] = { "takeWarp", w } end + function ow:warpToHealPoint() self.calls[#self.calls + 1] = { "heal" } end + function ow:replaceBlock(bx, by, b) self.map:setBlock(bx, by, b) self.calls[#self.calls + 1] = { "block", b } end + function ow:queueScript(s) self.calls[#self.calls + 1] = { "queue", s } end + return ow +end +local outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 26 } +local save = { flags = {}, lastOutdoor = outdoor } +local ow = fakeOw(deepCopy(floor2)) +scripts.onEnter(fakeGame(save), ow, "MT_MOON_POKECENTER") +local o = Origin.get(save) +check(o and o.gen == 1 and o.map == "MT_MOON_POKECENTER", "entering the 2F records the origin center") +eq(o and o.warp, r.plans.MT_MOON_POKECENTER.warp, "origin keeps the 1F stairs warp index") +eq(o and (o.x .. "," .. o.y), "13,1", "origin keeps the 1F stairs cell") +check(save.lastOutdoor == outdoor and save.lastOutdoor.id == "VIRIDIAN_CITY", "origin record leaves lastOutdoor alone") +scripts.onEnter(fakeGame(save), ow, UnionCenters.UNION_ROOM) +eq((Origin.get(save) or {}).map, "MT_MOON_POKECENTER", "returning from the room keeps the origin") +check(ow.calls[#ow.calls][1] == "queue", "returning from the room queues the walk-out") +eq(ow.map:blockAt(UnionCenters.GATE_2F.bx, UnionCenters.GATE_2F.by), r.gateOpen, "gate opens for the walk-out") +scripts.onEnter(fakeGame(save), ow, nil) +eq(ow.map:blockAt(UnionCenters.GATE_2F.bx, UnionCenters.GATE_2F.by), r.gateClosed, "a plain entry closes the gate") +eq((Origin.get(save) or {}).map, "MT_MOON_POKECENTER", "a boot entry keeps the origin") +check(scripts.onStep(fakeGame(save), ow, 12, 2) == false, "other 2F cells are plain steps") +ow.calls = {} +check(scripts.onStep(fakeGame(save), ow, 13, 1) == true, "the 2F stairs consume the step") +local call = ow.calls[1] +check(call and call[1] == "takeWarp" and call[2].destMap == "MT_MOON_POKECENTER" + and call[2].destWarp == r.plans.MT_MOON_POKECENTER.warp, "2F stairs return to the origin center") +local land = maps[call[2].destMap].warps[call[2].destWarp] +eq(land.x .. "," .. land.y, "13,1", "the return lands on that center's stairs") +check(Origin.get(save) == nil, "origin cleared once the player is back") +ow.calls = {} +Origin.record(save, { gen = 1, version = "red", map = "NOWHERE", warp = 3, x = 1, y = 1 }) +scripts.onStep(fakeGame(save), ow, 13, 1) +eq(ow.calls[1] and ow.calls[1][1], "heal", "an invalid origin falls back to the heal point") +ow.calls = {} +scripts.onStep(fakeGame({ flags = {} }), ow, 13, 1) +eq(ow.calls[1] and ow.calls[1][1], "heal", "no origin falls back to the heal point") + +local function savedOn(map, origin, x, y) + local s = { player = { map = map, x = x or 13, y = y or 2, facing = "left", name = "RED" }, + lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 }, flags = {}, + lastOutdoor = { id = "PALLET_TOWN", x = 5, y = 6 } } + if origin then Origin.record(s, origin) end + return s +end +local function spot(s) return s.player.map .. "@" .. s.player.x .. "," .. s.player.y .. ":" .. s.player.facing end +local mtMoon = { gen = 1, version = "red", map = "MT_MOON_POKECENTER", warp = 3, x = 13, y = 1 } +local offData = load(src) +for _, case in ipairs({ { "off", offData }, { "on", data } }) do + local label, d = case[1], case[2] + local s1 = savedOn(UnionCenters.FLOOR_2F, mtMoon) + check(UnionSafety.settle(s1, d) ~= nil, label .. ": a 2F save is settled at load") + eq(spot(s1), "MT_MOON_POKECENTER@3,3:up", label .. ": 2F save lands in front of the origin nurse") + check(Origin.get(s1) == nil, label .. ": settle clears the origin") + eq(s1.lastOutdoor.id, "ROUTE_4", label .. ": lastOutdoor follows the origin center's town") + local s2 = savedOn(UnionCenters.UNION_ROOM, { gen = 1, version = "red", map = "INDIGO_PLATEAU_LOBBY", warp = 4, x = 15, y = 5 }) + UnionSafety.settle(s2, d) + eq(spot(s2), "INDIGO_PLATEAU_LOBBY@7,7:up", label .. ": room save lands below the Indigo nurse") + eq(s2.lastOutdoor.id, "INDIGO_PLATEAU", label .. ": Indigo save keeps the plateau as lastOutdoor") + local s3 = savedOn(UnionCenters.FLOOR_2F) + UnionSafety.settle(s3, d) + eq(spot(s3), "VIRIDIAN_POKECENTER@3,3:up", label .. ": no origin uses the heal point's center nurse") + local s4 = savedOn(UnionCenters.FLOOR_2F) + s4.lastHeal = { map = "GONE", x = 1, y = 1 } + UnionSafety.settle(s4, d) + eq(spot(s4), "VIRIDIAN_POKECENTER@3,3:up", label .. ": an unknown heal point uses the boot heal town's center") + local s5 = savedOn("VIRIDIAN_POKECENTER", nil, 12, 1) + UnionSafety.settle(s5, d) + eq(spot(s5), "VIRIDIAN_POKECENTER@3,3:up", label .. ": a 1F stairs alcove cell is not vanilla and moves") + local s6 = savedOn("VIRIDIAN_POKECENTER", nil, 11, 3) + check(UnionSafety.settle(s6, d) == nil and spot(s6) == "VIRIDIAN_POKECENTER@11,3:left", + label .. ": a vanilla 1F cell is left alone") + local s7 = savedOn("VIRIDIAN_CITY", nil, 5, 5) + check(UnionSafety.settle(s7, d) == nil, label .. ": ordinary maps are left alone") +end + +local live = savedOn(UnionCenters.UNION_ROOM, mtMoon, 12, 20) +live.party = { "mon" } +local livePlayer = live.player +local written +local ok = UnionSafety.write(live, data, function(out) + written = out + out.meta = { stamped = true } + return true +end) +check(ok, "write passes the writer's result through") +check(written ~= live, "write hands the writer a copy") +eq(spot(written), "MT_MOON_POKECENTER@3,3:up", "written save stands in front of the origin nurse") +check(Origin.get(written) == nil, "written save carries no origin") +eq(written.lastOutdoor.id, "ROUTE_4", "written lastOutdoor matches the 1F center") +check(written.party == live.party, "written save shares the rest of the save") +check(live.player == livePlayer and spot(live) == "UNION_ROOM@12,20:left", "the live player is not moved") +eq((Origin.get(live) or {}).map, "MT_MOON_POKECENTER", "the live origin stays") +eq(live.lastOutdoor.id, "PALLET_TOWN", "the live lastOutdoor stays") +check(live.meta and live.meta.stamped, "writer stamps flow back to the live save") +local plain = savedOn("VIRIDIAN_CITY", nil, 5, 5) +local seenPlain +UnionSafety.write(plain, data, function(out) seenPlain = out return true end) +check(seenPlain == plain, "a vanilla position writes the live table unchanged") + +T.finish("union_gen1_centers") diff --git a/tests/engine/union_gen1_centers_cache.lua b/tests/engine/union_gen1_centers_cache.lua new file mode 100644 index 00000000..d2314be0 --- /dev/null +++ b/tests/engine/union_gen1_centers_cache.lua @@ -0,0 +1,94 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local UnionCenters = require("src.world.gen1.UnionCenters") +local Map = require("src.world.Map") +local UnionSafety = require("src.world.gen1.UnionSafety") + +local MODULES = { "maps", "tilesets", "text_pointers", "field", "audio" } + +local function cacheRoot(version) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + local ids = {} + local env = os.getenv("POKEPORT_IDENTITY") + if env and env ~= "" then ids[#ids + 1] = env end + ids[#ids + 1] = "g1r-" .. version + ids[#ids + 1] = "pokeport-test-caches" + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs(ids) do + local root = base .. "/" .. id .. "/" .. version .. "/data/generated/" + local f = io.open(root .. "maps.lua", "rb") + if f then f:close() return root end + end + end + return nil +end + +local function loadData(root) + local data = {} + for _, name in ipairs(MODULES) do + local f = io.open(root .. name .. ".lua", "rb") + if not f then return nil end + local chunk = load(f:read("*a"), "@" .. name, "t", {}) + f:close() + local ok, mod = pcall(chunk) + if not ok then return nil end + data[name] = mod + end + return data +end + +local found = 0 +for _, version in ipairs({ "red", "blue", "yellow" }) do + local root = cacheRoot(version) + local data = root and loadData(root) + if data then + found = found + 1 + local r, why = UnionCenters.apply(data) + check(r ~= nil, version .. ": apply on the real cache: " .. tostring(why)) + if r then + eq(#r.order, #UnionCenters.EXPECTED, version .. ": every Gen 1 center planned") + for _, id in ipairs(UnionCenters.EXPECTED) do + local plan = r.plans[id] + check(plan and plan.verified, ("%s: %s matches the expected desk pattern (%s)") + :format(version, id, tostring(r.refused[id]))) + if plan then + local ts = data.tilesets[plan.tileset] + local m = Map.new(data.maps[id], ts) + check(m:isWarpTileCell(plan.stairs.x, plan.stairs.y), version .. ": " .. id .. " stairs are a warp tile") + check(not m:isWalkableCell(plan.desk.bx * 2, plan.desk.by * 2 - 1), version .. ": " .. id .. " nurse side sealed") + check(m:isWalkableCell(plan.front.x, plan.front.y), version .. ": " .. id .. " desk front walkable") + local pcOk = false + for _, h in ipairs(data.field.hiddenExtras.pcTiles[id] or {}) do + if h.x == plan.pc.x and h.y == plan.pc.y then pcOk = true end + end + check(pcOk and not m:isWalkableCell(plan.pc.x, plan.pc.y), version .. ": " .. id .. " PC kept") + local front = UnionSafety.nurseFront(data, id) + check(front and m:isWalkableCell(front.x, front.y) and m:isCounterCell(front.x, front.y - 1), + version .. ": " .. id .. " nurse front is a walkable cell across the counter") + check(UnionSafety.townOf(data, id) ~= nil, version .. ": " .. id .. " has a town door for lastOutdoor") + check(UnionSafety.centerOfTown(data, (UnionSafety.townOf(data, id))) == id, + version .. ": " .. id .. " is found again from its town") + end + end + for id, reason in pairs(r.refused) do + check(false, ("%s: unexpected refusal %s: %s"):format(version, id, reason)) + end + local indigo = r.plans.INDIGO_PLATEAU_LOBBY + eq(indigo and indigo.stairs.x .. "," .. indigo.stairs.y, "15,5", version .. ": Indigo stairs cell") + eq(indigo and indigo.receptionist.x .. "," .. indigo.receptionist.y, "13,6", version .. ": Indigo desk position") + check(data.maps[UnionCenters.FLOOR_2F] and data.maps[UnionCenters.UNION_ROOM], version .. ": added maps present") + end + end +end + +if found == 0 then + print("[skip] union_gen1_centers_cache: no Gen 1 cache") + os.exit(0) +end + +T.finish("union_gen1_centers_cache") diff --git a/tests/engine/union_gen1_presence_test.lua b/tests/engine/union_gen1_presence_test.lua new file mode 100644 index 00000000..2687e5e8 --- /dev/null +++ b/tests/engine/union_gen1_presence_test.lua @@ -0,0 +1,569 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local FakeRelay = require("tests.support.fake_relay") +local GameVersion = require("src.core.GameVersion") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local Avatars = require("src.online.union.Avatars") +local Collision = require("src.world.Collision") +local StateStack = require("src.core.StateStack") +local UnionRoomMap = require("src.world.gen1.UnionRoomMap") +local Presence = require("src.world.gen1.UnionRoomPresence") +local Text = require("src.ui.union.gen1.Text") +require("src.ui.union.gen1.Activity").installed = true +local Font = require("src.render.Font") + +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +GameVersion.set("red") + +local function pid(n) return ("%08x"):format(n) end + +local FP = { red = "1111111111111111", blue = "1111111111111111", yellow = "2222222222222222", + gold = "3333333333333333", silver = "3333333333333333", crystal = "4444444444444444", + firered = "5555555555555555", leafgreen = "5555555555555555", + emerald = "6666666666666666", ruby = "7777777777777777", sapphire = "7777777777777777" } + +local function profileFor(version) + local gen = Participant.genOf(version) + return { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" } +end + +local function ctxFor(version, name, tid, gender, style) + return { version = version, name = name, trainerId = tid, gender = gender or 0, + style = style, profile = profileFor(version), + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local reads = 0 +Avatars.setReader(function() reads = reads + 1 return nil end) + +local Input = {} +Input.__index = Input +function Input:wasPressed(k) return self.pressed[k] == true end +function Input:isDown() return false end +function Input:press(k) self.pressed[k] = true end +function Input:clear() self.pressed = {} end + +local function newGame() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local player = { cellX = 12, cellY = 25, px = 12 * 16, py = 25 * 16, facing = "up" } + local ow = { + isOverworld = true, map = { id = UnionRoomMap.MAP_ID }, player = player, + npcs = {}, entities = { player }, scriptMoves = {}, + runner = { isRunning = function() return false end }, + } + local game = { + stack = stack, overworld = ow, + input = setmetatable({ pressed = {} }, Input), + data = { sprites = {}, field = {}, audio = { sfx = {} }, + text = { _CableClubNPCPleaseWaitText = "Please wait.{DONE}" } }, + save = { player = { name = "RED", id = 4321 }, options = {}, flags = {} }, + } + stack:push(ow) + return game, ow +end + +local World = {} +World.__index = World + +local function newWorld() + return setmetatable({ relay = FakeRelay.new({ clock = function() return CLOCK end }), + clients = {}, rooms = {}, seats = {} }, World) +end + +function World:add(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + self.seats[#self.seats + 1] = seat + local room = Room.new({ client = C }) + self.rooms[#self.rooms + 1] = room + return room, C, seat +end + +function World:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end +end + +local function presenceFor(w, game, opts) + opts = opts or {} + local room, C, seat = w:add(1, "RED") + w:pump() + local p = Presence.new(game, { + room = room, + connect = opts.connect or function() return true end, + ctx = { profile = profileFor("red"), vanillaFingerprint = FP.red, gameplayMods = false }, + }) + game.unionPresence = p + p:start() + return p, room, C, seat +end + +local function step(w, game, n) + for _ = 1, n or 1 do + w:pump(1) + game.stack:update(1 / 60) + if game.unionPresence then game.unionPresence:tick() end + game.input:clear() + end +end + +local function topText(game) + local top = game.stack:top() + if not (top and top.pages) then return nil end + local out = {} + for _, page in ipairs(top.pages) do + for _, line in ipairs(page) do out[#out + 1] = type(line) == "table" and (line.text or "") or tostring(line) end + end + return table.concat(out, " ") +end + +local function until_(w, game, cond, n) + for _ = 1, n or 600 do + if cond() then return true end + step(w, game, 1) + end + return cond() +end + +local function mashUntil(w, game, cond, n) + for i = 1, n or 600 do + if cond() then return true end + local top = game.stack:top() + if top and top.pages and (top.waiting or (top.done and not top.stay and not top.choice)) and i % 2 == 0 then game.input:press("a") end + step(w, game, 1) + end + return cond() +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local function has(list, item) + for _, v in ipairs(list) do if v == item then return true end end + return false +end + +do + local long = "ABCDEFGHIJ" + for key in pairs(Text.S) do + local s = Text.say(key, long, long, long) + for line in (s:gsub("[\f\v]", "\n") .. "\n"):gmatch("(.-)\n") do + T.check(Font.glyphCount(line) <= 18, ("Gen 1 line fits 18 columns: %s %q"):format(key, line)) + end + end + for _, code in ipairs({ "server_outdated", "client_outdated", "offline", "lost", "bad_avatar" }) do + T.check(type(Text.error(code)) == "string" and #Text.error(code) > 0, "error text for " .. code) + end + T.check(Text.error("server_outdated") ~= Text.error("offline"), "server_outdated has its own line") + local b = Text.blocked("policy_mismatch") + T.check(b == b:upper(), "blocked text is uppercase in the Gen 1 register") + for line in (b:gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do + T.check(#line <= 18, "blocked line fits 18 columns: " .. line) + end + T.eq(Text.closed("declined", "GOLD"), Text.say("declined", "GOLD"), "declined invite text") + T.eq(Text.closed("busy", "GOLD"), Text.say("busy", "GOLD"), "busy invite text") + T.eq(Text.closed("target_left", "GOLD"), Text.say("gone", "GOLD"), "left invite text") + T.eq(Text.closed("weird", "GOLD"), Text.say("refused", "GOLD"), "unknown invite close text") + T.check(Text.standin({ name = "MAY", game = "ruby" }, { need = { "ruby", "sapphire" } }):find("RUBY", 1, true) ~= nil, + "stand-in text names the import") +end + +do + local w = newWorld() + local game, ow = newGame() + local p, room = presenceFor(w, game) + T.eq(p.state, "joining", "online client joins right away") + local others = { + { 2, "BLUE", "blue", 0, nil }, { 3, "KRIS", "crystal", 1, nil }, + { 4, "LEAF", "leafgreen", 1, "g3:3" }, { 5, "MAY", "ruby", 1, "player" }, + } + local rooms = {} + for i, o in ipairs(others) do + rooms[i] = w:add(o[1], o[2]) + end + w:pump() + for i, o in ipairs(others) do rooms[i]:join(ctxFor(o[3], o[2], o[1], o[4], o[5])) end + step(w, game, 4) + T.eq(p.state, "joined", "presence reaches joined") + T.eq(count(p.members), 4, "four participants spawn") + for _, m in pairs(p.members) do + local cell = Presence.cellFor(m.p.slot) + T.eq(m.cellX .. "," .. m.cellY, cell.x .. "," .. cell.y, "member stands on its slot cell " .. m.p.slot) + T.check(has(ow.npcs, m) and has(ow.entities, m), "member is in npcs and entities") + T.eq(Collision.occupied(ow.entities, cell.x, cell.y), m, "member blocks its cell") + end + T.check(has(ow.entities, p.overlay), "the tag overlay rides the draw list") + T.check(p.overlay.passable, "the overlay never blocks") + local kris = p:memberById(pid(3)) + T.eq(kris.p.gen, 2, "Crystal member is Gen 2") + T.eq(kris.digit, 2, "Crystal member badge digit 2") + T.check(kris.entry.standin, "Crystal not imported here reads as a stand-in") + T.eq(p:memberById(pid(4)).p.style, "g3:3", "FRLG class style is kept") + + local readsBefore = reads + local resolves = Avatars.stats().resolves + for _ = 1, 120 do + step(w, game, 1) + for _, m in pairs(p.members) do m:draw(0, 0) end + p.overlay:draw(0, 0) + end + T.eq(reads, readsBefore, "no cache reads while ticking and drawing") + T.eq(Avatars.stats().resolves, resolves, "no avatar resolves while ticking and drawing") + + rooms[2]:leave() + step(w, game, 3) + T.eq(p:memberById(pid(3)), nil, "a leaver despawns") + T.check(not has(ow.npcs, kris) and not has(ow.entities, kris), "the leaver is gone from npcs and entities") + local slot = kris.p.slot + local rs = w:add(6, "SILV") + w:pump() + rs:join(ctxFor("silver", "SILV", 6, 0)) + step(w, game, 3) + local silv = p:memberById(pid(6)) + T.check(silv ~= nil and silv.p.slot == slot, "a newcomer takes the freed slot") + local n = 0 + for _, e in ipairs(ow.entities) do if e.unionMember then n = n + 1 end end + T.eq(n, 4, "no stale entities after churn") + rs:setStatus("busy") + step(w, game, 3) + T.eq(silv.p.status, "busy", "status change reaches the member") + + ow.npcs, ow.entities = {}, { ow.player } + step(w, game, 1) + T.eq(#ow.npcs, 4, "members rebind after the world rebuilds its lists") + + ow.map = { id = "POKECENTER_2F" } + step(w, game, 2) + T.eq(game.unionPresence, nil, "leaving the map drops the presence") + T.eq(#ow.npcs, 0, "leaving removes every member") + T.eq(#w.relay:sent(w.seats[1], "plaza_leave"), 1, "leaving sends plaza_leave") +end + +do + local w = newWorld() + local game, ow = newGame() + local p = presenceFor(w, game) + local rooms = {} + for i = 1, 39 do + rooms[i] = w:add(100 + i, "T" .. i) + end + w:pump() + for i = 1, 39 do + local v = ({ "red", "gold", "firered", "crystal", "emerald" })[(i % 5) + 1] + rooms[i]:join(ctxFor(v, "T" .. i, i, i % 2)) + w:pump(1) + end + step(w, game, 4) + T.eq(count(p.members), 39, "39 others spawn in a full room") + local seen = {} + local distinct = true + for _, m in pairs(p.members) do + local key = m.cellX .. "," .. m.cellY + if seen[key] then distinct = false end + seen[key] = true + for _, e in ipairs(UnionRoomMap.EXITS) do + T.check(not (e.x == m.cellX and e.y == m.cellY), "no member on an exit cell") + end + T.check(m.cellX >= 0 and m.cellX < UnionRoomMap.WIDTH * 2 and m.cellY >= 2 and m.cellY < UnionRoomMap.HEIGHT * 2, + "member cell inside the room floor") + end + T.check(distinct, "40 trainers stand on distinct cells") + local map = { inBounds = function() return true end, isWalkableCell = function() return true end, + cellTile = function() return 0 end, def = {} } + local m = p.members[2] + ow.player.cellX, ow.player.cellY = m.cellX, m.cellY + 1 + local ok, why = Collision.canMove(map, ow.entities, ow.player, "up") + T.check(not ok and why == "entity", "the player can't walk into a member") + ow.player.facing = "up" + step(w, game, 1) + T.eq(p.focus, m, "the faced member gets the name tag") + T.eq(p.overlay.py, m.py + 0.5, "the tag draws right after its member") + p:leave() +end + +do + local w = newWorld() + local game, ow = newGame() + local p = presenceFor(w, game) + local cell = Presence.cellFor(2) + ow.player.cellX, ow.player.cellY = cell.x, cell.y + local rb = w:add(2, "GOLD") + w:pump() + rb:join(ctxFor("gold", "GOLD", 2, 0)) + step(w, game, 4) + T.eq(p.members[2], nil, "a member does not spawn on the player") + T.check(p.pending[2] ~= nil, "the member waits for its cell") + ow.player.cellX, ow.player.cellY = cell.x + 1, cell.y + step(w, game, 1) + T.check(p.members[2] ~= nil, "the member spawns once the cell is free") + p:leave() +end + +do + local w = newWorld() + local game, ow = newGame() + local p = presenceFor(w, game) + local rb = w:add(2, "MAY") + w:pump() + rb:join(ctxFor("emerald", "MAY", 2, 1, "g3:2")) + step(w, game, 4) + local m = p.members[2] + Presence.talk(game, ow, m, function() end) + T.check(mashUntil(w, game, function() return getmetatable(game.stack:top()) == require("src.ui.Menu") end, 300), + "talking opens the BATTLE/TRADE/CANCEL menu") + local menu = game.stack:top() + T.eq(#menu.items, 3, "three choices") + T.eq(menu.items[1].label, "BATTLE", "BATTLE first") + T.eq(menu.items[3].label, "CANCEL", "CANCEL last") + T.check(p.busy, "the presence is busy while the menu is open") + step(w, game, 2) + rb:poll() + T.eq(rb:member(pid(1)).status, "busy", "others see us busy while talking") + game.input:press("a") + step(w, game, 1) + T.check(until_(w, game, function() return #rb:incoming() == 1 end, 60), "BATTLE sends an invite") + T.eq(rb:incoming()[1].mode, "battle", "the invite is a battle") + rb:reply(rb:incoming()[1].id, true) + T.check(until_(w, game, function() return p.room:xgRoom() ~= nil end, 60), "the accept opens an xg room") + T.check(until_(w, game, function() + local t = topText(game) or "" + return t:find("Getting ready", 1, true) ~= nil + end, 600), "accepted invite shows the getting ready line") + local prep = p.room:prep() + T.eq(prep.state, "prep", "the prep reached the point prep screens start") + local peer = rb:prep() + peer:poll() + T.check(until_(w, game, function() return game.stack:top() and game.stack:top().tick ~= nil end, 120), + "the waiter is up") + game.input:press("b") + step(w, game, 2) + w:pump() + peer:poll() + T.eq(peer.state, "closed", "B cancels the prep for the peer") + T.eq(p.room.prepObj, nil, "the prep is dropped") + T.check((topText(game) or ""):find("canceled", 1, true) ~= nil, "cancel line shown") + p:leave() +end + +do + local w = newWorld() + local game, ow = newGame() + local p = presenceFor(w, game) + local rb = w:add(2, "GOLD") + w:pump() + rb:join(ctxFor("gold", "GOLD", 2, 0)) + step(w, game, 4) + rb:setStatus("busy") + step(w, game, 3) + Presence.talk(game, ow, p.members[2], function() end) + T.check((topText(game) or ""):find("busy", 1, true) ~= nil, "a busy member gives the busy line") + T.check(not p.busy, "a busy line does not hold our status") + game.stack:pop() + rb:setStatus("idle") + step(w, game, 3) + + local h = require("src.ui.union.gen1.Talk").invite(game, p, p.members[2].p, "xg_trade", function() end) + step(w, game, 2) + T.eq(rb:incoming()[1].mode, "trade", "TRADE sends a trade invite") + rb:reply(rb:incoming()[1].id, false) + T.check(until_(w, game, function() return h.state == "closed" end, 60), "the decline closes the invite") + T.check(until_(w, game, function() return (topText(game) or ""):find("said no", 1, true) ~= nil end, 300), + "the decline line shows") + game.stack:pop() + step(w, game, 2) + + local h2 = require("src.ui.union.gen1.Talk").invite(game, p, p.members[2].p, "xg_battle", function() end) + until_(w, game, function() return game.stack:top() and game.stack:top().tick ~= nil end, 300) + game.input:press("b") + step(w, game, 2) + T.check(p.abandoned[h2], "B while waiting abandons the invite") + rb:reply(rb:incoming()[1].id, true) + T.check(until_(w, game, function() return next(p.abandoned) == nil end, 60), "the abandoned handle is swept") + w:pump() + T.eq(rb:xgRoom(), nil, "a late accept of an abandoned invite is cancelled for the peer") + T.eq(p.room:xgRoom(), nil, "and we are out of the xg room") + p:leave() +end + +do + local w = newWorld() + local game, ow = newGame() + local p = presenceFor(w, game) + local rb = w:add(2, "LEAF") + w:pump() + rb:join(ctxFor("firered", "LEAF", 2, 1)) + step(w, game, 4) + rb:poll() + rb:invite(pid(1), "xg_battle") + step(w, game, 3) + local top = game.stack:top() + T.check(top and top.choice ~= nil, "an incoming invite opens a yes/no prompt") + T.check((topText(game) or ""):find("BATTLE", 1, true) ~= nil, "the prompt names the battle") + T.eq(p.members[2].facing, "down", "the inviter turns toward the player") + T.eq(p.focus, p.members[2], "the inviter wears the name tag while asking") + local ChoiceBox = require("src.ui.ChoiceBox") + T.check(mashUntil(w, game, function() return getmetatable(game.stack:top()) == ChoiceBox end, 600), + "the YES/NO box comes up") + game.input:press("a") + T.check(until_(w, game, function() return rb:xgRoom() ~= nil end, 120), "accepting opens the xg room for the inviter") + T.check(until_(w, game, function() return (topText(game) or ""):find("Getting ready", 1, true) ~= nil end, 600), + "accepting an invite reaches the getting ready line") + p:leave() + + local w2 = newWorld() + local game2 = newGame() + local p2 = presenceFor(w2, game2) + local rc = w2:add(2, "LEAF") + w2:pump() + rc:join(ctxFor("firered", "LEAF", 2, 1)) + step(w2, game2, 4) + rc:poll() + local hc = rc:invite(pid(1), "xg_trade") + step(w2, game2, 3) + mashUntil(w2, game2, function() return getmetatable(game2.stack:top()) == ChoiceBox end, 600) + game2.input:press("b") + until_(w2, game2, function() return hc.state == "closed" end, 120) + T.eq(hc.state, "closed", "declining closes the inviter's handle") + T.eq(hc.why, "declined", "the inviter hears declined") + T.check(not p2.busy, "declining frees the presence") + p2:leave() +end + +do + local w = newWorld() + local game = newGame() + local offline = { state = function() return "offline" end } + local p = Presence.new(game, { + room = Room.new({ client = offline }), + connect = function() return false, "no route" end, + ctx = { profile = profileFor("red"), vanillaFingerprint = FP.red, gameplayMods = false }, + }) + game.unionPresence = p + p:start() + T.eq(p.state, "offline", "a failed connect leaves the room empty") + step(w, game, 1) + T.check((topText(game) or ""):find("No other", 1, true) ~= nil, "offline line reads as empty room") + T.eq(count(p.members), 0, "offline has no members") + p:leave() +end + +do + local w = newWorld() + local game = newGame() + local p, room, C, seat = presenceFor(w, game) + local rb = w:add(2, "GOLD") + w:pump() + rb:join(ctxFor("gold", "GOLD", 2, 0)) + step(w, game, 4) + local before = p.members[2] + w.relay:drop(seat) + step(w, game, 2) + T.eq(p.state, "reconnecting", "a dropped link is reconnecting") + for _ = 1, Presence.LOST_SHOW_FRAMES + 2 do + p:tick() + end + T.check((topText(game) or ""):find("Reconnecting", 1, true) ~= nil, "the reconnecting line shows") + T.eq(p.members[2], before, "members stay while reconnecting") + w.relay:reconnect(seat) + CLOCK = CLOCK + 2 + step(w, game, 8) + T.eq(C.state(), "online", "the client resumes") + T.eq(p.state, "joined", "the presence resumes") + T.eq(p.members[2], before, "the member object survives the resume") + T.check(game.stack:top() == game.overworld, "the reconnecting box closes") + p:leave() +end + +do + local w = newWorld() + local game = newGame() + local p = presenceFor(w, game) + local rb = w:add(2, "GOLD") + w:pump() + local ctx = ctxFor("gold", "GOLD", 2, 0) + ctx.caps = { proto = 1, policy = 99, gens = { ["2"] = { { version = "gold", fp = FP.gold } } } } + rb:join(ctx) + step(w, game, 4) + local Talk = require("src.ui.union.gen1.Talk") + Talk.invite(game, p, p.members[2].p, "xg_trade", function() end) + step(w, game, 2) + rb:reply(rb:incoming()[1].id, true) + T.check(mashUntil(w, game, function() + local t = topText(game) or "" + return t:find("VERSION", 1, true) ~= nil + end, 900), "a policy mismatch shows the blocked line in Gen 1 caps") + T.eq(p.activity and p.activity.why, "blocked", "the activity finished as blocked") + w:pump(4) + T.eq(p.room:xgRoom(), nil, "the blocked prep room is left") + p:leave() + + local w2 = newWorld() + local game2 = newGame() + local p2 = presenceFor(w2, game2) + local rc = w2:add(2, "MAY") + w2:pump() + rc:join(ctxFor("ruby", "MAY", 2, 1)) + step(w2, game2, 4) + Talk.invite(game2, p2, p2.members[2].p, "xg_trade", function() end) + step(w2, game2, 2) + rc:reply(rc:incoming()[1].id, true) + T.check(until_(w2, game2, function() return p2.activity and p2.activity.state == "ready" end, 900), + "a trade reaches the ready stage") + T.check((topText(game2) or ""):find("TRADE", 1, true) ~= nil, "ready trade line") + local peer = rc:prep() + peer:poll() + peer:cancel("cancel") + T.check(until_(w2, game2, function() + return (topText(game2) or ""):find("canceled", 1, true) ~= nil + end, 600), "the peer cancelling shows a line") + T.check(mashUntil(w2, game2, function() return p2.activity == nil end, 600), "and ends the activity") + T.check(not p2.busy, "the presence is free after the peer cancels") + p2:leave() +end + +local function cacheText(version) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs({ "g1r-" .. version, "pokeport-test-caches" }) do + local f = io.open(base .. "/" .. id .. "/" .. version .. "/data/generated/text.lua", "rb") + if f then + local body = f:read("*a") + f:close() + local chunk = load(body, "@text", "t", {}) + local ok, mod = pcall(chunk) + if ok then return mod end + end + end + end + return nil +end + +for _, version in ipairs({ "red", "blue", "yellow" }) do + local text = cacheText(version) + if text then + local line = Text.pleaseWait({ data = { text = text } }) + T.check(line:find("Please wait.", 1, true) == 1, version .. " cache carries the cable club wait line") + else + print("[skip] no " .. version .. " cache for the cart wait line") + end +end + +T.finish() diff --git a/tests/engine/union_gen2_center.lua b/tests/engine/union_gen2_center.lua new file mode 100644 index 00000000..40a5608e --- /dev/null +++ b/tests/engine/union_gen2_center.lua @@ -0,0 +1,415 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Center = require("src.world.gen2.UnionCenter2F") +local Room = require("src.world.gen2.UnionRoomMap") +local Safety = require("src.world.gen2.UnionSafety") +local Origin = require("src.online.union.Origin") +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") + +local ON, OFF = {}, { unionRoom = false } + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end + +local function deepEq(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not deepEq(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +local function grid(w, h, fill) + local out = {} + for i = 1, w * h do out[i] = fill end + return out +end + +local function fixture() + local pc = { blocks = {}, collision = {} } + local function pcBlock(id, tile, coll) + local tiles = {} + for i = 1, 16 do tiles[i] = tile end + pc.blocks[id + 1], pc.collision[id + 1] = tiles, coll + end + pcBlock(0, 0, { 7, 7, 7, 7 }) + pcBlock(1, 1, { 0, 0, 0, 0 }) + pcBlock(2, 2, { 7, 7, 0, 0 }) + pcBlock(3, 3, { 7, 0x71, 0, 0 }) + pcBlock(4, 4, { 7, 0, 0, 0 }) + pcBlock(5, 5, { 7, 7, 7, 7 }) + pcBlock(6, 6, { 0, 0, 0x72, 0 }) + pcBlock(7, 7, { 0, 0x90, 0, 0 }) + local gate = { blocks = {}, collision = {} } + local function gateBlock(id, tile, coll) + local tiles = {} + for i = 1, 16 do tiles[i] = tile end + gate.blocks[id + 1], gate.collision[id + 1] = tiles, coll + end + gateBlock(0, 0, { 7, 7, 7, 7 }) + gateBlock(1, 10, { 7, 7, 0, 0 }) + gateBlock(2, 11, { 0, 0, 0, 0 }) + gateBlock(3, 12, { 0, 0, 0x70, 0x70 }) + + local blocks2f = grid(8, 4, 1) + for bx = 0, 7 do blocks2f[bx + 1] = 2 end + blocks2f[2 + 1], blocks2f[4 + 1] = 3, 3 + blocks2f[8 + 2 + 1], blocks2f[8 + 4 + 1], blocks2f[8 + 5 + 1] = 4, 4, 5 + blocks2f[24 + 1] = 6 + local function rcpt(i, x, y, key) + return { index = i, x = x, y = y, sprite = "SPRITE_LINK_RECEPTIONIST", spriteId = 56, + palette = 10, movement = 6, eventFlag = 65535, scriptKey = key, type = 0 } + end + local maps = { + POKECENTER_2F = { + id = "POKECENTER_2F", group = 20, map = 1, width = 8, height = 4, + tileset = "TILESET_POKECENTER", environment = "INDOOR", blocks = blocks2f, + warps = { + { x = 0, y = 7, destMap = "POKECENTER_2F", destWarp = 255 }, + { x = 5, y = 0, destMap = "TRADE_CENTER", destWarp = 1 }, + { x = 9, y = 0, destMap = "COLOSSEUM", destWarp = 1 }, + { x = 13, y = 2, destMap = "TIME_CAPSULE", destWarp = 1 }, + }, + objects = { rcpt(1, 5, 2, "t"), rcpt(2, 9, 2, "b"), rcpt(3, 13, 3, "c"), + { index = 4, x = 1, y = 1, sprite = "SPRITE_OFFICER", eventFlag = 1809, scriptKey = "o" } }, + bgEvents = { { kind = 0, x = 7, y = 3, scriptKey = "sign" } }, + sceneScripts = { [0] = { sceneId = 0, scriptKey = "s0" }, [1] = { sceneId = 1, scriptKey = "s1" }, + [2] = { sceneId = 2, scriptKey = "s2" }, [3] = { sceneId = 3, scriptKey = "s3" } }, + coordEvents = {}, callbacks = {}, connections = {}, + }, + TRADE_CENTER = { + id = "TRADE_CENTER", group = 20, map = 2, width = 5, height = 4, music = 38, + tileset = "TILESET_GATE", environment = "INDOOR", borderBlock = 0, + blocks = { 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2 }, + warps = { { x = 4, y = 7, destMap = "POKECENTER_2F", destWarp = 2 }, + { x = 5, y = 7, destMap = "POKECENTER_2F", destWarp = 2 } }, + objects = {}, callbacks = { {} }, + }, + CHERRYGROVE_POKECENTER_1F = { + id = "CHERRYGROVE_POKECENTER_1F", group = 11, map = 4, width = 5, height = 4, + tileset = "TILESET_POKECENTER", environment = "INDOOR", + blocks = { 2, 2, 2, 2, 2, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1 }, + warps = { { x = 3, y = 7, destMap = "CHERRYGROVE_CITY", destWarp = 3 }, + { x = 4, y = 7, destMap = "CHERRYGROVE_CITY", destWarp = 3 }, + { x = 0, y = 7, destMap = "POKECENTER_2F", destWarp = 1 } }, + objects = { { index = 1, x = 3, y = 1, sprite = "SPRITE_NURSE", eventFlag = 65535, scriptKey = "n" } }, + }, + } + local data = { + gen2Maps = maps, + gen2Tilesets = { TILESET_POKECENTER = pc, TILESET_GATE = gate }, + gen2Scripts = { movements = {}, b = { { op = "checkevent", event = 31 }, { op = "end" } } }, + gen2Constants = { specialOrder = { "WarpToSpawnPoint", "HealParty", "TryQuickSave" } }, + gen2Landmarks = { spawns = { SPAWN_CHERRYGROVE = { map = "CHERRYGROVE_CITY", x = 29, y = 4 } } }, + } + return data +end + +local function suite(label, makeData) + local pristine = makeData() + local off = makeData() + eq(Center.apply(off, OFF), false, label .. ": OFF apply reports nothing applied") + check(deepEq(off, pristine), label .. ": OFF leaves maps, tilesets and scripts deep-equal to the cache") + for _ = 1, 3 do Center.apply(off, OFF) end + check(deepEq(off, pristine), label .. ": repeated OFF reloads never accumulate") + + local data = makeData() + eq(Center.apply(data, ON), true, label .. ": ON applies") + local once = copy(data) + eq(Center.apply(data, ON), true, label .. ": second apply is a no-op success") + check(deepEq(data, once), label .. ": patch is idempotent") + local again = makeData() + Center.apply(again, ON) + check(deepEq(again, once), label .. ": a fresh reload patches to the same tables") + + local maps, tilesets = data.gen2Maps, data.gen2Tilesets + local vanilla = pristine.gen2Maps.POKECENTER_2F + local def = maps.POKECENTER_2F + local room = maps[Room.ID] + check(room ~= nil, label .. ": union room map added") + eq(def.width, vanilla.width + 2, label .. ": 2F gains two block columns") + eq(#def.blocks, def.width * def.height, label .. ": 2F block grid is complete") + for by = 0, vanilla.height - 1 do + for bx = 0, vanilla.width - 1 do + if def.blocks[by * def.width + bx + 1] ~= vanilla.blocks[by * vanilla.width + bx + 1] then + check(false, ("%s: original 2F block (%d,%d) changed"):format(label, bx, by)) + end + end + end + for i, w in ipairs(vanilla.warps) do + check(deepEq(def.warps[i], w), ("%s: original 2F warp %d unchanged"):format(label, i)) + end + for i, o in ipairs(vanilla.objects) do + check(deepEq(def.objects[i], o), ("%s: original 2F object %d unchanged"):format(label, i)) + end + check(deepEq(def.bgEvents, vanilla.bgEvents), label .. ": 2F sign unchanged") + for id, row in pairs(vanilla.sceneScripts) do + check(deepEq(def.sceneScripts[id], row), label .. ": 2F scene " .. tostring(id) .. " unchanged") + end + eq(def.warps[1].destWarp, 0xff, label .. ": the 2F stairs keep the -1 destination") + local doorIndex = #vanilla.warps + 1 + local door = def.warps[doorIndex] + eq(door and door.destMap, Room.ID, label .. ": new 2F warp leads to the union room") + for _, w in ipairs(room.warps) do + eq(w.destMap, "POKECENTER_2F", label .. ": room exit leads to the 2F") + eq(w.destWarp, doorIndex, label .. ": room exit lands on the union door") + end + local rcpt = def.objects[#def.objects] + eq(rcpt.scriptKey, Center.RECEPTIONIST_KEY, label .. ": union receptionist added") + eq(rcpt.x, door.x, label .. ": receptionist stands in the door column") + local map2f = Map.new(def, tilesets[def.tileset]) + eq(map2f:cellCollision(door.x, door.y), 0x71, label .. ": the union door cell is a door") + check(map2f:isWalkable(rcpt.x, rcpt.y + 1), label .. ": the desk front is floor") + check(map2f:isWalkable(rcpt.x, rcpt.y - 1) and map2f:isWalkable(rcpt.x - 1, rcpt.y - 1), + label .. ": the receptionist has room to step aside") + for _, key in ipairs({ Center.RECEPTIONIST_KEY, Center.LEFT_KEY, Room.SETUP_KEY }) do + check(type(data.gen2Scripts[key]) == "table", label .. ": script " .. key .. " registered") + end + local gateCmd = data.gen2Scripts[Center.RECEPTIONIST_KEY][3] + eq(gateCmd.op, "checkevent", label .. ": receptionist gates on an event") + eq(gateCmd.event, data.gen2Scripts[vanilla.objects[2].scriptKey][1].event, + label .. ": the gate is the cable club's own event") + + local roomMap = Map.new(room, tilesets[room.tileset]) + eq(room.width * 2, Room.WIDTH, label .. ": room width") + local seen = {} + for slot = 1, Room.CAP do + local x, y = Room.cellFor(slot) + check(x ~= nil, label .. ": slot " .. slot .. " has a cell") + local key = y * 64 + x + check(not seen[key], label .. ": slot " .. slot .. " does not overlap") + seen[key] = true + check(roomMap:isWalkable(x, y) and roomMap:isWalkable(x, y - 1), + label .. ": slot " .. slot .. " and its badge cell are floor") + eq(Room.slotAt(x, y), slot, label .. ": slotAt inverts cellFor for " .. slot) + check(not roomMap:warpAt(x, y), label .. ": slot " .. slot .. " is not an exit") + end + eq(Room.cellFor(Room.CAP + 1), nil, label .. ": no 41st slot") + eq(roomMap:cellCollision(Room.EXIT_X, Room.EXIT_Y), 0x70, label .. ": room exit is a warp mat") + + local function world(mapId, x, y) + local game = { data = { audio = { sfxOrder = {} } }, save = { player = {}, version = "gold" } } + local w = World.new(game) + w.maps, w.tilesets = maps, tilesets + w.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset]) + w.player = { cellX = x, cellY = y, facing = "left", moving = false } + w.setMap = function(self, id, cx, cy, facing) + self.loaded = { id = id, x = cx, y = cy, facing = facing } + self.map = Map.new(maps[id], tilesets[maps[id].tileset]) + self.player = { cellX = cx, cellY = cy, facing = facing or "down", moving = false } + return true + end + return w + end + local function pump(w) + for _ = 1, 64 do + if not w.mapSetup then return end + w:updateMapSetup() + end + end + local oneF = "CHERRYGROVE_POKECENTER_1F" + local w = world(oneF, 0, 7) + local stairsIndex = 3 + local stairs = maps[oneF].warps[stairsIndex] + check(w:takeWarp(stairs), label .. ": up the 1F stairs") + pump(w) + eq(w.loaded.id, "POKECENTER_2F", label .. ": arrived on the 2F") + eq(w.backupWarp and w.backupWarp.map, oneF, label .. ": backupWarp banks the 1F") + local origin = Origin.get(w.game.save) + eq(origin and origin.map, oneF, label .. ": origin records the 1F center") + eq(origin and origin.gen, 2, label .. ": origin is gen 2") + eq(origin and origin.x, stairs.x, label .. ": origin x is the 1F stairs") + w.game.save.unionOrigin = nil + w.player = { cellX = door.x, cellY = door.y, facing = "up", moving = false } + check(w:takeWarp(door), label .. ": through the union door") + pump(w) + eq(w.loaded.id, Room.ID, label .. ": in the union room") + eq(w.backupWarp.map, oneF, label .. ": entering the room keeps backupWarp") + eq(Origin.get(w.game.save), nil, label .. ": the room door does not rewrite the origin") + check(w:takeWarp(room.warps[1]), label .. ": out of the room") + pump(w) + eq(w.loaded.id, "POKECENTER_2F", label .. ": back on the 2F") + eq(w.loaded.x, door.x, label .. ": at the union door x") + eq(w.loaded.y, door.y, label .. ": at the union door y") + eq(w.backupWarp.map, oneF, label .. ": leaving the room keeps backupWarp") + w.player = { cellX = 0, cellY = 7, facing = "left", moving = false } + check(w:takeWarp(def.warps[1]), label .. ": down the 2F stairs") + pump(w) + eq(w.loaded.id, oneF, label .. ": the stairs still return to the same center") + + local offData = makeData() + local fx, fy, ff = Safety.nurseFront(data, oneF) + check(fx ~= nil, label .. ": " .. oneF .. " has a nurse front") + eq(ff, "up", label .. ": nurse front faces up") + local function stranded(pos, backup, withOrigin) + local save = { position = pos, backupWarp = backup, spawn = "SPAWN_CHERRYGROVE", + mapScenes = { POKECENTER_2F = 99, [Room.ID] = 1 } } + if withOrigin ~= false then + Origin.record(save, { gen = 2, version = "gold", map = oneF, warp = 3, x = 0, y = 7, facing = "left" }) + end + return save + end + local function atNurse(save, what) + eq(save.position and save.position.map, oneF, label .. ": " .. what .. " lands in the origin 1F") + eq(save.position and save.position.x, fx, label .. ": " .. what .. " at the nurse front x") + eq(save.position and save.position.y, fy, label .. ": " .. what .. " at the nurse front y") + eq(save.position and save.position.facing, "up", label .. ": " .. what .. " faces the nurse") + eq(save.backupWarp and save.backupWarp.map, oneF, label .. ": " .. what .. " banks the 1F stairs") + eq(save.backupWarp and save.backupWarp.warp, stairsIndex, label .. ": " .. what .. " banks the stairs warp") + end + local s1 = stranded({ map = Room.ID, x = 12, y = 23, facing = "down" }, { map = oneF, warp = 3 }) + eq(Safety.settle(s1, offData), oneF, label .. ": OFF room save relocates to the 1F") + atNurse(s1, "OFF room load") + eq(Origin.get(s1), nil, label .. ": load relocation clears the origin") + eq(s1.mapScenes.POKECENTER_2F, 0, label .. ": load relocation resets a union 2F scene") + eq(s1.mapScenes[Room.ID], nil, label .. ": load relocation drops the room scene") + local s2 = stranded({ map = Room.ID, x = 12, y = 23 }, { map = "NOWHERE", warp = 1 }, false) + eq(Safety.settle(s2, offData), oneF, label .. ": unknown origin falls back to the heal-point center") + atNurse(s2, "heal-point fallback") + local s3 = stranded({ map = "POKECENTER_2F", x = door.x, y = door.y + 3 }, { map = oneF, warp = 3 }) + eq(Safety.settle(s3, offData), oneF, label .. ": OFF save on a removed 2F cell relocates") + atNurse(s3, "OFF removed 2F cell") + local s4 = stranded({ map = "POKECENTER_2F", x = 0, y = 7 }, { map = oneF, warp = 3 }) + eq(Safety.settle(s4, offData), nil, label .. ": OFF save on a vanilla 2F cell stays") + check(Origin.get(s4) ~= nil, label .. ": and keeps its origin") + local s5 = stranded({ map = Room.ID, x = 12, y = 23 }, { map = oneF, warp = 3 }) + eq(Safety.settle(s5, data), oneF, label .. ": ON load also moves a room save to the 1F") + atNurse(s5, "ON room load") + local s6 = stranded({ map = "POKECENTER_2F", x = 14, y = 4 }, { map = oneF, warp = 3 }) + eq(Safety.settle(s6, data), nil, label .. ": ON save on a vanilla 2F cell stays") + + check(Safety.isAdded(data, { map = Room.ID, x = 1, y = 1 }), label .. ": the room is added") + check(Safety.isAdded(data, { map = "POKECENTER_2F", x = 16, y = 4 }), label .. ": 2F x=16 is added") + check(not Safety.isAdded(data, { map = "POKECENTER_2F", x = 15, y = 4 }), label .. ": 2F x=15 is vanilla") + check(not Safety.isAdded(data, { map = oneF, x = 3, y = 3 }), label .. ": a 1F cell is vanilla") + local leaveId + for id, row in pairs(def.sceneScripts) do + if row.scriptKey == Center.LEFT_KEY then leaveId = id end + end + local liveScenes = { POKECENTER_2F = leaveId, [Room.ID] = 1 } + local liveBackup = { map = oneF, warp = 3 } + for _, pos in ipairs({ { map = Room.ID, x = 12, y = 23, facing = "down" }, + { map = "POKECENTER_2F", x = door.x, y = door.y + 3, facing = "up" } }) do + local live = { position = pos, backupWarp = liveBackup, mapScenes = liveScenes, spawn = "SPAWN_CHERRYGROVE" } + Origin.record(live, { gen = 2, version = "gold", map = oneF, warp = 3, x = 0, y = 7 }) + eq(Safety.seal(live, data), oneF, label .. ": save-time seal moves " .. pos.map .. " to the 1F") + atNurse(live, "sealed " .. pos.map) + check(live.backupWarp ~= liveBackup and liveBackup.map == oneF and liveBackup.warp == 3, + label .. ": seal never mutates the live backupWarp") + check(live.mapScenes ~= liveScenes and liveScenes.POKECENTER_2F == leaveId and liveScenes[Room.ID] == 1, + label .. ": seal never mutates the live scene table") + eq(live.mapScenes[Room.ID], nil, label .. ": sealed save drops the room scene") + eq(live.mapScenes.POKECENTER_2F, 0, label .. ": sealed save resets the 2F leave scene") + check(Origin.get(live) ~= nil, label .. ": seal keeps the live origin") + end + local plain = { position = { map = "POKECENTER_2F", x = 0, y = 7 }, backupWarp = liveBackup } + eq(Safety.seal(plain, data), nil, label .. ": seal leaves a vanilla 2F save alone") + eq(plain.backupWarp, liveBackup, label .. ": and its backupWarp") + return data +end + +suite("fixture", fixture) + +local ok, Plaza = pcall(require, "src.core.game3.link.union_plaza_map") +if ok and Plaza and Plaza.cellFor then + for slot = 1, Room.CAP do + local gx, gy = Plaza.cellFor(slot) + local x, y = Room.cellFor(slot) + if gx ~= x or gy ~= y then + check(false, ("slot %d: gen 2 (%s,%s) vs gen 3 (%s,%s)"):format(slot, tostring(x), tostring(y), tostring(gx), tostring(gy))) + end + end + check(true, "slot geometry matches the Gen 3 plaza") +else + print("[skip] gen 3 plaza module not loadable: " .. tostring(Plaza)) +end + +local home = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/" +for _, v in ipairs({ "gold", "silver", "crystal" }) do + local dir = os.getenv(v:upper() .. "_CACHE") or (home .. "g1r-" .. v .. "/" .. v) + local probe = io.open(dir .. "/data/generated/maps.lua", "r") + if not probe then + print("[skip] no " .. v .. " cache at " .. dir) + else + probe:close() + local raw = {} + for _, f in ipairs({ "maps", "tilesets", "scripts", "constants", "landmarks" }) do + raw[f] = assert(loadfile(dir .. "/data/generated/" .. f .. ".lua"))() + end + local function makeData() + return { gen2Maps = copy(raw.maps), gen2Tilesets = copy(raw.tilesets), + gen2Scripts = copy(raw.scripts), gen2Constants = copy(raw.constants), + gen2Landmarks = copy(raw.landmarks) } + end + local data = suite(v, makeData) + local centers = 0 + for id, cdef in pairs(data.gen2Maps) do + local leads = false + for _, wp in ipairs(cdef.warps or {}) do + if wp.destMap == "POKECENTER_2F" and id ~= "POKECENTER_2F" and wp.destWarp == 1 then leads = true end + end + if leads then + centers = centers + 1 + local nx, ny, nf = Safety.nurseFront(data, id) + check(nx ~= nil and nf == "up", ("%s: %s has a nurse front cell"):format(v, id)) + end + end + eq(centers, 22, v .. ": 22 centers lead to the 2F") + local def = data.gen2Maps.POKECENTER_2F + local map = Map.new(def, data.gen2Tilesets[def.tileset]) + local blocked = {} + for _, o in ipairs(def.objects) do blocked[o.y * 64 + o.x] = true end + local seen, queue = { [7 * 64] = true }, { { 0, 7 } } + local head = 1 + while queue[head] do + local cx, cy = queue[head][1], queue[head][2] + head = head + 1 + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = cx + d[1], cy + d[2] + local k = ny * 64 + nx + if not seen[k] and not blocked[k] and map:isWalkable(nx, ny) then + seen[k] = true + queue[#queue + 1] = { nx, ny } + end + end + end + local function reachable(x, y) + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + if seen[(y + d[2]) * 64 + x + d[1]] then return true end + local cx, cy = x + d[1], y + d[2] + if map:inBounds(cx, cy) and require("src.world.gen2.Permissions").isCounter(map:cellCollision(cx, cy)) + and seen[(y + 2 * d[2]) * 64 + x + 2 * d[1]] then + return true + end + end + return false + end + for i, o in ipairs(def.objects) do + check(reachable(o.x, o.y), ("%s: 2F object %d (%s) can be talked to"):format(v, i, tostring(o.sprite))) + end + for _, ev in ipairs(def.bgEvents) do + check(reachable(ev.x, ev.y), ("%s: 2F sign at (%d,%d) can be read"):format(v, ev.x, ev.y)) + end + for i, wp in ipairs(def.warps) do + if map:isWalkable(wp.x, wp.y) or i == 1 then + local behind + for _, o in ipairs(def.objects) do + if o.x == wp.x and o.y > wp.y and o.y <= wp.y + 3 then behind = o end + end + check(behind ~= nil or seen[wp.y * 64 + wp.x] or i == 1, + ("%s: 2F warp %d to %s is reachable or guarded by its receptionist"):format(v, i, wp.destMap)) + end + end + end +end + +T.finish("union gen2 center") diff --git a/tests/engine/union_gen2_presence_draw_test.lua b/tests/engine/union_gen2_presence_draw_test.lua new file mode 100644 index 00000000..f431e76d --- /dev/null +++ b/tests/engine/union_gen2_presence_draw_test.lua @@ -0,0 +1,91 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Avatars = require("src.online.union.Avatars") +local Badge = require("src.online.union.Badge") +local Presence = require("src.world.gen2.UnionRoomPresence") +local Tag = require("src.ui.gen2.union.Tag") + +local function u32(n) + return string.char(math.floor(n / 16777216) % 256, math.floor(n / 65536) % 256, + math.floor(n / 256) % 256, n % 256) +end + +local function png(w, h) + return "\137PNG\r\n\26\n" .. u32(13) .. "IHDR" .. u32(w) .. u32(h) .. "\2\0\0\0\0" +end + +local FILES = { + ["red|assets/generated/sprites/red.png"] = png(16, 96), + ["crystal|assets/generated/sprites/kris.png"] = png(16, 96), + ["crystal|data/generated/sprites.lua"] = [[return { SPRITE_KRIS = { paletteId = 1, palette = "PAL_OW_BLUE" } }]], + ["crystal|data/generated/palettes.lua"] = [[return { objects = { DAY = { + { { 255, 255, 255 }, { 255, 160, 120 }, { 200, 40, 40 }, { 0, 0, 0 } }, + { { 255, 255, 255 }, { 150, 180, 255 }, { 40, 60, 220 }, { 0, 0, 0 } } } } }]], + ["firered|data/generated/gba/ow/manifest.lua"] = [[return { sprites = { + [19] = { width = 16, height = 32, frameCount = 10 } } }]], + ["firered|data/generated/gba/union_room/avatars.lua"] = [[return { gfx_ids = { + male = { 41, 54, 39, 19, 19, 20, 25, 26 }, female = { 42, 58, 40, 22, 23, 24, 28, 29 } } }]], + ["firered|data/generated/gba/ow/19.rgba"] = ("\0"):rep(16 * 32 * 10 * 4), +} + +local reads = 0 +Avatars.setReader(function(version, rel) + reads = reads + 1 + return FILES[version .. "|" .. rel] +end) +Badge.reset() + +local drawn = {} +local realDraw = love.graphics.draw +love.graphics.draw = function(img, quad, x, y, r, sx, sy) + drawn[#drawn + 1] = { img = img, x = x, y = y, sx = sx, sy = sy } + return realDraw(img, quad, x, y, r, sx, sy) +end + +local session = { lost = false } +local people = { + { slot = 1, id = "00000001", name = "RED", game = "red", gen = 1, gender = 0, style = "player" }, + { slot = 2, id = "00000002", name = "KRIS", game = "crystal", gen = 2, gender = 1, style = "player" }, + { slot = 3, id = "00000003", name = "LEAF", game = "firered", gen = 3, gender = 0, style = "g3:3" }, + { slot = 4, id = "00000004", name = "MAY", game = "emerald", gen = 3, gender = 1, style = "g3:5" }, +} +local ents = {} +for i, p in ipairs(people) do + ents[i] = Presence.Entity.new(session, p) + ents[i].alpha = 1 +end + +T.check(not ents[1].avatar.standin and ents[1].avatar.layout == "gb", "the Gen 1 member has its own sprite") +T.check(not ents[2].avatar.standin and ents[2].avatar.palette.mode == "gbc", "the Crystal member has her GBC palette") +T.check(not ents[3].avatar.standin and ents[3].avatar.h == 32, "the FRLG class member is 16x32") +T.check(ents[4].avatar.standin, "a member from a game with no cache is a stand-in") +T.eq(ents[4].avatar.need and ents[4].avatar.need[1], "emerald", "the stand-in names the import it needs") + +for _, e in ipairs(ents) do e:draw(0, 0, 3) end +local reads0, images0, badges0 = reads, Avatars.stats().images, Badge.builds() +for _ = 1, 200 do + for _, e in ipairs(ents) do e:draw(0, 0, 3) end +end +T.eq(reads, reads0, "drawing members reads no cache files after the first frame") +T.eq(Avatars.stats().images, images0, "member images are built once") +T.eq(Badge.builds(), badges0, "badge images are built once") + +drawn = {} +ents[3]:draw(10, 20, 2) +local sprite = drawn[1] +T.check(sprite ~= nil, "the GBA member draws") +T.eq(sprite.y, 20 + (ents[3].py + Presence.FOOT) * 2 - 32 * 2, "a 16x32 member stands on its feet") +T.eq(sprite.sx, 2, "members draw at the world's integer scale") + +drawn = {} +ents[1]:draw(0, 0, 1) +T.eq(drawn[1].y, ents[1].py + Presence.FOOT - 16, "a 16x16 member lands where Gen 2 sprites land") + +local w1, h1 = Tag.size("RED", false) +local w2, h2 = Tag.size("RED", true) +T.check(w2 > w1 and h2 > h1, "the name tag is wider than the bare badge") +T.check(w2 <= 48, "a 3-letter tag fits between two columns") + +love.graphics.draw = realDraw +T.finish("union_gen2_presence_draw") diff --git a/tests/engine/union_gen2_presence_test.lua b/tests/engine/union_gen2_presence_test.lua new file mode 100644 index 00000000..7c0e77b4 --- /dev/null +++ b/tests/engine/union_gen2_presence_test.lua @@ -0,0 +1,479 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local FakeRelay = require("tests.support.fake_relay") +local Data = require("tests.modkit.fixtures").fresh() +require("src.render.Font").load(Data) + +local Avatars = require("src.online.union.Avatars") +local ChoiceBox = require("src.ui.ChoiceBox") +local NPC = require("src.world.gen2.Npc") +local Participant = require("src.online.union.Participant") +local Player = require("src.world.gen2.Player") +local Presence = require("src.world.gen2.UnionRoomPresence") +local Room = require("src.online.union.Room") +local RoomMap = require("src.world.gen2.UnionRoomMap") +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") +local Activity = require("src.ui.gen2.union.Activity") +Activity.installed = true +local Text = require("src.ui.gen2.union.Text") + +Sound.play = function() end +Sound.playPress = function() end + +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +Data.text = Data.text or {} +Data.text._MysteryGiftCanceledText = "The link has been\ncancelled.{PROMPT}" + +local pushedText = {} +local realNew = TextBox.new +TextBox.new = function(game, text, onDone, opts) + pushedText[#pushedText + 1] = text + return realNew(game, text, onDone, opts) +end + +local reads = 0 +Avatars.setReader(function() + reads = reads + 1 + return nil +end) + +local function pid(n) return ("%08x"):format(n) end + +local FP = { red = "1111111111111111", gold = "3333333333333333", crystal = "4444444444444444", + firered = "5555555555555555", ruby = "7777777777777777", silver = "3333333333333333" } + +local function ctxFor(version, name, tid, gender, style) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = gender or 0, style = style, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" }, + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local function newStack() + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t + end + function stack:top() return self.states[#self.states] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return stack +end + +local MAP = {} +function MAP:inBounds(x, y) return x >= 0 and y >= 0 and x < RoomMap.WIDTH and y < RoomMap.HEIGHT end +function MAP:isWalkable(x, y) return self:inBounds(x, y) and y >= 2 end + +local function newWorld() + local w = { npcs = {}, entities = {}, map = MAP } + MAP.id = RoomMap.ID + w.player = Player.new(12, 24, "up") + w.entities[1] = w.player + function w:busy() return false end + function w:npcAt(x, y) + for _, n in ipairs(self.npcs) do + if NPC.covers(n, x, y) then return n end + end + return nil + end + return w +end + +local Env = {} +Env.__index = Env + +local function env(opts) + opts = opts or {} + local e = setmetatable({ relay = FakeRelay.new({ clock = function() return CLOCK end, legacy = opts.legacy }), + clients = {}, rooms = {}, seats = {}, pressed = {} }, Env) + e.stack = newStack() + e.game = { data = Data, stack = e.stack, save = { options = { textSpeed = 1 }, player = { name = "GOLD", id = 1 } }, + input = { wasPressed = function(_, k) return e.pressed[k] or false end, + isDown = function() return false end } } + e.world = newWorld() + return e +end + +function Env:client(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + self.seats[n] = seat + return C, seat +end + +function Env:add(n, version, name, gender, style) + local C = self:client(n, name) + self:pump(2) + local r = Room.new({ client = C }) + r:join(ctxFor(version, name, n, gender, style)) + self.rooms[n] = r + return r, C +end + +function Env:me(version) + local C, seat = self:client(1, "ME") + self.mine, self.mySeat = C, seat + self:pump(2) + self.session = Presence.new(self.game, self.world, { + client = C, room = Room.new({ client = C }), ctx = ctxFor(version or "gold", "ME", 1), + }) + return self.session +end + +function Env:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + for _, r in pairs(self.rooms) do r:poll() end + if self.session then self.session:update() end + self.stack:update(1 / 60) + end +end + +function Env:press(key, rounds) + self.pressed = { [key] = true } + self:pump(1) + self.pressed = {} + self:pump(rounds or 1) +end + +function Env:mash(key, cond, n) + for _ = 1, n or 400 do + if cond() then return true end + self:press(key) + end + return cond() +end + +function Env:wait(cond, n) + for _ = 1, n or 400 do + if cond() then return true end + self:pump(1) + end + return cond() +end + +local function topMt(e) return getmetatable(e.stack:top()) end + +local function checkMirror(e, label) + local s = e.session + local members = s.room:members() + local ok = true + for _, p in ipairs(members) do + local ent = s.bySlot[p.slot] + local pend = s.pending[p.slot] + if not ((ent and ent.participant.id == p.id) or (pend and pend.id == p.id)) then ok = false end + end + local count = 0 + for slot, ent in pairs(s.bySlot) do + count = count + 1 + local m = s.room:member(slot) + if not m or m.id ~= ent.participant.id then ok = false end + local x, y = RoomMap.cellFor(slot) + if ent.cellX ~= x or ent.cellY ~= y then ok = false end + end + local inNpcs, inEntities = 0, 0 + for _, n in ipairs(e.world.npcs) do if n.session == s then inNpcs = inNpcs + 1 end end + for _, n in ipairs(e.world.entities) do if n.session == s then inEntities = inEntities + 1 end end + T.check(ok and inNpcs == count and inEntities == count, label) +end + +do + local e = env() + e:add(2, "red", "RED", 0) + e:add(3, "crystal", "KRIS", 1) + e:add(4, "firered", "LEAF", 1, "g3:3") + local s = e:me("gold") + e:pump(6) + T.eq(s.state, "joined", "the presence joins once the client is online") + T.eq(#s:entities(), 3, "three others are spawned") + checkMirror(e, "entities mirror the plaza by slot") + local ent = s:entity(e.rooms[3]:self().slot) + T.eq(ent.participant.gender, 1, "the Crystal member keeps her gender") + T.eq(ent.participant.gen, 2, "the Crystal member reads gen 2") + T.eq(s:entity(e.rooms[4]:self().slot).participant.style, "g3:3", "the FRLG class style rides the entity") + T.check(ent.avatar.standin, "a member whose source game has no cache is a stand-in") + T.eq(#e.world.entities, 4, "the player plus three entities") + + e.rooms[3]:leave() + e:pump(4) + T.eq(#s:entities(), 2, "a leaver despawns") + checkMirror(e, "the mirror holds after a leave") + e:add(5, "silver", "SILV", 0) + e:pump(4) + T.eq(#s:entities(), 3, "a newcomer spawns") + checkMirror(e, "the newcomer takes the freed slot") + + local rng = 12345 + local function rand(n) + rng = (rng * 1103515245 + 12345) % 2147483648 + return rng % n + 1 + end + local versions = { "red", "gold", "crystal", "firered", "ruby" } + local mirrored = true + for i = 1, 60 do + local n = 10 + rand(12) + if e.rooms[n] and e.rooms[n].state ~= "left" then + e.rooms[n]:leave() + else + if e.rooms[n] then + e.rooms[n]:join(ctxFor(versions[rand(#versions)], "T" .. n, n)) + else + e:add(n, versions[rand(#versions)], "T" .. n) + end + end + e:pump(2) + local before = T.failures + checkMirror(e, "churn step " .. i .. " keeps the mirror") + if T.failures ~= before then mirrored = false break end + end + T.check(mirrored, "sixty churn steps keep entities equal to the plaza") + T.check(s.spawned > 10 and s.despawned > 5, "churn really spawned and despawned") +end + +do + local e = env() + local s = e:me("crystal") + e:pump(4) + local slot = 2 + local x, y = RoomMap.cellFor(slot) + e.world.player.cellX, e.world.player.cellY = x, y + e:add(2, "red", "RED") + e:pump(4) + T.check(s:entity(slot) == nil and s.pending[slot] ~= nil, "a member joining onto the player's cell waits") + e.world.player.cellX, e.world.player.cellY = x, y + 1 + e:pump(2) + T.check(s:entity(slot) ~= nil and s.pending[slot] == nil, "the member appears once the player steps off") + + e.world.player.facing = "up" + e.world.player.turnTimer = 0 + local r = e.world.player:tryMove("up", MAP, e.world.entities) + T.eq(r, "blocked", "a member blocks the player's step") + T.eq(e.world:npcAt(x, y), s:entity(slot), "the member is found where it stands") + T.check(not s:entity(slot).passable, "members are not passable") +end + +do + local e = env() + local s = e:me("gold") + e:pump(4) + for i = 2, 40 do + e:add(i, ({ "red", "crystal", "firered", "ruby" })[i % 4 + 1], "T" .. i) + e:pump(1) + end + e:pump(6) + T.eq(#s:entities(), 39, "39 others fill the room with me") + local seen, distinct = {}, true + for _, ent in ipairs(s:entities()) do + local key = ent.cellY * 64 + ent.cellX + if seen[key] then distinct = false end + seen[key] = true + end + T.check(distinct, "every member stands on its own cell") + local ex, ey = RoomMap.entry() + local reach, queue = { [ey * 64 + ex] = true }, { { ex, ey } } + while #queue > 0 do + local c = table.remove(queue, 1) + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local key = ny * 64 + nx + if MAP:isWalkable(nx, ny) and not seen[key] and not reach[key] then + reach[key] = true + queue[#queue + 1] = { nx, ny } + end + end + end + local talkable = true + for _, ent in ipairs(s:entities()) do + local any = false + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if reach[(ent.cellY + d[2]) * 64 + ent.cellX + d[1]] then any = true end + end + if not any then talkable = false end + end + T.check(talkable, "every member has a reachable cell to be talked to from") + + local r0, s0 = Avatars.stats().resolves, reads + for _ = 1, 120 do + e:pump(1) + for _, ent in ipairs(s:entities()) do ent:draw(0, 0, 2) end + end + T.eq(reads, s0, "no cache reads while the room runs and draws") + T.eq(Avatars.stats().resolves, r0, "no avatar resolves per frame") +end + +do + local e = env() + local rb = e:add(2, "red", "RED") + local s = e:me("gold") + e:pump(6) + local ent = s:entity(rb:self().slot) + e.world.player.cellX, e.world.player.cellY, e.world.player.facing = ent.cellX, ent.cellY + 1, "up" + e:pump(1) + T.eq(s.tagged, ent, "the faced member carries the name tag") + T.check(ent:onTalk(e.world), "talking to a member is claimed") + T.eq(ent.facing, "down", "the member turns to the player") + T.check(e:mash("a", function() return topMt(e) == ScriptMenu end), "the BATTLE / TRADE / CANCEL menu opens") + T.eq(#e.stack:top().items, 3, "the menu has three rows") + e:press("a") + T.check(e:wait(function() return #rb:incoming() == 1 end), "a battle invite reaches the member") + T.eq(rb:incoming()[1].mode, "battle", "the invite is a battle") + rb:reply(rb:incoming()[1].id, false) + T.check(e:wait(function() + return pushedText[#pushedText] == Text.closed("declined", "RED") + end), "a decline shows its line") + T.check(e:mash("a", function() return s.ui == nil and e.stack:top() == nil end), "the decline line closes") + T.eq(ent.facing, "down", "the member faces its default way after the talk") + + local screenCalled + Activity.screens.battle = function(act) screenCalled = act end + ent:onTalk(e.world) + e:mash("a", function() return topMt(e) == ScriptMenu end) + e:press("a") + T.check(e:wait(function() return #rb:incoming() == 1 end), "a second invite arrives") + rb:reply(rb:incoming()[1].id, true) + T.check(e:wait(function() return s.activity ~= nil end), "an accepted invite begins the activity") + T.check(e:wait(function() return screenCalled ~= nil end), "the activity hands over once prep rules are in") + T.eq(screenCalled.mode, "battle", "the hand-over carries the mode") + T.eq(screenCalled.peer.name, "RED", "the hand-over names the peer") + T.eq(screenCalled.prep.rules and screenCalled.prep.rules.ruleset, "g3u", "Gen 2 vs Gen 1 is a g3u battle") + screenCalled:cancel("cancel") + local pb = rb:prep() + T.check(e:wait(function() pb:poll() return pb.state == "closed" end), "cancel closes the peer's prep") + T.check(e:mash("a", function() return s.activity == nil and s.ui == nil and e.stack:top() == nil end), + "the activity ends cleanly") + Activity.screens.battle = nil + + ent:onTalk(e.world) + e:mash("a", function() return topMt(e) == ScriptMenu end) + e:press("a") + e:wait(function() return #rb:incoming() == 1 end) + T.check(e:wait(function() local t = e.stack:top() return t and t.tick ~= nil end), "the waiting line is up") + e:press("b") + T.check(e:wait(function() return pushedText[#pushedText] == Text.cancelled(e.game) end), + "B while waiting cancels with the cart line") + rb:reply(rb:incoming()[1].id, true) + e:pump(6) + T.check(s.activity == nil, "an invite accepted after cancelling never opens an activity") + e:mash("a", function() return e.stack:top() == nil end) + T.check(e:wait(function() return e.mine.room() == nil end), "the late room is left") +end + +do + local e = env() + local rb = e:add(2, "firered", "LEAF", 1, "g3:3") + local s = e:me("crystal") + e:pump(6) + local ent = s:entity(rb:self().slot) + local h = rb:invite(pid(1), "xg_trade") + T.check(e:wait(function() return topMt(e) == TextBox end), "an incoming request opens a prompt") + T.eq(pushedText[#pushedText], Text.say("askTrade", "LEAF"), "the prompt names the requester and trade") + T.eq(ent.facing, "down", "the requester turns toward the player") + T.check(e:mash("a", function() return topMt(e) == ChoiceBox end), "YES / NO comes up") + e:press("down") + e:press("a") + T.check(e:wait(function() return s.ui == nil and e.stack:top() == nil end), "NO closes the prompt") + T.check(e:wait(function() return h.state == "closed" and h.why == "declined" end), "the requester sees the decline") + + rb:invite(pid(1), "xg_battle") + e:wait(function() return topMt(e) == TextBox end) + T.eq(pushedText[#pushedText], Text.say("askBattle", "LEAF"), "a battle request names battle") + e:mash("a", function() return topMt(e) == ChoiceBox end) + e:press("a") + T.check(e:wait(function() return s.activity ~= nil end), "YES begins the activity") + local pb = rb:prep() + T.check(e:mash("a", function() return s.activity and s.activity.waiter and s.activity.waiter:shown() end), + "the getting-ready line is up") + e:press("b") + T.check(e:wait(function() return pushedText[#pushedText] == Text.cancelled(e.game) end), + "B on the getting-ready line cancels with the cart line") + T.check(e:wait(function() pb:poll() return pb.state == "closed" end), "the requester's prep closes") + e:mash("a", function() return s.activity == nil and e.stack:top() == nil end) + T.check(s.activity == nil, "the activity is gone after the cancel line") + + rb:invite(pid(1), "xg_battle") + e:wait(function() return topMt(e) == TextBox end) + e:mash("a", function() return topMt(e) == ChoiceBox end) + e:press("a") + e:wait(function() return s.activity ~= nil end) + e:mash("a", function() return s.activity and s.activity.waiter and s.activity.waiter:shown() end) + local pr = rb:prep() + pr:cancel("cancel") + T.check(e:wait(function() return pushedText[#pushedText] == Text.say("peerCancel", "LEAF") end), + "a peer cancel shows the peer's line") +end + +do + local e = env({ legacy = true }) + local s = e:me("gold") + e:pump(8) + T.eq(s.state, "offline", "an old relay leaves the presence offline") + T.eq(s.err, "server_outdated", "the error is server_outdated") + T.check(e:wait(function() return pushedText[#pushedText] == Text.error("server_outdated") end), + "the server_outdated line is shown") + T.eq(#s:entities(), 0, "the room stays empty") + T.check(Text.error("server_outdated") ~= Text.error("client_outdated"), "outdated texts differ per side") +end + +do + local e = env() + local rb = e:add(2, "red", "RED") + local s = e:me("gold") + e:pump(6) + local ent = s:entity(rb:self().slot) + for _ = 1, 40 do e:pump(1) end + T.eq(ent.alpha, 1, "members are fully shown while linked") + e.relay:drop(e.mySeat) + e:pump(2) + T.check(s.lost, "a dropped link is noticed") + for _ = 1, 30 do e:pump(1) end + T.eq(ent.alpha, Presence.FADE, "members fade while reconnecting") + T.check(e:wait(function() return pushedText[#pushedText] == Text.S.lost end), "the reconnecting line shows") + e.relay:reconnect(e.mySeat) + CLOCK = CLOCK + 2 + T.check(e:wait(function() return not s.lost end), "the link comes back") + for _ = 1, 30 do e:pump(1) end + T.eq(ent.alpha, 1, "members come back") + T.eq(s:entity(rb:self().slot), ent, "the same entity stands in the same slot after the resume") + T.check(e:wait(function() return e.stack:top() == nil and s.ui == nil end), "the reconnecting line closes itself") +end + +do + local e = env() + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return nil, "no route" end }) + local Connect = { + start = function() return C.connect({ name = "ME", profiles = {} }) end, + } + local s = Presence.new(e.game, e.world, { client = C, room = Room.new({ client = C }), + ctx = ctxFor("gold", "ME", 1), connect = Connect }) + e.session = s + e:pump(2) + T.eq(s.state, "offline", "no network leaves the room offline") + T.check(e:wait(function() return pushedText[#pushedText] == Text.error("offline") end), + "the no-network line is shown") + e:mash("a", function() return e.stack:top() == nil end) + T.check(s:idle(), "the room works as an empty room afterwards") +end + +TextBox.new = realNew +T.finish("union_gen2_presence") diff --git a/tests/engine/union_gen3_save_spot.lua b/tests/engine/union_gen3_save_spot.lua new file mode 100644 index 00000000..e64f1866 --- /dev/null +++ b/tests/engine/union_gen3_save_spot.lua @@ -0,0 +1,123 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SELF = "tests/engine/union_gen3_save_spot.lua" + +local function child(V, ROOT) + require("src.core.GameVersion").set(V) + require("src.import.gba.versions").select(V) + local Dataset = require("src.core.game3.dataset") + Dataset.cacheRootOverride = ROOT + Dataset.mountExtractRoots() + local SaveConvert = require("src.save_convert.SaveConvert") + SaveConvert.setGen3CacheDir(V, (ROOT:gsub("/data/generated/gba$", ""))) + local Schema = require("src.core.game3.save_schema_firered") + local Spot = require("src.core.game3.link.union_save_spot") + local s = Schema.newGame({ version = V, name = "TEST", gender = 0, rngSeed = 5 }) + s.version, s.trainerId, s.secretId = V, 1234, 5678 + local base = Schema.toSaveTable(s) + local pre = Spot.prefix(V) + local center = V == "firered" and "VIRIDIAN_CITY" or "OLDALE_TOWN" + local twoF, oneF = pre .. center .. "_POKEMON_CENTER_2F", pre .. center .. "_POKEMON_CENTER_1F" + local function at(map, x, y) + local t = {} + for k, v in pairs(base) do t[k] = v end + t.map, t.x, t.y, t.facing = map, x, y, "down" + t.dynamicWarp = { map = twoF, warpId = -1, x = 5, y = 1 } + t.specialSaveWarpFlags = 1 + t.continueGameWarp = { map = twoF, warpId = -1, x = 5, y = 1 } + return t + end + local cases = { { "room", at(Spot.roomId(V), 12, 20), oneF } } + if V == "ruby" then cases[#cases + 1] = { "added door cell", at(twoF, 2, 1), oneF } end + cases[#cases + 1] = { "vanilla 2F cell", at(twoF, 2, 3), twoF } + for _, c in ipairs(cases) do + local label, live, want = V .. " " .. c[1], c[2], c[3] + local liveMap = live.map + local bytes, err = SaveConvert.exportSav(live, V, nil) + check(bytes ~= nil, label .. ": exported -- " .. tostring(err)) + eq(live.map, liveMap, label .. ": the live save is not moved") + local back = bytes and SaveConvert.importSav(bytes, V, V) + eq(back and back.map, want, label .. ": the .sav reads back on " .. want) + if want == oneF then + eq(back and back.x, 7, label .. ": x in front of the nurse") + eq(back and back.y, 4, label .. ": y in front of the nurse") + end + end + T.finish("union_gen3_save_spot " .. V) +end + +if arg[1] and arg[2] then return child(arg[1], arg[2]) end + +do + local Spot = require("src.core.game3.link.union_save_spot") + local Rules = require("src.core.game3.profiles.firered_rules") + local nurse = Spot.nurseGfx("firered") + local open = function() return 0 end + local L = { collAt = function(_, x, y) return y == 3 and 1 or 0 end } + local game = { data = { maps = { + FR_CERULEAN_CITY_POKEMON_CENTER_1F = { + midLayout = L, objects = { { graphicsId = nurse, x = 7, y = 2 } }, + warps = { { x = 1, y = 6, destMap = "FR_CERULEAN_CITY_POKEMON_CENTER_2F" } }, + }, + FR_CERULEAN_CITY_POKEMON_CENTER_2F = { + midLayout = { collAt = open }, objects = {}, + warps = { { x = 1, y = 6, destMap = "FR_CERULEAN_CITY_POKEMON_CENTER_1F" } }, + }, + } } } + local session = { + version = "firered", map = "FR_UNION_ROOM_PLAZA", x = 12, y = 20, facing = "down", + dynamicWarp = { map = "FR_CERULEAN_CITY_POKEMON_CENTER_2F", warpId = 0, x = 5, y = 1 }, + specialSaveWarpFlags = 0, + } + check(type(Rules.saveLocation) == "function", "FireRed rules rewrite a plaza save") + local map, x, y, facing = Rules.saveLocation(session, game) + eq(map, "FR_CERULEAN_CITY_POKEMON_CENTER_1F", "plaza save lands in the origin center 1F") + eq(x, 7, "in front of the nurse (x)") + eq(y, 4, "in front of the nurse (y)") + eq(facing, "up", "facing the nurse") + eq(session.map, "FR_UNION_ROOM_PLAZA", "the live player is not moved") + session.map = "FR_CERULEAN_CITY_POKEMON_CENTER_2F" + check(Rules.saveLocation(session, game) == nil, "a vanilla 2F save is untouched") +end + +local function root(version) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + local ids = {} + local env = os.getenv("POKEPORT_IDENTITY") + if env and env ~= "" then ids[#ids + 1] = env end + ids[#ids + 1] = "g1r-" .. version + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs(ids) do + local r = base .. "/" .. id .. "/" .. version .. "/data/generated/gba" + local f = io.open(r .. "/map_tree/census.json", "rb") + if f then + f:close() + return r + end + end + end + return nil +end + +for _, v in ipairs({ "firered", "emerald", "ruby" }) do + local r = root(v) + if not r then + print("[skip] union_gen3_save_spot " .. v .. ": no cache") + else + local cmd = ("luajit %s %s '%s' 2>&1"):format(SELF, v, r) + local p = io.popen(cmd) + local out = p:read("*a") + local ok = p:close() + for line in out:gmatch("[^\n]+") do + if line:find("FAIL", 1, true) then print(line) end + end + check(ok == true or ok == 0, v .. ": cart export of an added-map save round-trips to the nurse front") + end +end + +T.finish("union_gen3_save_spot") diff --git a/tests/engine/union_room_avatars_test.lua b/tests/engine/union_room_avatars_test.lua new file mode 100644 index 00000000..f4d9d44a --- /dev/null +++ b/tests/engine/union_room_avatars_test.lua @@ -0,0 +1,395 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Avatars = require("src.online.union.Avatars") +local Badge = require("src.online.union.Badge") +local Participant = require("src.online.union.Participant") +local GameVersion = require("src.core.GameVersion") + +local function u32(n) + return string.char(math.floor(n / 16777216) % 256, math.floor(n / 65536) % 256, + math.floor(n / 256) % 256, n % 256) +end + +local function png(w, h) + return "\137PNG\r\n\26\n" .. u32(13) .. "IHDR" .. u32(w) .. u32(h) .. "\2\0\0\0\0" +end + +local function lua(t) + local out = {} + local function emit(v) + if type(v) == "table" then + out[#out + 1] = "{" + for k, x in pairs(v) do + out[#out + 1] = "[" .. (type(k) == "string" and ("%q"):format(k) or tostring(k)) .. "]=" + emit(x) + out[#out + 1] = "," + end + out[#out + 1] = "}" + elseif type(v) == "string" then + out[#out + 1] = ("%q"):format(v) + else + out[#out + 1] = tostring(v) + end + end + emit(t) + return "return " .. table.concat(out) +end + +local GB2_PALETTES = [[return { objects = { DAY = { + { { 255, 255, 255 }, { 255, 160, 120 }, { 200, 40, 40 }, { 0, 0, 0 } }, + { { 255, 255, 255 }, { 150, 180, 255 }, { 40, 60, 220 }, { 0, 0, 0 } } } } }]] +local FRLG_UNION = [[return { gfx_ids = { male = { 41, 54, 39, 19, 19, 20, 25, 26 }, + female = { 42, 58, 40, 22, 23, 24, 28, 29 } } }]] +local E_UNION = [[return { gfx_ids = { male = { 33, 44, 31, 35, 37, 36, 65, 66 }, + female = { 34, 40, 32, 47, 47, 14, 20, 45 } } }]] + +local function rgba(w, h, n) return ("\0"):rep(w * h * n * 4) end + +local function gbSprites(lists, extra, gen) + local t = {} + for _, list in pairs(lists) do + for _, id in ipairs(list) do + t[id] = { walker = true, frames = 6, paletteId = 1, palette = "PAL_OW_BLUE", + image = "assets/generated/sprites/" .. id:gsub("^SPRITE_", ""):lower() .. ".png" } + end + end + for _, id in ipairs(extra) do + t[id] = { walker = true, frames = 6, paletteId = 0, palette = "PAL_OW_RED", + image = "assets/generated/sprites/" .. id:gsub("^SPRITE_", ""):lower() .. ".png" } + end + return t +end + +local function gbaManifest(lists, own, withAvatars) + local sprites = {} + for gid, n in pairs(own) do sprites[gid] = { width = 16, height = 32, frameCount = n } end + for _, list in pairs(lists) do + for _, gid in ipairs(list) do sprites[gid] = { width = 16, height = 32, frameCount = 9 } end + end + local t = { sprites = sprites } + if withAvatars then t.avatars = { player = { { state = "NORMAL", male = 0, female = 89 } } } end + return t +end + +local log = {} + +local function synthetic(imported) + local files = {} + local function put(version, rel, body) files[version .. "|" .. rel] = body end + local gb1 = gbSprites(Avatars.HOST_GB1, { "SPRITE_RED", "SPRITE_OAK", "SPRITE_NURSE" }) + for _, v in ipairs({ "red", "blue", "yellow" }) do + put(v, "data/generated/sprites.lua", lua(gb1)) + for _, def in pairs(gb1) do put(v, def.image, png(16, 96)) end + end + local gb2 = gbSprites(Avatars.HOST_GB2, { "SPRITE_CHRIS", "SPRITE_KRIS", "SPRITE_ELM", "SPRITE_CLERK" }) + gb2.SPRITE_CHRIS.paletteId, gb2.SPRITE_KRIS.paletteId = 0, 1 + gb2.SPRITE_KRIS.palette = "PAL_OW_BLUE" + for _, v in ipairs({ "gold", "silver", "crystal" }) do + put(v, "data/generated/sprites.lua", lua(gb2)) + put(v, "data/generated/palettes.lua", GB2_PALETTES) + for id, def in pairs(gb2) do + if id ~= "SPRITE_KRIS" or v == "crystal" then put(v, def.image, png(16, 96)) end + end + end + for _, v in ipairs({ "firered", "leafgreen" }) do + local m = gbaManifest(Avatars.HOST_FRLG, { [0] = 20, [7] = 20, [64] = 9, [71] = 9 }, false) + put(v, "data/generated/gba/ow/manifest.lua", lua(m)) + put(v, "data/generated/gba/union_room/avatars.lua", FRLG_UNION) + for gid, info in pairs(m.sprites) do + put(v, "data/generated/gba/ow/" .. gid .. ".rgba", rgba(16, 32, info.frameCount)) + end + end + for _, v in ipairs({ "ruby", "sapphire", "emerald" }) do + local m = gbaManifest(Avatars.HOST_RSE, { [0] = 18, [89] = 18, [33] = 9, [58] = 9 }, true) + put(v, "data/generated/gba/ow/manifest.lua", lua(m)) + for gid, info in pairs(m.sprites) do + if gid ~= 33 or v == "emerald" then + put(v, "data/generated/gba/ow/" .. gid .. ".rgba", rgba(16, 32, info.frameCount)) + end + end + end + put("emerald", "data/generated/gba/union_room/avatars.lua", E_UNION) + local set = {} + for _, v in ipairs(imported) do set[v] = true end + return function(version, rel) + local body = set[version] and files[version .. "|" .. rel] or nil + if body then log[#log + 1] = version .. "|" .. rel end + return body + end +end + +local function who(game, gender, style, tid, name) + return { game = game, gen = Participant.genOf(game), gender = gender or 0, style = style or "player", + trainerId = tid or 1, name = name or "ASH" } +end + +local function needs(e) + return table.concat(e.need or {}, ",") +end + +local function inList(list, v) + for _, x in ipairs(list) do if x == v then return true end end + return false +end + +do + Avatars.setReader(synthetic(GameVersion.ORDER)) + local host = { version = "red" } + local r = Avatars.resolve(who("red"), host) + T.check(not r.standin and not r.hostStandin, "a Gen 1 player resolves to the real sprite") + T.eq(r.version, "red", "the Gen 1 sprite comes from the participant's own version") + T.eq(r.source.rel, "assets/generated/sprites/red.png", "the Gen 1 player is the RED sheet") + T.eq(r.layout, "gb", "Gen 1 sheets use the GB layout") + T.eq(r.frames, 6, "a GB walker sheet has 6 frames") + T.eq(r.w .. "x" .. r.h, "16x16", "GB frames are 16x16") + T.eq(r.anchor.y, 16, "the anchor is the feet") + T.eq(r.palette.mode, "dmg", "Gen 1 sprites keep DMG shades") + T.eq(r.rects[3].y, 48, "frame rects step down the sheet") + T.eq(Avatars.resolve(who("yellow"), host).version, "yellow", "Yellow uses its own sheet when imported") + local g = Avatars.resolve(who("gold"), host) + T.eq(g.source.rel, "assets/generated/sprites/chris.png", "a Gen 2 male uses the Chris sheet") + T.eq(g.palette.mode, "gbc", "Gen 2 sprites carry a GBC palette") + T.eq(g.palette.name, "PAL_OW_RED", "the Chris sheet wears PAL_OW_RED") + T.eq(#g.palette.colors, 4, "the palette has four colors") + local k = Avatars.resolve(who("crystal", 1), host) + T.eq(k.source.rel, "assets/generated/sprites/kris.png", "a Crystal female uses the Kris sheet") + T.eq(k.palette.name, "PAL_OW_BLUE", "the Kris sheet wears PAL_OW_BLUE") + local fr = Avatars.resolve(who("firered"), host) + T.eq(fr.gid, 0, "a FireRed male player is gfx 0") + T.eq(fr.layout, "gba", "Gen 3 sheets use the GBA layout") + T.eq(fr.palette.mode, "rgba", "Gen 3 sheets are baked RGBA") + T.eq(fr.h, 32, "Gen 3 frames are 32 tall") + T.eq(Avatars.resolve(who("leafgreen", 1), host).gid, 7, "a LeafGreen female player is gfx 7") + T.eq(Avatars.resolve(who("firered", 0, "g3:3"), host).gid, 19, "a FRLG class token picks the union class sprite") + T.eq(Avatars.resolve(who("emerald", 0, "g3:0"), host).gid, 33, "an Emerald class token picks the Emerald class sprite") + T.eq(Avatars.resolve(who("ruby", 1), host).gid, 89, "a Ruby female player is May") + T.eq(Avatars.resolve(who("sapphire"), host).version, "sapphire", "Sapphire uses its own sheet") +end + +do + Avatars.setReader(synthetic({ "red" })) + local host = { version = "red" } + T.eq(Avatars.resolve(who("blue"), host).version, "red", "Blue's player shows from an imported Red") + T.eq(Avatars.resolve(who("yellow"), host).version, "red", "Yellow's player shows from an imported Red") + log = {} + local g = Avatars.resolve(who("gold", 0, "player", 4242, "ETHAN"), host) + T.check(g.hostStandin and not g.standin, "a Gen 2 player without a Gen 2 import wears a Red NPC") + T.eq(g.version, "red", "the host stand-in comes from the viewer's game") + T.eq(g.layout, "gb", "the host stand-in is in the viewer's native layout") + T.eq(g.palette.mode, "dmg", "a Red viewer's stand-in keeps DMG shades") + T.check(inList(Avatars.HOST_GB1[0], g.hostRef), "a male stand-in is a curated Gen 1 trainer (" .. tostring(g.hostRef) .. ")") + T.eq(needs(g), "gold,silver,crystal", "the stand-in still names the imports for the real look") + T.eq(g.gen, 2, "the stand-in keeps the source gen for the badge") + T.eq(g.anchor.y, 16, "the stand-in is anchored at the feet") + local foreign = false + for _, l in ipairs(log) do if l:sub(1, 4) ~= "red|" then foreign = true end end + T.check(not foreign and #log > 0, "the stand-in reads only the active game's cache") + local e = Avatars.resolve(who("emerald", 1, "player", 77, "MAY"), host) + T.check(e.hostStandin and e.version == "red", "a Gen 3 player is never drawn with a Gen 3 sheet the viewer lacks") + T.check(inList(Avatars.HOST_GB1[1], e.hostRef), "a female stand-in is a curated Gen 1 trainer") + T.check(Avatars.draw(e, 40, 40, "down", 0, false, 1), "the stand-in draws like a sprite") + T.eq(Avatars.drawStandin(), false, "nothing draws a pawn") +end + +do + local host = { version = "red" } + local picks = {} + for tid = 1, 40 do + Avatars.setReader(synthetic({ "red" })) + local p = who("gold", 0, "player", tid, "T" .. tid) + local a = Avatars.resolve(p, host) + local b = Avatars.resolve(p, host) + Avatars.reset() + Avatars.setReader(synthetic({ "red" })) + local c = Avatars.resolve(p, host) + if a.hostRef ~= b.hostRef or a.hostRef ~= c.hostRef then picks.unstable = true end + picks[a.hostRef] = true + end + T.check(not picks.unstable, "the same participant gets the same pick across frames and sessions") + local n = 0 + for k in pairs(picks) do if k ~= "unstable" then n = n + 1 end end + T.check(n >= 5, "different participants spread across the curated list (" .. n .. ")") + Avatars.setReader(synthetic({ "red" })) + local x = Avatars.resolve(who("gold", 0, "player", 9, "AAA"), host) + local y = Avatars.resolve(who("gold", 0, "player", 9, "AAA"), { version = "red" }) + T.check(x == y, "a repeated resolve returns the cached stand-in") +end + +do + Avatars.setReader(synthetic({ "gold" })) + local host = { version = "gold" } + local k = Avatars.resolve(who("crystal", 1, "player", 5, "KRIS"), host) + T.check(k.hostStandin, "Kris needs Crystal: a Gold viewer shows a Gold NPC") + T.eq(needs(k), "crystal", "the female stand-in names Crystal only") + T.check(inList(Avatars.HOST_GB2[1], k.hostRef), "the Gold stand-in is a curated female trainer") + T.eq(k.palette.mode, "gbc", "a Gold viewer's stand-in wears a GBC palette") + T.eq(k.version, "gold", "the Gold stand-in comes from Gold") + T.eq(Avatars.resolve(who("crystal", 0), host).version, "gold", "Crystal's Chris shows from Gold") + T.eq(Avatars.resolve(who("silver"), host).version, "gold", "Silver's Chris shows from Gold") + local r = Avatars.resolve(who("red", 0, "player", 3, "RED"), host) + T.check(r.hostStandin and r.gen == 1 and inList(Avatars.HOST_GB2[0], r.hostRef), + "a Gen 1 player seen from Gold wears a Gold trainer and keeps badge 1") +end + +do + Avatars.setReader(synthetic({ "ruby", "firered" })) + local host = { version = "ruby" } + T.eq(Avatars.resolve(who("emerald"), host).version, "ruby", "Emerald's Brendan shows from Ruby") + local cls = Avatars.resolve(who("emerald", 0, "g3:0", 8, "WALLY"), host) + T.check(cls.hostStandin and cls.version == "ruby", "an Emerald class without Emerald wears a Ruby NPC") + T.eq(needs(cls), "emerald", "the class stand-in names Emerald") + T.check(inList(Avatars.HOST_RSE[0], cls.gid), "the Ruby stand-in is a curated RS trainer gfx") + T.eq(cls.layout, "gba", "a Ruby viewer's stand-in is a GBA sheet") + T.eq(cls.palette.mode, "rgba", "a Ruby viewer's stand-in is RGBA") + T.eq(Avatars.resolve(who("leafgreen", 0, "g3:3"), host).version, "firered", "LeafGreen classes show from FireRed") + local r = Avatars.resolve(who("red", 1, "player", 2, "LEAF"), { version = "firered" }) + T.check(r.hostStandin and inList(Avatars.HOST_FRLG[1], r.gid) and r.version == "firered", + "a Gen 1 player seen from FireRed wears a curated FRLG trainer") +end + +do + local banned1 = { SPRITE_RED = 1, SPRITE_BLUE = 1, SPRITE_OAK = 1, SPRITE_NURSE = 1, SPRITE_CLERK = 1, + SPRITE_GIOVANNI = 1, SPRITE_BROCK = 1, SPRITE_MISTY = 1, SPRITE_LANCE = 1, SPRITE_AGATHA = 1, + SPRITE_BRUNO = 1, SPRITE_LORELEI = 1, SPRITE_KOGA = 1, SPRITE_MOM = 1, SPRITE_DAISY = 1, + SPRITE_MR_FUJI = 1, SPRITE_ROCKET = 1, SPRITE_ROCKET_GIRL = 1, SPRITE_SEEL = 1, SPRITE_BIRD = 1, + SPRITE_MONSTER = 1, SPRITE_FAIRY = 1, SPRITE_PIKACHU = 1, SPRITE_CHRIS = 1, SPRITE_KRIS = 1, + SPRITE_RIVAL = 1, SPRITE_ELM = 1, SPRITE_RECEPTIONIST = 1, SPRITE_LINK_RECEPTIONIST = 1, + SPRITE_GYM_GUIDE = 1, SPRITE_FALKNER = 1, SPRITE_WHITNEY = 1, SPRITE_KIMONO_GIRL = 1, + SPRITE_SILPH_WORKER_F = 1, SPRITE_OFFICER = 1, SPRITE_OFFICER_JENNY = 1, SPRITE_WAITER = 1 } + local bad = {} + for _, lists in ipairs({ Avatars.HOST_GB1, Avatars.HOST_GB2 }) do + for _, list in pairs(lists) do + for _, id in ipairs(list) do if banned1[id] then bad[#bad + 1] = id end end + end + end + local bannedFrlg = { [0] = 1, [7] = 1, [14] = 1, [15] = 1, [49] = 1, [50] = 1, [64] = 1, [65] = 1, + [66] = 1, [68] = 1, [71] = 1, [72] = 1, [73] = 1, [74] = 1, [75] = 1, [76] = 1, [77] = 1, [78] = 1, [79] = 1 } + local bannedRse = { [0] = 1, [4] = 1, [28] = 1, [41] = 1, [58] = 1, [59] = 1, [60] = 1, [64] = 1, + [70] = 1, [71] = 1, [72] = 1, [73] = 1, [74] = 1, [75] = 1, [83] = 1, [85] = 1, [89] = 1 } + for g = 0, 1 do + for _, gid in ipairs(Avatars.HOST_RSE[g]) do if bannedRse[gid] then bad[#bad + 1] = "rse" .. gid end end + for _, gid in ipairs(Avatars.HOST_FRLG[g]) do if bannedFrlg[gid] then bad[#bad + 1] = "frlg" .. gid end end + end + T.eq(table.concat(bad, ","), "", "the curated lists hold no story characters, staff, Pokemon or objects") +end + +do + Avatars.setReader(synthetic({ "red", "gold" })) + local host = { version = "red" } + local p = who("red") + Avatars.resolve(p, host) + local before = Avatars.stats() + for _ = 1, 200 do Avatars.resolve(p, host) end + for _ = 1, 200 do Avatars.resolve(who("gold"), host) end + local after = Avatars.stats() + T.eq(after.reads - before.reads, 3, "repeated resolves read each source file once") + for _ = 1, 50 do Avatars.resolve(who("crystal", 1, "player", 6, "K"), host) end + T.eq(Avatars.stats().resolves, 3, "a stand-in is cached per participant too") + local readsBefore = Avatars.stats().reads + local entry = Avatars.resolve(p, host) + for _ = 1, 60 do Avatars.draw(entry, 40, 40, "down", 0, false, 2) end + T.eq(Avatars.stats().reads, readsBefore, "drawing never reads the cache") + T.eq(Avatars.stats().images, 1, "drawing builds the image once") +end + +do + local gb = { layout = "gb", frames = 6 } + local f, flip = Avatars.pose(gb, "right", 0, false) + T.eq(f .. tostring(flip), "2true", "GB right is the left frame flipped") + f, flip = Avatars.pose(gb, "down", 1, true) + T.eq(f .. tostring(flip), "3true", "GB alternate down step flips the walk frame") + local gba = { layout = "gba", frames = 9 } + T.eq((Avatars.pose(gba, "up", 1, true)), 5, "GBA north step A is frame 5") + T.eq((Avatars.pose(gba, "left", 1, false)), 8, "GBA west step B is frame 8") + T.eq((Avatars.pose(gba, "up", 0)), 1, "GBA stands north on frame 1") +end + +do + T.eq(select(1, Badge.size(1)), 9, "the badge is 9 px at 1x") + T.eq(select(2, Badge.size(3)), 27, "the badge scales by integer factors") + local function sig(d) + local out = {} + for _, p in ipairs(Badge.pixels(d, 3)) do + if p.c == Badge.STYLES[3].digit then out[#out + 1] = p.x .. ":" .. p.y end + end + return table.concat(out, ",") + end + T.check(sig(1) ~= sig(2) and sig(2) ~= sig(3) and sig(1) ~= sig(3), "the three digits draw differently") + T.check(#sig(1) > 0, "a digit has pixels") + local ring = 0 + for _, p in ipairs(Badge.pixels(2, 1)) do if p.c == Badge.STYLES[1].ring then ring = ring + 1 end end + T.eq(ring, 24, "the ring is a closed 9x9 circle") + T.eq(Badge.STYLES[1].fill[2], 248, "the Gen 1 style uses DMG shades") + Badge.reset() + for _ = 1, 100 do Badge.draw(10, 10, 1, 1, 2) end + Badge.draw(10, 10, 2, 1, 2) + T.eq(Badge.builds(), 2, "badge images are built once per digit and style") + T.check(not Badge.draw(0, 0, 4, 1), "only 1, 2 and 3 have badges") +end + +local function cacheRoot(version) + local home = os.getenv("HOME") + if not home then return nil end + local prefix = GameVersion.cachePrefix(version) + local probe = GameVersion.generation(version) == 3 and "data/generated/gba/ow/manifest.lua" + or "assets/generated/sprites/red.png" + if GameVersion.generation(version) == 2 then probe = "assets/generated/sprites/chris.png" end + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs({ os.getenv("POKEPORT_IDENTITY") or "", "g1r-" .. version, "pokeport-test-caches" }) do + if id ~= "" then + local root = base .. "/" .. id + local f = io.open(root .. "/" .. prefix .. probe, "rb") + if f then + f:close() + return root + end + end + end + end + return nil +end + +do + local roots, list = {}, {} + for _, v in ipairs(GameVersion.ORDER) do + roots[v] = cacheRoot(v) + if roots[v] then list[#list + 1] = v end + end + if #list == 0 then + print("[skip] no imported caches for the real avatar checks") + else + Avatars.setReader(Avatars.directoryReader(roots)) + for _, v in ipairs(list) do + local gen = GameVersion.generation(v) + local e = Avatars.resolve(who(v, 0)) + T.check(not e.standin, v .. ": the real player sprite resolves from the cache") + if not e.standin then + T.eq(e.gen, gen, v .. ": the sprite comes from the participant's own gen") + T.check(e.frames >= (gen == 3 and 9 or 6), v .. ": the sheet has every walk frame") + if gen == 2 then T.eq(#e.palette.colors, 4, v .. ": the real Gen 2 palette has four colors") end + end + local missing = {} + for g = 0, 1 do + for _, ref in ipairs(Avatars.hostList(v, g)) do + local h = Avatars.hostEntry(v, ref) + if not h or h.frames < (gen == 3 and 9 or 6) then missing[#missing + 1] = tostring(ref) end + end + end + T.eq(table.concat(missing, ","), "", v .. ": every curated stand-in is a walking sprite in the cache") + if v == "crystal" then + local k = Avatars.resolve(who("crystal", 1)) + T.eq(k.source and k.source.rel, "assets/generated/sprites/kris.png", "crystal: the real Kris sheet resolves") + end + if v == "firered" or v == "leafgreen" or v == "emerald" then + for n = 0, 7 do + local c = Avatars.resolve(who(v, n % 2, "g3:" .. n)) + T.check(not c.standin and c.layout == "gba", v .. ": union class " .. n .. " resolves") + end + end + end + end + Avatars.setReader(nil) +end + +T.finish() diff --git a/tests/engine/union_room_prep_test.lua b/tests/engine/union_room_prep_test.lua new file mode 100644 index 00000000..01b2d757 --- /dev/null +++ b/tests/engine/union_room_prep_test.lua @@ -0,0 +1,283 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local Prep = require("src.online.union.Prep") + +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +local function pid(n) return ("%08x"):format(n) end +local D1, D2, D3 = "00000000000000a1", "00000000000000b2", "00000000000000c3" + +local FP = { red = "1111111111111111", yellow = "2222222222222222", gold = "3333333333333333", + emerald = "6666666666666666", firered = "5555555555555555" } + +local function ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" }, + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local function world() + local w = { relay = FakeRelay.new({ clock = function() return CLOCK end }), clients = {}, rooms = {}, seats = {} } + function w:add(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + self.seats[#self.seats + 1] = seat + local r = Room.new({ client = C }) + self.rooms[#self.rooms + 1] = r + return r, C, seat + end + function w:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end + end + return w +end + +local function pair(va, vb, activity) + local w = world() + local ra, ca, sa = w:add(1, "A") + local rb, cb, sb = w:add(2, "B") + w:pump() + ra:join(ctxFor(va, "A", 1)) + rb:join(ctxFor(vb, "B", 2)) + w:pump() + ra:poll(); rb:poll() + ra:invite(pid(2), activity) + w:pump() + rb:reply(rb:incoming()[1].id, true) + w:pump() + local pa, pb = ra:prep(), rb:prep() + return w, pa, pb, { ra = ra, rb = rb, ca = ca, cb = cb, sa = sa, sb = sb } +end + +local function kinds(events) + local out = {} + for _, e in ipairs(events) do out[#out + 1] = e.kind end + return table.concat(out, ",") +end + +local function has(events, kind, pred) + for _, e in ipairs(events) do + if e.kind == kind and (pred == nil or pred(e)) then return e end + end + return nil +end + +do + local w, pa, pb = pair("red", "emerald", "xg_battle") + T.check(pa ~= nil and pb ~= nil, "both seats get a prep") + local ea = pa:poll() + pb:poll() + T.check(has(ea, "rules") ~= nil, "rules arrive right after accept from plaza caps (" .. kinds(ea) .. ")") + T.eq(pa.rules.ruleset, "g3u", "Gen 1 vs Gen 3 resolves g3u") + T.eq(pa.rules.dexMax, 151, "g3u dex is the lower gen's") + T.eq(pa.rev, 1, "the rules bump the rev to 1") + T.eq(pa.state, "prep", "the prep is open") + T.check(not pa:canReady(), "no ready before rosters") + T.check(pa:roster(3, D1), "seat 0 sends a roster") + w:pump() + local eb = pb:poll() + pa:poll() + T.eq(pb.peer.roster.size, 3, "the peer roster is seen") + T.eq(pb.rev, 2, "the roster bumps the rev") + T.eq(pa.rev, 2, "the sender learns the new rev from xg_rev") + T.eq(pa.mine.roster and pa.mine.roster.size, 3, "xg_rev confirms the sender's roster") + T.check(has(eb, "peer_roster") ~= nil, "a peer_roster event fires") + pb:roster(5, D2) + w:pump() + pa:poll(); pb:poll() + T.eq(pa.size, 3, "two rosters give xg_size = the smaller") + T.eq(pa.rev, 3, "the second roster bumps to 3") + T.check(pa:canReady(), "ready is allowed with rules and a size") + pa:ready(D1) + w:pump() + pb:poll() + T.check(pb.peer.ready ~= nil, "the peer's ready is seen at the current rev") + pa:sizeRequest(2) + w:pump() + local ea2 = pa:poll() + pb:poll() + T.eq(pa.mine.ready, nil, "a later change clears my ready") + T.eq(pb.peer.ready, nil, "a later change clears the peer ready") + T.check(pa:ackInvalidated(), "the invalidation is surfaced") + T.check(has(ea2, "rev") ~= nil, "a rev event fires") + + local stale = pb.rev + pa:roster(2, D3) + w:pump(1) + w.relay:pump() + pb.rev = stale + pb.session.send(require("src.online.Protocol2").xgRoster(stale - 1, 2, D2)) + w:pump() + local ebn = pb:poll() + local nack = has(ebn, "nack") + T.check(nack ~= nil and nack.why == "stale_rev" and nack.of == "xg_roster", "a stale roster is nacked stale_rev") + T.eq(pb.rev, w.relay.rooms[w.clients[1].room().room].xg.rev, "the nack refreshes the rev to the relay's") + T.check(pb.invalidated, "a stale nack surfaces invalidated") + pa:poll() + T.eq(pa.rev, pb.rev, "both seats agree on the rev") + + pa:ready(D1) + pb:ready(D2) + w:pump() + local ga, gb = pa:poll(), pb:poll() + local go = has(ga, "go") + T.check(go ~= nil, "both ready on the same rev gives xg_go") + T.eq(pa.state, "go", "seat 0 is in go") + T.eq(pb.state, "go", "seat 1 is in go") + T.eq(go.go.ruleset, "g3u", "xg_go carries the ruleset") + T.eq(go.go.size, 2, "xg_go carries the agreed size") + T.eq(go.go.dexMax, 151, "xg_go carries the dex limit") + T.eq(pa.go.seed, pb.go.seed, "both seats get the same seed") + T.check(has(gb, "go") ~= nil, "seat 1 sees xg_go") +end + +do + local w, pa, pb = pair("red", "gold", "xg_battle") + pa:poll(); pb:poll() + pb:cancel("changed my mind") + w:pump() + local ea = pa:poll() + local closed = has(ea, "closed") + T.check(closed ~= nil, "a cancel closes the peer's prep") + T.eq(closed and closed.why, "cancel", "the close says cancel") + T.eq(closed and closed.seat, 1, "the close names the cancelling seat") + T.eq(pa.state, "closed", "the prep is closed") + T.check(w.clients[1].room() == nil, "the client left the closed room") + T.check(not pa:roster(1, D1), "a closed prep sends nothing") +end + +do + local w, pa, pb, x = pair("red", "gold", "xg_battle") + pa:poll(); pb:poll() + pa:roster(4, D1) + w:pump() + pa:poll(); pb:poll() + pb:roster(6, D2) + w:pump(1) + w.relay:drop(x.sa) + w:pump() + w.relay:reconnect(x.sa) + CLOCK = CLOCK + 2 + w:pump(6) + local ev = pa:poll() + T.check(has(ev, "snapshot") ~= nil, "a resume re-applies the room_state snapshot") + local relayRev = w.relay.rooms[w.clients[1].room().room].xg.rev + T.eq(pa.rev, relayRev, "the resumed prep has the relay's rev") + T.eq(pa.peer.roster and pa.peer.roster.size, 6, "the resumed prep has the peer roster") + T.eq(pa.mine.roster and pa.mine.roster.size, 4, "the resumed prep has its own roster") + T.eq(pa.size, 4, "the resumed prep has the agreed size") + T.eq(pa.state, "prep", "the resumed prep is still open") +end + +do + local w, pa, pb = pair("firered", "red", "xg_trade") + pa:poll(); pb:poll() + T.eq(pa.rules and pa.rules.mode, "trade", "trade rules arrive") + T.check(not pa:roster(1, D1), "rosters are battle only") + pa:offer({ species = 25, level = 12, nickname = "PIKACHU" }, D3) + pb:offer({ species = 1, level = 5 }, D3) + w:pump() + pa:poll() + local raced = pb:poll() + T.check(has(raced, "nack", function(e) return e.why == "stale_rev" and e.of == "xg_offer" end) ~= nil, + "two offers on one rev: the later one is nacked stale_rev") + T.eq(pb.mine.offer, nil, "the nacked offer is not counted") + pb:offer({ species = 1, level = 5 }, D3) + w:pump() + pa:poll(); pb:poll() + T.eq(pb.offerRev, 2, "a re-sent offer takes the next offerRev") + T.eq(pb.peer.offer.payload.species, 25, "the offer payload reaches the peer") + T.eq(pa.offerRev, 1, "offerRev starts at 1") + T.check(pa:canReady(), "both offers allow ready") + pa:ready(D1) + pb:ready(D2) + w:pump() + local ea = pa:poll() + pb:poll() + local nack = has(ea, "nack") + T.check(nack ~= nil and nack.why == "digest", "different trade digests are nacked digest") + T.check(pa.invalidated and pa.mine.ready == nil, "a digest nack clears ready") + pa:ready(D3) + pb:ready(D3) + w:pump() + pa:poll(); pb:poll() + T.eq(pa.state, "go", "agreed digests give xg_go") + T.eq(pa.go.ruleset, "trade", "the go is a trade") + local Protocol2 = require("src.online.Protocol2") + w.clients[1].roomSession():send({ type = "trade_confirm", digest = "ffffffffffffffff" }) + w:pump() + local en = pa:poll() + T.check(has(en, "nack", function(e) return e.why == "digest_unagreed" end) ~= nil, + "a confirm with another digest is nacked digest_unagreed") + w.clients[1].roomSession():send({ type = "trade_confirm", digest = D3 }) + w.clients[2].roomSession():send({ type = "trade_confirm", digest = D3 }) + w:pump() + local commit = w.clients[1].roomSession():take("trade_commit") + T.check(commit ~= nil, "the trade layer still reads trade_commit") + local er = pa:poll() + pb:poll() + T.check(has(er, "trade_round") ~= nil, "the barrier sends the prep back for another round") + T.eq(pa.state, "prep", "the prep reopens after a trade") + T.eq(pa.mine.offer, nil, "offers are cleared after a round") + T.eq(pa.round, 1, "the round counter advances") + pa:offer({ species = 4 }, D1) + T.eq(pa.offerRev, 2, "offerRev keeps rising across rounds") + T.check(Protocol2.isXg({ type = "xg_go" }), "xg_go is an xg message") +end + +do + local sent = {} + local inbox = {} + local snapshot = { mode = "battle", rev = 7, gens = { 1, 2 }, + rules = { mode = "battle", ruleset = "g3u", dexMax = 151 }, + rosters = { { size = 3, digest16 = D1 } }, sizeReq = {}, offers = {}, + ready = { true, false }, caps = { true, true } } + local session = { + send = function(m) sent[#sent + 1] = m return true end, + take = function(pred) + for i, m in ipairs(inbox) do + if pred(m) then return table.remove(inbox, i) end + end + return nil + end, + snapshot = function() return snapshot, { stage = "prep" } end, + seat = function() return 0 end, + open = function() return true end, + } + local p = Prep.new(session, { mode = "battle" }) + T.eq(p.rev, 7, "a prep built from a snapshot starts at its rev") + T.check(p.mine.ready ~= nil, "a snapshot ready flag is kept") + T.eq(p.mine.roster.size, 3, "a snapshot roster is mine by seat") + inbox[#inbox + 1] = { type = "xg_rules", rev = 3, mode = "battle", ruleset = "g3u", dexMax = 251, relay = true } + inbox[#inbox + 1] = { type = "game3_hello" } + p:poll() + T.eq(p.rev, 7, "an older replayed message never lowers the rev") + T.eq(#inbox, 1, "the prep leaves non-xg messages for the battle layer") + inbox[#inbox + 1] = { type = "xg_closed", why = "timeout", relay = true, seat = -1 } + local ev = p:poll() + T.check(has(ev, "closed", function(e) return e.why == "timeout" end) ~= nil, "xg_closed timeout closes") + T.eq(p.closed.seat, nil, "a relay close names no seat") + session.open = function() return false end + local p2 = Prep.new(session, { mode = "battle" }) + local ev2 = p2:poll() + T.check(has(ev2, "closed", function(e) return e.why == "gone" end) ~= nil, "a vanished room closes the prep") + T.eq(#sent, 0, "a passive prep sends nothing") +end + +T.finish() diff --git a/tests/engine/union_room_relay_test.lua b/tests/engine/union_room_relay_test.lua new file mode 100644 index 00000000..400f4353 --- /dev/null +++ b/tests/engine/union_room_relay_test.lua @@ -0,0 +1,278 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") + +local okFfi, ffi = pcall(require, "ffi") +local RELAY_JS = os.getenv("POKESERVER_RELAY") or "../pokeserver/relay.js" + +local function exists(path) + local f = io.open(path, "rb") + if f then f:close() return true end + return false +end + +local function hasNode() + local p = io.popen("command -v node 2>/dev/null") + local out = p and p:read("*a") or "" + if p then p:close() end + return out:match("%S") ~= nil +end + +if not okFfi or not (ffi.os == "OSX" or ffi.os == "Linux") or not exists(RELAY_JS) or not hasNode() then + print("[skip] no local pokeserver checkout, node or ffi for the live relay test") + os.exit(0) +end + +local Json = require("src.link.Json") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") + +ffi.cdef([[ +int socket(int domain, int type, int protocol); +int connect(int s, const void *addr, unsigned int len); +long recv(int s, void *buf, unsigned long len, int flags); +long send(int s, const void *buf, unsigned long len, int flags); +int close(int fd); +int fcntl(int fd, int cmd, ...); +int setsockopt(int s, int level, int name, const void *val, unsigned int len); +int usleep(unsigned int usec); +]]) + +local OSX = ffi.os == "OSX" +local O_NONBLOCK = OSX and 0x4 or 0x800 +local EAGAIN = OSX and 35 or 11 +local SEND_FLAGS = OSX and 0 or 0x4000 + +local function sockaddr(port) + local buf = ffi.new("uint8_t[16]") + if OSX then + buf[0], buf[1] = 16, 2 + else + buf[0], buf[1] = 2, 0 + end + buf[2], buf[3] = math.floor(port / 256), port % 256 + buf[4], buf[5], buf[6], buf[7] = 127, 0, 0, 1 + return buf +end + +local function tcp(port) + local fd = ffi.C.socket(2, 1, 0) + if fd < 0 then return nil, "socket" end + if OSX then + local one = ffi.new("int[1]", 1) + ffi.C.setsockopt(fd, 0xffff, 0x1022, one, 4) + end + if ffi.C.connect(fd, sockaddr(port), 16) ~= 0 then + ffi.C.close(fd) + return nil, "connect" + end + ffi.C.fcntl(fd, 4, ffi.cast("int", O_NONBLOCK)) + local t = { paired = true, closed = false, error = nil, fd = fd, rx = "", inbox = {} } + local chunk = ffi.new("uint8_t[8192]") + function t:rawSend(text) + local at = 0 + while at < #text and not self.closed do + local n = tonumber(ffi.C.send(self.fd, ffi.cast("const char *", text) + at, #text - at, SEND_FLAGS)) + if n > 0 then + at = at + n + elseif ffi.errno() == EAGAIN then + ffi.C.usleep(1000) + else + self.closed = true + end + end + end + function t:update() + while not self.closed do + local n = tonumber(ffi.C.recv(self.fd, chunk, 8192, 0)) + if n > 0 then + self.rx = self.rx .. ffi.string(chunk, n) + elseif n == 0 then + self.closed = true + else + if ffi.errno() ~= EAGAIN then self.closed = true end + break + end + end + while true do + local nl = self.rx:find("\n", 1, true) + if not nl then break end + local line = self.rx:sub(1, nl - 1) + self.rx = self.rx:sub(nl + 1) + local msg = Json.decode(line) + if type(msg) == "table" then + if msg.type == "ping" then + self:rawSend(Json.encode({ type = "pong", t = msg.t }) .. "\n") + elseif msg.type ~= "pong" then + self.inbox[#self.inbox + 1] = msg + end + end + end + end + function t:poll() + local out = self.inbox + self.inbox = {} + return out + end + function t:send(msg) + self:rawSend(Json.encode(msg) .. "\n") + end + function t:close() + if self.fd then ffi.C.close(self.fd) end + self.fd = nil + self.closed = true + end + return t +end + +local launcher = os.tmpname() +local portFile = os.tmpname() +os.remove(portFile) +do + local f = io.open(launcher, "wb") + f:write([[ +const path = require('path'); +const { createRelay } = require(path.resolve(process.argv[2])); +const fs = require('fs'); +const relay = createRelay({ log: () => {}, lobbyEnabled: true }); +relay.server.listen(0, '127.0.0.1', () => { + fs.writeFileSync(process.argv[3], String(relay.server.address().port)); +}); +process.stdin.on('end', () => process.exit(0)); +process.stdin.on('data', () => {}); +process.stdin.resume(); +setTimeout(() => process.exit(0), 60000); +]]) + f:close() +end + +local node = io.popen(("node %q %q %q"):format(launcher, RELAY_JS, portFile), "w") +local port +for _ = 1, 500 do + local f = io.open(portFile, "rb") + if f then + port = tonumber(f:read("*a")) + f:close() + if port then break end + end + ffi.C.usleep(10000) +end + +local function stop() + if node then node:close() end + node = nil + os.remove(launcher) + os.remove(portFile) +end + +if not port then + stop() + T.check(false, "the local relay started") + T.finish() +end + +local clients = {} + +local function newClient(name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "127.0.0.1:" .. port, connect = function() return tcp(port) end }) + C.connect({ name = name, profiles = {} }) + clients[#clients + 1] = C + return C +end + +local function pumpUntil(cond, ms) + for _ = 1, math.floor((ms or 3000) / 5) do + for _, C in ipairs(clients) do C.update(0) end + if cond() then return true end + ffi.C.usleep(5000) + end + return false +end + +local FP = { red = "1111111111111111", emerald = "6666666666666666", gold = "3333333333333333" } + +local function ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" }, + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local ok, err = pcall(function() + local ca, cb, cc = newClient("RED"), newClient("MAY"), newClient("GOLD") + T.check(pumpUntil(function() return ca.state() == "online" and cb.state() == "online" + and cc.state() == "online" end), "three clients reach the live relay") + local ra, rb, rc = Room.new({ client = ca }), Room.new({ client = cb }), Room.new({ client = cc }) + ra:join(ctxFor("red", "RED", 11)) + rb:join(ctxFor("emerald", "MAY", 22)) + rc:join(ctxFor("gold", "GOLD", 33)) + T.check(pumpUntil(function() + ra:poll(); rb:poll(); rc:poll() + return #ra:members() == 2 and #rb:members() == 2 and #rc:members() == 2 + end), "Gen 1, 2 and 3 share one live instance") + T.eq(ra:error(), nil, "the live relay accepts the Gen 1 xgen join") + local seen = {} + for _, p in ipairs(ra:members()) do seen[p.gen] = p end + T.check(seen[2] and seen[3] and not seen[2].legacy and not seen[3].legacy, + "live rows carry relay gens") + T.eq(seen[3] and seen[3].caps and seen[3].caps.gens["3"][1].version, "emerald", "live rows carry caps") + T.eq(ra:self().gen, 1, "my own live row has gen 1") + + local cd = newClient("LEAF") + T.check(pumpUntil(function() return cd.state() == "online" end), "a legacy client connects") + cd.joinPlaza("union", ctxFor("emerald", "LEAF", 44).profile, + { name = "LEAF", trainerId = 44, gender = 1, version = "emerald" }, 40) + pumpUntil(function() return cd.plaza() ~= nil end) + T.eq(#(cd.plaza() and cd.plaza().members or {}), 1, "a legacy Gen 3 join keeps its own shard") + ra:poll() + T.eq(#ra:members(), 2, "the xgen room never sees the legacy member") + + local h = ra:invite(rb:self() and rb:self().id or nil, "xg_battle") + T.check(pumpUntil(function() return #rb:incoming() > 0 end), "the live xg invite arrives") + rb:reply(rb:incoming()[1].id, true) + T.check(pumpUntil(function() return ra:xgRoom() ~= nil and rb:xgRoom() ~= nil end), "the live xg room opens") + T.eq(h.state, "accepted", "the live invite is accepted") + local pa, pb = ra:prep(), rb:prep() + T.check(pumpUntil(function() pa:poll(); pb:poll() return pa.rules ~= nil and pb.rules ~= nil end), + "live rules arrive") + T.eq(pa.rules.ruleset, "g3u", "the live relay resolves g3u") + T.eq(pa.rules.dexMax, 151, "the live g3u dex is Gen 1's") + pa:roster(3, "00000000000000a1") + T.check(pumpUntil(function() pa:poll(); pb:poll() return pb.peer.roster ~= nil and pa.mine.roster ~= nil end), + "a live roster is confirmed and relayed") + pb:roster(2, "00000000000000b2") + T.check(pumpUntil(function() pa:poll(); pb:poll() return pa.size ~= nil and pa.rev == pb.rev end), + "the live size is agreed") + T.eq(pa.size, 2, "the live size is the smaller roster") + pa:ready("00000000000000a1") + pb:ready("00000000000000b2") + T.check(pumpUntil(function() pa:poll(); pb:poll() return pa.state == "go" and pb.state == "go" end), + "both live seats reach xg_go") + T.eq(pa.go and pa.go.size, 2, "the live go carries the size") + T.eq(pa.go and pa.go.seed, pb.go and pb.go.seed, "the live go seed matches") + local ce = newClient("BLUE") + T.check(pumpUntil(function() return ce.state() == "online" end), "a fourth client connects") + local re = Room.new({ client = ce }) + re:join(ctxFor("red", "BLUE", 55)) + pumpUntil(function() re:poll(); rc:poll() return re:self() ~= nil and #rc:members() >= 3 end) + local ht = rc:invite(re:self() and re:self().id or nil, "xg_trade") + T.check(pumpUntil(function() re:poll(); rc:poll() return #re:incoming() > 0 end), "the live trade invite arrives") + re:reply(re:incoming()[1].id, true) + T.check(pumpUntil(function() re:poll(); rc:poll() return ht.state == "accepted" and rc:prep() ~= nil + and re:prep() ~= nil end), "the live trade room opens") + local te, tc = re:prep(), rc:prep() + pumpUntil(function() te:poll(); tc:poll() return te.rules ~= nil and tc.rules ~= nil end) + tc:cancel("refused:personality_unrepresentable") + T.check(pumpUntil(function() te:poll(); tc:poll() return te.state == "closed" end), "a refusal closes the live trade") + T.eq(te.closed and te.closed.why, "cancel", "the peer sees a cancel") + T.eq(te.closed and te.closed.detail, "refused:personality_unrepresentable", "with the whole refusal code") + ca.disconnect(); cb.disconnect(); cc.disconnect(); cd.disconnect(); ce.disconnect() +end) +if not ok then T.check(false, "live relay run: " .. tostring(err)) end +stop() +T.finish() diff --git a/tests/engine/union_room_service_test.lua b/tests/engine/union_room_service_test.lua new file mode 100644 index 00000000..1f9e35a9 --- /dev/null +++ b/tests/engine/union_room_service_test.lua @@ -0,0 +1,336 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Caps = require("src.online.union.Caps") +local Room = require("src.online.union.Room") +local Wire = require("src.link.Wire") +local Protocol2 = require("src.online.Protocol2") + +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +local function pid(n) return ("%08x"):format(n) end + +local FP = { red = "1111111111111111", blue = "1111111111111111", yellow = "2222222222222222", + gold = "3333333333333333", silver = "3333333333333333", crystal = "4444444444444444", + firered = "5555555555555555", leafgreen = "5555555555555555", + emerald = "6666666666666666", ruby = "7777777777777777", sapphire = "7777777777777777" } + +local function profileFor(version) + local gen = Participant.genOf(version) + return { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", + kind = "vanilla" } +end + +local function ctxFor(version, name, tid, gender, style) + return { version = version, name = name, trainerId = tid, gender = gender or 0, + style = style, profile = profileFor(version), + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local World = {} +World.__index = World + +local function newWorld(opts) + return setmetatable({ relay = FakeRelay.new({ clock = function() return CLOCK end, + legacy = opts and opts.legacy }), + clients = {}, rooms = {}, seats = {} }, World) +end + +function World:add(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + self.seats[#self.seats + 1] = seat + local room = Room.new({ client = C }) + self.rooms[#self.rooms + 1] = room + return room, C, seat +end + +function World:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end +end + +local function slots(list) + local out = {} + for _, p in ipairs(list) do out[#out + 1] = p.slot end + return table.concat(out, ",") +end + +local function ids(list) + local out = {} + for _, p in ipairs(list) do out[#out + 1] = p.id end + return table.concat(out, ",") +end + +do + local row = Wire.member({ id = pid(7), name = "ACC", slot = 3, status = "idle", + avatar = { name = "ASH", trainerId = 70000, gender = 1, version = "crystal", style = "player", gen = 2 }, + caps = { proto = 1, policy = 1, gens = { ["2"] = { { version = "crystal", fp = "ABCDEF0123456789" } } } } }) + local p = Participant.fromMember(row) + T.check(p ~= nil, "a Gen 2 xgen row parses") + T.eq(p.gen, 2, "the row's relay gen is kept") + T.eq(p.game, "crystal", "the participant's game is the avatar version") + T.eq(p.gender, 1, "gender comes from the avatar") + T.eq(p.trainerId, 65535, "trainer id clamps to 16 bits on the wire") + T.eq(p.legacy, false, "a row with avatar.gen is not legacy") + T.eq(p.caps.gens["2"][1].fp, "abcdef0123456789", "caps fingerprints are lowercased") + T.eq(Participant.badgeDigit(p), 2, "the badge digit is the source gen") + + local legacy = Participant.fromMember(Wire.member({ id = pid(8), slot = 4, + avatar = { name = "LEAF", trainerId = 5, gender = 1, version = "leafgreen" } })) + T.eq(legacy.legacy, true, "a row without avatar.gen is legacy") + T.eq(legacy.gen, 3, "a legacy row's gen comes from the version family") + T.eq(legacy.style, "player", "a legacy row defaults to the player style") + + T.eq(Participant.fromMember(Wire.member({ id = pid(9), slot = 5, + avatar = { name = "X", version = "emerald", gen = 1 } })), nil, + "a row whose gen disagrees with its version family is refused") + T.eq(Participant.fromMember(Wire.member({ id = pid(10), slot = 6, avatar = { name = "X" } })), nil, + "a row without a version is refused") + T.eq(Participant.fromMember(Wire.member({ id = pid(11), avatar = { version = "red", gen = 1 } })), nil, + "a row without a slot is refused") + T.eq(Participant.fromMember(Wire.member({ id = pid(12), slot = 1, avatar = { version = "zelda", gen = 1 } })), nil, + "a row with an unknown version is refused") + T.eq(Wire.avatar({ name = "A", version = "red", style = "BAD STYLE" }).style, nil, + "a malformed style token is dropped on the wire") + local av = Participant.wireAvatar({ name = "ABCDEFGHIJKLMN", trainerId = 70001, gender = "female", + version = "red" }) + T.eq(av.name, "ABCDEFGHIJ", "the wire avatar name is cut to 10 characters") + T.eq(av.trainerId, 70001 % 65536, "the wire avatar trainer id is 16 bits") + T.eq(av.style, "player", "the wire avatar style defaults to player") + + local caps = Caps.compute({ version = "yellow", vanillaFingerprint = FP.yellow, gameplayMods = false }) + T.eq(caps.gens["1"][1].version, "yellow", "caps list only the active game's own version") + T.eq(caps.gens["2"], nil, "caps never list another gen") + T.check(Caps.empty(Caps.compute({ version = "yellow", vanillaFingerprint = FP.yellow, + gameplayMods = true })), + "gameplay mods omit the gen entry") +end + +do + local hello = Protocol2.lobbyHello({ name = "X", xgen = 1 }) + T.eq(hello.xgen, 1, "lobby_hello carries xgen") + local join = Protocol2.plazaJoin("union", profileFor("red"), { name = "A", version = "red", style = "player" }, + 40, { xgen = 1, caps = Caps.compute({ version = "red", vanillaFingerprint = FP.red, gameplayMods = false }) }) + T.eq(join.xgen, 1, "plaza_join carries xgen") + T.eq(join.avatar.style, "player", "plaza_join keeps the avatar style") + T.eq(join.caps.gens["1"][1].fp, FP.red, "plaza_join carries caps") + local legacyJoin = Protocol2.plazaJoin("union", profileFor("firered"), { name = "A", version = "firered" }, 40) + T.eq(legacyJoin.xgen, nil, "a plain plaza_join stays without xgen") + T.eq(legacyJoin.caps, nil, "a plain plaza_join stays without caps") + T.check(Protocol2.CLIENT_TYPES.set_caps, "set_caps is a client type") + local nack = Wire.sanitize({ type = "room_msg", seq = nil, seat = -1, relay = true, + msg = { type = "xg_nack", of = "xg_roster", why = "stale_rev", rev = 1, current = 3 } }) + T.eq(nack.msg.of, "xg_roster", "xg_nack keeps of") + T.eq(nack.msg.current, 3, "xg_nack keeps current") + local st = Wire.sanitize({ type = "room_state", room = "r0000000000003001", intent = "xg", mode = "battle", + stage = "prep", players = { { id = pid(1), seat = 0, gen = 1, avatar = { name = "A", version = "red", gen = 1 } } }, + xg = { mode = "battle", rev = 4, gens = { 1, 3 }, rules = { mode = "battle", ruleset = "g3u", dexMax = 151 }, + caps = { true, false }, rosters = { { size = 3, digest16 = "00112233445566ff" } }, + sizeReq = {}, offers = {}, ready = { false, true } } }) + T.eq(st.mode, "battle", "room_state keeps the xg mode") + T.eq(st.xg.rev, 4, "room_state keeps the xg snapshot rev") + T.eq(st.xg.rules.dexMax, 151, "the snapshot keeps the rules") + T.eq(st.xg.rosters[1].size, 3, "the snapshot keeps seat rosters") + T.eq(st.xg.ready[2], true, "the snapshot keeps ready flags") + T.eq(st.players[1].gen, 1, "room_state player rows keep gen") + T.eq(st.players[1].avatar.version, "red", "room_state player rows keep the avatar") +end + +do + local w = newWorld() + local ra, ca = w:add(1, "RED") + local rb = w:add(2, "GOLD") + local rc = w:add(3, "MAY") + w:pump() + T.check(ra:join(ctxFor("red", "RED", 100, 0)), "a Gen 1 game joins") + T.check(rb:join(ctxFor("gold", "GOLD", 200, 0)), "a Gen 2 game joins") + T.check(rc:join(ctxFor("emerald", "MAY", 300, 1, "g3:2")), "a Gen 3 game joins") + w:pump() + local sent = w.relay:sent(w.seats[1], "plaza_join")[1] + T.eq(sent.xgen, 1, "the room join sends xgen") + T.eq(sent.profile.rulesetId, "union", "a Gen 1 profile carries rulesetId union") + T.eq(sent.caps.gens["1"][1].version, "red", "the join sends the active game's caps") + local hello = w.relay:sent(w.seats[1], "lobby_hello")[1] + T.eq(hello.xgen, 1, "the client hello advertises xgen") + local da, dc = ra:poll(), rc:poll() + T.eq(#da.joined, 2, "Gen 1 sees two others join") + T.eq(slots(da.joined), "2,3", "joined rows come by slot") + T.eq(da.joined[1].gen, 2, "Gen 1 sees the Gen 2 member's gen") + T.eq(da.joined[2].gen, 3, "Gen 1 sees the Gen 3 member's gen") + T.eq(da.joined[2].style, "g3:2", "the Gen 3 style token rides the row") + T.eq(dc.joined[1].gen, 1, "Gen 3 sees the Gen 1 member's gen") + T.eq(ra:self().slot, 1, "Gen 1 holds slot 1") + T.eq(ra:count(), 3, "the room counts three trainers") + local again = ra:poll() + T.eq(#again.joined + #again.left + #again.changed, 0, "an unchanged plaza produces no diff") + local builds = ra.builds + for _ = 1, 30 do ra:poll() end + T.eq(ra.builds, builds, "polling an unchanged plaza never rebuilds") + + rc:leave() + w:pump() + local d1 = ra:poll() + T.eq(#d1.left, 1, "a leaver shows in left") + T.eq(d1.left[1].slot, 3, "the leaver's slot is reported") + local rd = w:add(4, "SILV") + w:pump() + rd:join(ctxFor("silver", "SILV", 400, 0)) + w:pump() + local d2 = ra:poll() + T.eq(#d2.joined, 1, "a newcomer shows in joined") + T.eq(d2.joined[1].slot, 3, "the newcomer takes the lowest free slot") + T.eq(d2.joined[1].id, pid(4), "the slot now holds the newcomer") + + rb:setStatus("busy") + w:pump() + local d3 = ra:poll() + T.eq(#d3.changed, 1, "a status change is a changed row") + T.eq(d3.changed[1].status, "busy", "the changed row carries the new status") + T.check(ra:busy(ra:member(pid(2))), "a busy member reads busy") + rb:setStatus("idle") + w:pump() + ra:poll() + + w.relay:drop(w.seats[1]) + w:pump() + w.relay:reconnect(w.seats[1]) + CLOCK = CLOCK + 2 + w:pump(6) + T.eq(ca.state(), "online", "the client resumes") + local d4 = ra:poll() + T.eq(#d4.joined + #d4.left + #d4.changed, 0, "a resume keeps every slot stable") + T.eq(ids(ra:members()), pid(2) .. "," .. pid(4), "members are unchanged after the resume") +end + +do + local w = newWorld() + local rooms = {} + for i = 1, 41 do + local r = w:add(100 + i, "T" .. i) + rooms[i] = r + end + w:pump() + for i = 1, 41 do + local v = ({ "red", "gold", "firered" })[(i % 3) + 1] + rooms[i]:join(ctxFor(v, "T" .. i, i, 0)) + w:pump(1) + end + w:pump() + rooms[1]:poll() + rooms[41]:poll() + T.eq(rooms[1]:count(), 40, "an instance holds 40 trainers") + T.eq(#rooms[1]:members(), 39, "a full instance shows 39 others") + T.eq(rooms[41]:count(), 1, "the 41st trainer opens a new instance") + local maxSlot = 0 + for _, p in ipairs(rooms[1]:members()) do maxSlot = math.max(maxSlot, p.slot) end + T.eq(maxSlot, 40, "slots run 1..40") + local sent = w.relay:sent(w.seats[1], "plaza_join")[1] + T.eq(sent.cap, 40, "the join asks for cap 40") +end + +do + local w = newWorld() + local ra, ca = w:add(1, "RED") + local rb, cb = w:add(2, "MAY") + local rc, cc = w:add(3, "GOLD") + w:pump() + ra:join(ctxFor("red", "RED", 1, 0)) + rb:join(ctxFor("emerald", "MAY", 2, 1)) + rc:join(ctxFor("gold", "GOLD", 3, 0)) + w:pump() + ra:poll(); rb:poll(); rc:poll() + local h = ra:invite(rb:self(), "xg_battle") + T.check(h ~= nil, "an xg_battle invite goes out") + w:pump() + local inc = rb:incoming() + T.eq(#inc, 1, "the invitee sees one xg invite") + T.eq(inc[1].mode, "battle", "the invite reads as a battle") + T.eq(inc[1].from.gen, 1, "the invite names a Gen 1 sender") + T.eq(rb:incoming()[1].from.id, pid(1), "the invite resolves the sender's participant") + rb:reply(inc[1].id, true) + w:pump() + T.eq(h.state, "accepted", "the sender sees the invite accepted") + local room = ra:xgRoom() + T.check(room ~= nil, "the sender sits in an xg room") + T.eq(room.stage, "prep", "the xg room starts in prep") + T.eq(room.xg.gens[1], 1, "the snapshot carries the seat gens") + T.eq(room.players[2].gen, 3, "player rows carry gens") + T.eq(cb.room().mode, "battle", "the invitee's room carries the mode") + ra:poll(); rc:poll() + T.eq(rc:member(pid(1)).status, "battling", "plaza rows show the pair battling") + local hc = rc:invite(pid(1), "xg_trade") + w:pump() + T.eq(hc.state, "closed", "an invite to a battling trainer closes") + T.eq(hc.why, "busy", "an invite to a battling trainer is busy") + T.check(cc.room() == nil, "the refused sender has no room") + T.check(ca.room() ~= nil and cb.room() ~= nil, "the pair keeps its room") +end + +do + local w = newWorld({ legacy = true }) + local ra = w:add(1, "RED") + local rb, cb = w:add(2, "LEAF") + w:pump() + ra:join(ctxFor("red", "RED", 1, 0)) + rb:join(ctxFor("leafgreen", "LEAF", 2, 1)) + w:pump() + local da = ra:poll() + T.eq(da.error and da.error.error, "server_outdated", "a Gen 1 join on an old relay is server_outdated") + T.eq(ra:poll().error, nil, "the error is reported once") + T.eq(ra:error().error, "server_outdated", "the error stays readable") + local db = rb:poll() + T.eq(db.error and db.error.error, "server_outdated", "a Gen 3 join landing in a legacy shard is server_outdated") + w:pump() + T.eq(#w.relay:sent(w.seats[2], "plaza_leave"), 1, "the client leaves the legacy shard") + T.check(cb.plaza() == nil, "the legacy plaza is dropped") +end + +do + local w = newWorld() + local ra, ca = w:add(1, "RED") + w:pump() + local saved = FakeRelay.PLAZA_CAP + FakeRelay.PLAZA_CAP = 60 + ra:join(ctxFor("red", "RED", 1, 0)) + w:pump() + FakeRelay.PLAZA_CAP = saved + local d = ra:poll() + T.eq(d.error and d.error.error, "client_outdated", "plaza_cap upgrade_required is client_outdated") + T.check(ca.upgradeRequired() ~= nil, "the client latched the upgrade") +end + +do + local w = newWorld() + local ra = w:add(1, "RED") + local rb = w:add(2, "MAY") + w:pump() + local bad = ctxFor("red", "RED", 1, 0) + bad.profile.rulesetId = "gen1_faithful" + ra:join(bad) + w:pump() + local d = ra:poll() + T.eq(d.error and d.error.error, "server_outdated", "a refused Gen 1 profile reads as server_outdated") + local badAv = ctxFor("emerald", "MAY", 2, 1) + badAv.version = "emerald" + badAv.profile.version = "emerald" + rb:join(badAv) + rb.avatar.version = "red" + w.clients[2].joinPlaza("union", badAv.profile, rb.avatar, 40, { xgen = 1, caps = rb.caps }) + w:pump() + local db = rb:poll() + T.eq(db.error and db.error.error, "bad_avatar", "an avatar outside the profile family is bad_avatar") +end + +T.finish() diff --git a/tests/engine/union_setting_test.lua b/tests/engine/union_setting_test.lua new file mode 100644 index 00000000..c979a8a3 --- /dev/null +++ b/tests/engine/union_setting_test.lua @@ -0,0 +1,88 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local Setting = require("src.online.union.Setting") + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +eq(Setting.enabledIn(nil), true, "no options file at all: ON") +eq(Setting.enabledIn({}), true, "an install with no saved preference: ON") +eq(Setting.enabledIn({ unionRoom = true }), true, "explicit ON") +eq(Setting.enabledIn({ unionRoom = false }), false, "explicit OFF") + +local fresh = memfs() +eq(Setting.enabled(fresh), true, "fresh install reads ON") + +local old = memfs() +old.files["options.lua"] = [[return { textSpeed = 3, reduceMotion = true }]] +eq(Setting.enabled(old), true, "existing install without the key reads ON") + +local off = memfs() +SaveData.saveOptions(SaveData.defaultOptions(), off) +local opts = SaveData.loadOptions(off) +opts[Setting.KEY] = false +check(SaveData.saveOptions(opts, off), "OFF writes") +local restarted = memfs() +restarted.files = off.files +for k, v in pairs(off) do if k ~= "files" then restarted[k] = v end end +eq(Setting.enabled(restarted), false, "explicit OFF survives a restart") +local again = SaveData.loadOptions(off) +again.textSpeed = 1 +SaveData.saveOptions(again, off) +eq(Setting.enabled(off), false, "an unrelated options write keeps OFF") + +for _, gen in ipairs({ 1, 2 }) do + eq(Setting.patchesOn(gen, { unionRoom = false }), false, "gen " .. gen .. " restores vanilla when OFF") + eq(Setting.patchesOn(gen, {}), true, "gen " .. gen .. " patches by default") +end +eq(Setting.patchesOn(3, { unionRoom = false }), true, "gen 3 Union Rooms ignore OFF") +eq(Setting.appliesTo(3), false, "the setting never applies to gen 3") + +local okL, LauncherSettings = pcall(require, "src.import.LauncherSettings") +if okL then + local lfs = memfs() + local realLoad, realSave = SaveData.loadOptions, SaveData.saveOptions + SaveData.loadOptions = function(f) return realLoad(f or lfs) end + SaveData.saveOptions = function(o, f) return realSave(o, f or lfs) end + local okO, model = pcall(LauncherSettings.open, {}, "red") + if okO and type(model) == "table" and type(model.sections) == "table" then + local row + for _, section in ipairs(model.sections) do + for _, r in ipairs(section.rows or {}) do + if tostring(r.label) == "Union Room" then row = r end + end + end + check(row ~= nil, "launcher options carry a Union Room row") + if row then + eq(tostring(row.value()), "ON", "row shows ON by default") + check(type(row.note) == "string" and row.note:find("Gen 3", 1, true) ~= nil, + "row explains Gen 3 stays available") + row.step(1) + eq(tostring(row.value()), "OFF", "row toggles OFF") + eq(model.opts[Setting.KEY], false, "toggle stores false") + row.step(1) + eq(model.opts[Setting.KEY], true, "toggle back stores true") + end + else + print("[skip] LauncherSettings.open unavailable headless: " .. tostring(model)) + end + SaveData.loadOptions, SaveData.saveOptions = realLoad, realSave +end + +T.finish("union_setting") diff --git a/tests/engine/union_trade_combos_cache.lua b/tests/engine/union_trade_combos_cache.lua new file mode 100644 index 00000000..be81cea7 --- /dev/null +++ b/tests/engine/union_trade_combos_cache.lua @@ -0,0 +1,163 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Model = require("src.online.union.TradePrepModel") +local Txn = require("src.online.union.TradeTxn") +local TradeConvert = require("src.online.xgen.TradeConvert") +local Project = require("src.online.xgen.Project") +local Protocol = require("src.link.Protocol") +local GameVersion = require("src.core.GameVersion") +local SaveData = require("src.core.SaveData") +local Save2 = require("src.core.gen2.Save") + +local PICK = { [1] = { "red", "blue", "yellow" }, [2] = { "gold", "silver", "crystal" }, [3] = { "firered", "leafgreen" } } + +local real = {} +local chosen = {} +for gen, list in pairs(PICK) do + for _, v in ipairs(list) do + if not chosen[gen] then + local d = F.real(v) + if d then real[v], chosen[gen] = d, v end + end + end +end + +local gen3root +if chosen[3] then + local base = F.cacheRoot(chosen[3]) + gen3root = base and (base .. "/" .. GameVersion.cachePrefix(chosen[3]) .. "data/generated/gba") + local Dataset = require("src.core.game3.dataset") + Dataset.cacheRootOverride = gen3root + Dataset.mountExtractRoots() + require("src.core.game3.pokemon").install(nil) + if not require("src.core.game3.pokemon")._names then chosen[3] = nil end +end + +if not (chosen[1] or chosen[2] or chosen[3]) then + print("[skip] union_trade_combos: no imported caches") + os.exit(0) +end + +Model.datasetSource = function(v) return real[v] end + +local function gameData(d) + return { pokemon = d.raw.pokemon, moves = d.raw.moves, items = d.raw.items or {} } +end + +local function sample(data, otId, nick) + local sp = data.species[25] + local moves = {} + for _, row in ipairs(sp.levelMoves) do + if row.level <= 20 and #moves < 2 and row.move <= 165 then + local dup = false + for _, m in ipairs(moves) do if m == row.move then dup = true end end + if not dup then moves[#moves + 1] = row.move end + end + end + if data.generation == 3 then + local list = {} + for _, m in ipairs(moves) do list[#list + 1] = { id = m, pp = data.moves[m].pp, ppUps = 0 } end + local Pokemon = require("src.core.game3.pokemon") + local pid = 0x00C0FFEE + return { species = sp.localKey, level = 20, exp = sp.exp[20], personality = pid, otId = otId, otSecretId = 654, + otName = "ASH", nickname = nick, ivs = { hp = 10, atk = 11, def = 12, spe = 13, spa = 14, spd = 15 }, + evs = { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 }, moves = list, friendship = 90, item = 0, + nature = pid % 25, gender = Pokemon.gender(sp.localKey, pid), ability = Pokemon.abilityId(sp.localKey, pid), + abilityNum = 0, metLocation = 1, metLevel = 3, metGame = 4, pokeball = 4, otGender = 0, language = 2, ribbons = 0, + markings = 0, contest = { cool = 0, beauty = 0, cute = 0, smart = 0, tough = 0, sheen = 0 }, isEgg = false, + hp = 40, status = "", pokerus = 0, eggCycles = 0, fatefulEncounter = false, modernFatefulEncounter = false } + end + local list = {} + for _, m in ipairs(moves) do list[#list + 1] = { id = data.moves[m].localKey, pp = data.moves[m].pp, ppUps = 0 } end + local rec = { species = sp.localKey, level = 20, nickname = nick, ot = "ASH", otId = otId, + dvs = { attack = 9, defense = 8, speed = 7, special = 6 }, statExp = { hp = 1, attack = 4, defense = 9, speed = 16, special = 25 }, + moves = list } + if data.generation == 2 then rec.experience = sp.exp[20]; rec.happiness = 80 else rec.exp = sp.exp[20] end + return rec +end + +local function nativeOf(gen, d, rec) + if gen == 3 then return assert(Protocol.unpackMon3(nil, rec, { strict = true })) end + if gen == 2 then return assert(Protocol.unpackMon2(gameData(d), rec, { strict = true })) end + local m = assert(Protocol.unpackMon(gameData(d), rec, { strict = true })) + m.catchRate = rec.catchRate + return m +end + +local function destGame(gen, version, d, existing) + if gen == 3 then + return { session = { version = version, party = { existing }, dex = { seen = {}, owned = {}, caught = {} }, flags = {}, vars = {} } } + end + if gen == 2 then + local was = GameVersion.get() + GameVersion.set(version) + local save = Save2.newGame({ playerName = "GOLD" }) + GameVersion.set(was) + save.version = version + save.party = { existing } + return { save = save, data = gameData(d) } + end + return { save = { version = version, party = { existing }, pokedex = { seen = {}, owned = {} } }, data = gameData(d) } +end + +local combos = 0 +for gs = 1, 3 do + for gd = 1, 3 do + local sv, dv = chosen[gs], chosen[gd] + if sv and dv then + local src, dst = real[sv], real[dv] + local label = sv .. " -> " .. dv + local offered = sample(src, 321, "PIKA") + local existing = nativeOf(gd, dst, sample(dst, 999, "KEEP")) + local game = destGame(gd, dv, dst, existing) + local adapter = Txn.newAdapter(game, dv) + adapter.writer = function() return true end + local sender = Model.new({ version = sv, peerVersion = dv, owned = { { ref = { where = "party", index = 1 }, rec = offered } } }) + local rep = sender:choose(1) + T.check(rep.ok, label .. ": offer converts (" .. tostring(rep.blocks[1] and rep.blocks[1].code) .. ")") + if rep.ok then + local payload, digest = sender:payload() + local receiver = Model.new({ version = dv, peerVersion = sv, owned = adapter:owned() }) + local ok, why = receiver:receive(payload, function(final) return adapter:validate(final) end) + T.check(ok, label .. ": receiver re-converts and validates in its own game (" .. tostring(why) .. ")") + if ok then + local packedExisting = adapter:pack(existing) + local entry = { key = "room:1:" .. digest, out = { ref = { where = "party", index = 1 }, + canonical = TradeConvert.canonical(packedExisting), identity = Txn.identityOf(gd, packedExisting) }, + incoming = receiver.peer.final, incomingIdentity = Txn.identityOf(gd, receiver.peer.final) } + local status = Txn.applyEntry(adapter, entry) + T.eq(status, "applied", label .. ": applies into the destination save structure") + local s = adapter:save() + local got = s.party[1] + local repacked = adapter:pack(got) + local view = Project.read(repacked, dst) + T.check(view ~= nil and view.national == 25, label .. ": the result reads back as Pikachu in " .. dv) + local again = gd == 3 and Protocol.unpackMon3(nil, repacked, { strict = true }) + or gd == 2 and Protocol.unpackMon2(gameData(dst), repacked, { strict = true }) + or Protocol.unpackMon(gameData(dst), repacked, { strict = true }) + T.check(again ~= nil, label .. ": the stored mon decodes strictly as a valid " .. dv .. " mon") + if gd == 1 then + local report = SaveData.validate(s, gameData(dst)) + T.eq(#report.lostMons, 0, label .. ": Gen 1 save validation keeps it") + T.check(s.pokedex.owned[got.species], label .. ": dex owned") + elseif gd == 2 then + local report = Save2.validate(s) + T.check(Save2.emptyReport(report), label .. ": Gen 2 save validation is clean") + T.check(s.pokedex.caught[got.species], label .. ": dex caught") + else + T.eq(got.friendship, 70, label .. ": Gen 3 traded friendship") + T.check(s.dex.seen[got.species], label .. ": dex seen") + end + T.eq(Txn.applyEntry(adapter, entry), "already", label .. ": a second apply is refused") + combos = combos + 1 + end + end + end + end +end + +T.check(combos > 0, "at least one combination ran") +print(("[info] union_trade_combos: %d combinations"):format(combos)) +T.finish("union_trade_combos") diff --git a/tests/engine/union_trade_flow_test.lua b/tests/engine/union_trade_flow_test.lua new file mode 100644 index 00000000..c71fffab --- /dev/null +++ b/tests/engine/union_trade_flow_test.lua @@ -0,0 +1,135 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local U = require("tests.engine._union_trade_fixture") +local Model = require("src.online.union.TradePrepModel") +local Txn = require("src.online.union.TradeTxn") +local Open = require("src.ui.union.prep.OpenTrade") + +love.timer.getTime = function() return U.CLOCK.t end +local fixtures = { red = F.data("red"), gold = F.data("gold"), emerald = F.data("emerald") } +Model.datasetSource = function(version) return fixtures[version] end + +local function text(pg) + return table.concat(pg.lines or {}, " | ") +end + +local function pick(ctl, id) + local pg = ctl:page() + for i, it in ipairs(pg.items) do + if it.id == id then + ctl.cursor = i + ctl:input("a") + return true + end + end + return false +end + +local function run(w, a, b, n) + for _ = 1, n or 3 do + w:pump() + a:poll(0) + b:poll(0) + end +end + +Txn.reset() +local w, ra, rb = U.pair("red", "gold") +local ga, gb = U.g1game(), U.g2game() +local a = Open.controller(ga, ra, ra:prep(), { version = "red" }) +local b = Open.controller(gb, rb, rb:prep(), { version = "gold" }) +run(w, a, b) +T.eq(a.opponent.version, "gold", "the controller reads the peer's game from the room") +local pg = a:page() +T.eq(pg.step, "pick", "the trade opens on the pick page") +T.check(text(pg):find("Pokémon Gold", 1, true) ~= nil, "the pick page names the other player's game") +T.check(#pg.items == 3, "both party mons and STOP are listed") +T.check(pick(a, "mon"), "seat 0 picks its first mon") +pg = a:page() +T.eq(pg.step, "offer", "the offer page previews the conversion") +T.check(text(pg):find("In Pokémon Gold it becomes", 1, true) ~= nil, "the destination form is stated") +T.check(text(pg):find("Permanent changes", 1, true) ~= nil, "permanent changes are listed before offering") +pick(a, "offer") +T.eq(a.step, "wait", "offered, waiting for the peer") +run(w, a, b) +pick(b, "mon") +pick(b, "offer") +run(w, a, b, 5) +T.eq(a.step, "confirm", "both offers bring seat 0 to the final check") +T.eq(b.step, "confirm", "and seat 1") +local conf = text(a:page()) +T.check(conf:find("You send PIKACHU", 1, true) ~= nil, "the final page shows what is sent") +T.check(conf:find("You get BULBASAUR", 1, true) ~= nil, "and what is received") +T.check(conf:find("feature of this app", 1, true) ~= nil, "the page says this is an app feature, not cart behaviour") +T.check(conf:find("gone from your game for good", 1, true) ~= nil, "the page says the trade is final") +T.check(not text(b:page()):find("Some data", 1, true), "every permanent change is named") +local before = ga.save.party[1].species +pick(a, "trade") +run(w, a, b) +T.eq(a.step, "ready", "seat 0 waits for agreement") +T.eq(ga.save.party[1].species, before, "agreeing alone changes nothing") +pick(b, "trade") +run(w, a, b, 6) +T.eq(a.step, "done", "seat 0 finishes the trade (" .. tostring(a.closedText) .. ")") +T.eq(b.step, "done", "seat 1 finishes the trade (" .. tostring(b.closedText) .. ")") +T.eq(ga.save.party[1].species, "BULBASAUR", "Red holds the Bulbasaur") +T.eq(gb.save.party[1].species, "PIKACHU", "Gold holds the Pikachu") +T.check(text(a:page()):find("B sent BULBASAUR", 1, true) ~= nil, "the result page names the received mon") +pick(a, "again") +T.eq(a.step, "pick", "another round starts from the pick page") +T.eq(#a.model.owned, 2, "the owned list is rebuilt after the trade") +pick(a, "stop") +run(w, a, b) +T.check(a.done, "STOP ends seat 0's screen") +T.eq(b.step, "closed", "the peer is told trading stopped") + +do + Txn.reset() + local w2, rc, rd = U.pair("red", "gold") + local gc, gd = U.g1game(), U.g2game() + local c = Open.controller(gc, rc, rc:prep(), { version = "red" }) + local d = Open.controller(gd, rd, rd:prep(), { version = "gold" }) + run(w2, c, d) + pick(c, "mon"); pick(c, "offer") + run(w2, c, d) + pick(d, "mon"); pick(d, "offer") + run(w2, c, d, 5) + pick(c, "trade") + run(w2, c, d) + pick(d, "change") + pick(d, "mon") + d.model:stage(2, false) + pick(d, "offer") + run(w2, c, d, 6) + T.eq(c.step, "confirm", "a changed peer offer sends the agreed player back to the final check") + T.check(text(c:page()):find("An offer changed", 1, true) ~= nil, "and says why") + T.eq(gc.save.party[1].species, "PIKACHU", "nothing was traded") +end + +do + Txn.reset() + local w3, re, rf = U.pair("red", "gold") + local ge, gf = U.g1game(), U.g2game() + local e = Open.controller(ge, re, re:prep(), { version = "red" }) + local f = Open.controller(gf, rf, rf:prep(), { version = "gold" }) + run(w3, e, f) + pick(e, "mon"); pick(e, "offer") + run(w3, e, f) + pick(f, "mon") + f.model.peerVersion = "blue" + pick(f, "offer") + run(w3, e, f, 6) + T.eq(e.step, "closed", "a refused offer closes the refusing side") + local mine = text(e:page()) + T.check(mine:find("B's offer can't be accepted here", 1, true) and mine:find("meant for another game", 1, true), + "the refusing side says why: " .. mine) + T.eq(f.step, "closed", "the sender is told") + local theirs = text(f:page()) + T.check(theirs:find("A can't accept your offer", 1, true) and theirs:find("meant for another game", 1, true), + "the sender sees the reason: " .. theirs) + T.eq(ge.save.party[1].species, "PIKACHU", "nothing was traded") +end + +T.finish("union_trade_flow") diff --git a/tests/engine/union_trade_model_test.lua b/tests/engine/union_trade_model_test.lua new file mode 100644 index 00000000..fd29d69d --- /dev/null +++ b/tests/engine/union_trade_model_test.lua @@ -0,0 +1,148 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Model = require("src.online.union.TradePrepModel") +local Json = require("src.link.Json") +local Wire = require("src.link.Wire") + +local fixtures = { red = F.data("red"), gold = F.data("gold"), silver = F.data("silver"), emerald = F.data("emerald") } +Model.datasetSource = function(version) return fixtures[version] end + +local function pika1() + return { species = "PIKACHU", level = 25, exp = 15625, hp = 50, nickname = "ZAPPY", ot = "RED", otId = 4242, + dvs = { attack = 10, defense = 10, speed = 10, special = 10 }, + statExp = { hp = 100, attack = 400, defense = 0, speed = 900, special = 2500 }, + moves = { { id = "THUNDERSHOCK", pp = 30, ppUps = 0 }, { id = "GROWL", pp = 40 } }, catchRate = 190 } +end + +local function umbreon2(item) + return { species = "UMBREON", level = 40, experience = 64000, nickname = "MOON", ot = "GOLD", otId = 9, + dvs = { attack = 15, defense = 10, speed = 3, special = 7 }, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + moves = { { id = "TACKLE", pp = 35 }, { id = "CRUNCH", pp = 15 } }, item = item, happiness = 200, pokerus = 0 } +end + +local function g3pika() + return { species = 25, level = 30, exp = 27000, personality = 0xABCD1234, otId = 77, otSecretId = 88, otName = "May", + nickname = "Volt", ivs = { hp = 31, atk = 20, def = 21, spe = 30, spa = 19, spd = 5 }, + evs = { hp = 4, atk = 0, def = 0, spe = 252, spa = 252, spd = 0 }, moves = { { id = 84, pp = 30 }, { id = 98, pp = 30 }, { id = 345, pp = 20 } }, + item = 0, friendship = 120, pokerus = 0, metLocation = 16, metLevel = 5, metGame = 3, pokeball = 4, otGender = 1, + language = 2, markings = 0, ribbons = 0, contest = { cool = 0, beauty = 0, cute = 0, smart = 0, tough = 0, sheen = 0 }, + abilityNum = 0, isEgg = false } +end + +local function owned(list) + local out = {} + for i, rec in ipairs(list) do out[i] = { ref = { where = "party", index = i }, rec = rec } end + return out +end + +local function wireTrip(payload, digest16) + local text = Json.encode({ type = "xg_offer", rev = 3, offerRev = 1, payload = payload, digest16 = digest16 }) + local clean = Wire.sanitize(Json.decode(text)) + return clean and clean.payload +end + +do + local a = Model.new({ version = "red", peerVersion = "emerald", owned = owned({ pika1() }) }) + local b = Model.new({ version = "emerald", peerVersion = "red", owned = owned({ g3pika() }) }) + local ra = a:choose(1) + T.check(ra and ra.ok, "Gen 1 Pikachu is offerable to Emerald") + T.check(ra.final and ra.final.friendship == 70, "the Gen 3 receiver's traded friendship rule is in the preview") + local rb = b:choose(1) + T.check(rb and not rb.ok and rb.blocks[1].code == "move_missing", "Magical Leaf blocks the Emerald offer to Red") + T.check(b:moveOptions(3) ~= nil and #b:moveOptions(3) > 0, "legal replacements are offered for the blocked slot") + T.check(b:payload() == nil, "a blocked offer has no payload") + local bad = b:stage(3, 57) + T.eq(bad.blocks[1] and bad.blocks[1].code, "replacement_not_legal", "staging a move the species cannot learn is refused (no free move editor)") + local fixed = b:stage(3, 0) + T.check(fixed.ok, "dropping the move makes the offer valid") + local pa, da = a:payload() + local pb, db = b:payload() + T.check(pa ~= nil and pb ~= nil, "both payloads build") + T.check(type(da) == "string" and #da == 16 and type(db) == "string", "offer digests are 16 hex") + local okB, whyB = b:receive(wireTrip(pa, da)) + T.check(okB, "Emerald independently re-converts the Red offer and accepts it (" .. tostring(whyB) .. ")") + local okA, whyA = a:receive(wireTrip(pb, db)) + T.check(okA, "Red independently re-converts the Emerald offer with the staged move (" .. tostring(whyA) .. ")") + local digA = a:agreed(0, 1, 2) + local digB = b:agreed(1, 2, 1) + T.check(digA ~= nil and digA == digB, "both seats compute the same agreed digest") + T.check(a:agreed(0, 1, 3) ~= digA, "a new peer offer revision changes the agreed digest") + T.check(a:agreed(0, 2, 2) ~= digA, "a new own offer revision changes the agreed digest") + local s = a:summary() + T.check(s.mine and s.theirs and s.mine.ok and s.theirs.ok, "the summary shows both final exchanges") + local losses = 0 + for _, c in ipairs(s.theirs.changes) do if c.kind == "loss" then losses = losses + 1 end end + T.check(losses > 0, "Gen 3 -> Gen 1 losses are listed for the receiver") + + local tampered = Model.fromWire(pa) + tampered.preview.nickname = "ZAPZAP" + local okT, whyT = b:receive(Model.toWire(tampered)) + T.check(not okT and whyT == "preview_mismatch", "a claimed preview that differs from the re-conversion is refused") + local lie = Model.fromWire(pa) + lie.preview.level = 99 + T.eq(select(2, b:receive(Model.toWire(lie))), "preview_mismatch", "a claimed preview with a bumped level is refused") + local wrong = Model.fromWire(pa) + wrong.dst = "ruby" + T.eq(select(2, b:receive(Model.toWire(wrong))), "wrong_destination", "an offer aimed at another game is refused") + local imposter = Model.fromWire(pa) + imposter.src = "gold" + T.eq(select(2, b:receive(Model.toWire(imposter))), "wrong_source", "an offer claiming another source game is refused") + local illegal = Model.fromWire(pb) + illegal.adjust = { { slot = 3, move = 57 } } + T.eq(select(2, a:receive(Model.toWire(illegal))), "replacement_not_legal", "a peer's illegal staged move is refused on receipt") + local okV, whyV = b:receive(wireTrip(pa, da), function() return false, "not_valid_here" end) + T.check(not okV and whyV == "not_valid_here", "the receiver's own validity check can refuse") +end + +do + local rental = pika1() + rental.rental = true + local m = Model.new({ version = "red", peerVersion = "gold", owned = owned({ rental }) }) + T.eq(m:choose(1).blocks[1].code, "rental", "rentals are untradeable") + local proj = pika1() + proj.sourceGen = 1 + local m2 = Model.new({ version = "red", peerVersion = "gold", owned = owned({ proj }) }) + T.eq(m2:choose(1).blocks[1].code, "projection", "battle projections are untradeable") + local egg = umbreon2(nil) + egg.isEgg = true + local m3 = Model.new({ version = "gold", peerVersion = "emerald", owned = owned({ egg }) }) + T.eq(m3:choose(1).blocks[1].code, "egg", "eggs are refused") + local m4 = Model.new({ version = "gold", peerVersion = "emerald", owned = owned({ umbreon2("FLOWER_MAIL") }) }) + T.eq(m4:choose(1).blocks[1].code, "mail", "mail is refused") + local m5 = Model.new({ version = "gold", peerVersion = "red", owned = owned({ umbreon2(nil) }) }) + T.eq(m5:choose(1).blocks[1].code, "species_missing", "a species the recipient's game lacks must be swapped for another") + local locked = owned({ pika1() }) + locked[1].locked = "pending" + local m6 = Model.new({ version = "red", peerVersion = "gold", owned = locked }) + T.eq(m6:choose(1).blocks[1].code, "pending", "a mon held by an unsettled trade cannot be offered") + local m7 = Model.new({ version = "red", peerVersion = "gold", owned = owned({ pika1() }) }) + local before = Model.plain(pika1()) + m7:choose(1) + m7:payload() + T.check(F.deepEqual(m7.owned[1].rec, before), "preparation never mutates the owned record") +end + +do + local nested = { a = { [0] = 1, [2] = 2, ["#x"] = 3 }, b = { 1, 2, 3 }, c = {} } + local back = Model.fromWire(Json.decode(Json.encode(Model.toWire(nested)))) + T.eq(back.a[0], 1, "numeric keys survive the wire") + T.eq(back.a["#x"], 3, "hash-prefixed string keys survive the wire") + T.eq(back.b[3], 3, "arrays survive the wire") + T.eq(select(2, Model.toWire({ s = ("x"):rep(300) })), "too_big", "strings past the relay cap are refused, never truncated") +end + +do + local d = Model.datasetSource + Model.datasetSource = function(v) if v == "gold" then return nil end return d(v) end + local m = Model.new({ version = "red", peerVersion = "gold", owned = owned({ pika1() }) }) + local r = m:choose(1) + T.check(r.ok, "a same-generation dataset stands in when the exact peer game is not imported") + T.eq(r.datasets.target, "silver", "the stand-in is reported") + Model.datasetSource = function(v) if v == "gold" or v == "silver" then return nil end return d(v) end + T.eq(m:choose(1).blocks[1].code, "missing_import", "no dataset for the peer's generation blocks the offer") + Model.datasetSource = d +end + +T.finish("union_trade_model") diff --git a/tests/engine/union_trade_txn_test.lua b/tests/engine/union_trade_txn_test.lua new file mode 100644 index 00000000..443b6636 --- /dev/null +++ b/tests/engine/union_trade_txn_test.lua @@ -0,0 +1,429 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local Model = require("src.online.union.TradePrepModel") +local Txn = require("src.online.union.TradeTxn") +local TradeConvert = require("src.online.xgen.TradeConvert") +local GameVersion = require("src.core.GameVersion") +local SaveData = require("src.core.SaveData") +local Save2 = require("src.core.gen2.Save") +local LT = require("src.core.game3.link.trade") + +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +local fixtures = { red = F.data("red"), gold = F.data("gold"), emerald = F.data("emerald") } +Model.datasetSource = function(version) return fixtures[version] end + +local OUTCOME = { value = "commit", asked = 0 } +LT.outcomeClient = { + send = function(_, _, _, _, opts) + OUTCOME.asked = OUTCOME.asked + 1 + OUTCOME.last = opts and opts.params + return { id = OUTCOME.asked } + end, + poll = function() return { status = "ok", data = { outcome = OUTCOME.value } } end, + release = function() end, +} + +local function pid(n) return ("%08x"):format(n) end +local FP = { red = "1111111111111111", gold = "3333333333333333", emerald = "6666666666666666" } + +local function ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, + fingerprint = FP[version], rulesetId = gen == 3 and "g3_single" or "union", kind = "vanilla" }, + vanillaFingerprint = FP[version], gameplayMods = false } +end + +local function world() + local w = { relay = FakeRelay.new({ clock = function() return CLOCK end }), clients = {} } + function w:add(n, name) + local seat = self.relay:seat(pid(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + return Room.new({ client = C }), C + end + function w:pump(rounds) + for _ = 1, rounds or 4 do + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end + end + return w +end + +local function pika1(level) + return { species = "PIKACHU", level = level or 25, exp = 15625, hp = 50, nickname = "ZAPPY", ot = "RED", otId = 4242, + dvs = { attack = 10, defense = 10, speed = 10, special = 10 }, + statExp = { hp = 100, attack = 400, defense = 0, speed = 900, special = 2500 }, + stats = { hp = 50, attack = 40, defense = 30, speed = 60, special = 40 }, + moves = { { id = "THUNDERSHOCK", pp = 30 }, { id = "GROWL", pp = 40 } }, catchRate = 190 } +end + +local function bulba2() + return { species = "BULBASAUR", level = 10, experience = 560, ot = "GOLD", otId = 9, + dvs = { attack = 12, defense = 9, speed = 5, special = 7, hp = 8 }, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 30, attack = 15, defense = 15, speed = 12, specialAttack = 16, specialDefense = 16 }, hp = 30, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 } }, happiness = 90, pokerus = 0 } +end + +local function g1game() + local save = { version = "red", party = { pika1(), pika1(30) }, pokedex = { seen = {}, owned = {} }, + player = { map = "PALLET_TOWN", x = 5, y = 5, facing = "down", name = "RED" } } + return { save = save, data = F.raw("red") } +end + +local function g2game() + local was = GameVersion.get() + GameVersion.set("gold") + local save = Save2.newGame({ playerName = "GOLD" }) + GameVersion.set(was) + save.version = "gold" + save.party = { bulba2() } + return { save = save, data = F.raw("gold") } +end + +local function side(room, game, version, peerVersion) + local adapter = Txn.newAdapter(game, version) + local s = { room = room, game = game, adapter = adapter, prep = room:prep() } + s.model = Model.new({ version = version, peerVersion = peerVersion, owned = adapter:owned() }) + s.txn = Txn.new({ game = game, prep = s.prep, model = s.model, adapter = adapter, peerName = "PEER" }) + return s +end + +local function offer(w, s, other) + local r = s.model:choose(1) + assert(r.ok, "offerable: " .. tostring(r.blocks[1] and r.blocks[1].code)) + local payload, digest = s.model:payload() + s.prep:offer(payload, digest) + w:pump() + s.prep:poll() + other.prep:poll() +end + +local function receiveBoth(a, b) + local okA, whyA = a.model:receive(a.prep.peer.offer.payload, function(final) return a.adapter:validate(final) end) + local okB, whyB = b.model:receive(b.prep.peer.offer.payload, function(final) return b.adapter:validate(final) end) + return okA, okB, whyA, whyB +end + +local function setup() + Txn.reset() + love.filesystem._reset = nil + local w = world() + local ra = w:add(1, "A") + local rb = w:add(2, "B") + w:pump() + ra:join(ctxFor("red", "A", 1)) + rb:join(ctxFor("gold", "B", 2)) + w:pump() + ra:poll(); rb:poll() + ra:invite(pid(2), "xg_trade") + w:pump() + rb:reply(rb:incoming()[1].id, true) + w:pump() + local a = side(ra, g1game(), "red", "gold") + local b = side(rb, g2game(), "gold", "red") + a.prep:poll(); b.prep:poll() + offer(w, a, b) + offer(w, b, a) + local okA, okB, whyA, whyB = receiveBoth(a, b) + assert(okA and okB, "receive: " .. tostring(whyA) .. " " .. tostring(whyB)) + return w, a, b +end + +local function readyBoth(w, a, b) + a.prep:ready(a.txn:readyDigest()) + b.prep:ready(b.txn:readyDigest()) + w:pump() + a.prep:poll(); b.prep:poll() +end + +local function wipeDisk() + for _, v in ipairs({ "red", "gold" }) do + local main = SaveData.saveFilename(v) + for _, p in ipairs({ main, main .. ".bak", main .. ".tmp", Txn.journalPath(v), Txn.journalPath(v) .. ".tmp" }) do + if love.filesystem.getInfo(p) then love.filesystem.remove(p) end + end + end +end + +local function persist(s) + T.check(s.adapter:write(), s.adapter.version .. " save written before the trade") +end + +local function species(game, i) return game.save.party[i] and game.save.party[i].species end + +local function diskSave(version) + if version == "gold" then return (Save2.load("gold")) end + return (SaveData.load(version)) +end + +local function contains(text, needle) return type(text) == "string" and text:find(needle, 1, true) ~= nil end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + local stale = a.txn:readyDigest() + readyBoth(w, a, b) + T.eq(a.prep.state, "go", "agreed digests open the trade") + T.check(a.txn:confirm(), "seat 0 journals and confirms") + T.eq(#Txn.pending("red"), 1, "the journal entry is written before trade_confirm") + local entry = Txn.pending("red")[1] + T.eq(entry.digest, stale, "the journal binds the agreed digest") + T.check(entry.out and entry.out.canonical and entry.incoming, "the entry holds the outgoing record and the incoming converted record") + T.check(b.txn:confirm(), "seat 1 journals and confirms") + w:pump() + local ea = a.txn:pump(0) + local eb = b.txn:pump(0) + T.eq(a.txn.state, "done", "seat 0 applied and saved") + T.eq(b.txn.state, "done", "seat 1 applied and saved") + T.eq(species(a.game, 1), "BULBASAUR", "Red received the Bulbasaur in the outgoing slot") + T.eq(species(b.game, 1), "PIKACHU", "Gold received the Pikachu in the outgoing slot") + T.eq(species(a.game, 2), "PIKACHU", "the other party mon is untouched") + T.check(a.game.save.pokedex.owned.BULBASAUR, "Red marks the received species owned") + T.check(b.game.save.pokedex.caught.PIKACHU, "Gold marks the received species caught") + T.eq(#Txn.pending("red") + #Txn.pending("gold"), 0, "journals dropped after the save") + local diskA = diskSave("red") + T.eq(diskA and diskA.party[1].species, "BULBASAUR", "the trade is on disk for Red") + local report = SaveData.validate(diskA, F.raw("red")) + T.eq(#report.lostMons, 0, "the received mon passes Gen 1 save validation") + local diskB = diskSave("gold") + T.eq(diskB and diskB.party[1].species, "PIKACHU", "the trade is on disk for Gold") + local main = SaveData.saveFilename("red") + local bak = love.filesystem.read(main .. ".bak") + T.check(not contains(bak, "ZAPPY") or contains(bak, "BULBASAUR"), "the rolling backup no longer holds the traded-away mon alone") + local outCount = 0 + for _, mon in ipairs(SaveData.decode(bak).party or {}) do if mon.nickname == "ZAPPY" and mon.level == 25 then outCount = outCount + 1 end end + T.eq(outCount, 0, "no restorable copy of the traded-away mon remains in the backup") + local items = love.filesystem.getDirectoryItems("") or {} + local archives = 0 + for _, name in ipairs(items) do if name:find("trade%-bak") or name:find("_xtrade") then archives = archives + 1 end end + T.eq(archives, 0, "no archive or journal file remains") + T.check(eb[1] and eb[#eb].kind == "done", "the receiver gets a done event") + T.check(ea[#ea].kind == "done", "the sender gets a done event") + local rounds = a.prep:poll() + local round + for _, e in ipairs(rounds) do if e.kind == "trade_round" then round = e end end + T.check(round ~= nil or a.prep.round >= 1, "the room returns to prep for another round") + T.check(a.txn:nextRound(), "the transaction resets for the next round") + + local dup = { type = "trade_commit", n = 1, digests = { stale, stale } } + a.prep.session.take = (function(orig) + local sent = false + return function(pred) + if not sent and pred(dup) then sent = true return dup end + return orig(pred) + end + end)(a.prep.session.take) + a.txn:pump(0) + T.eq(species(a.game, 1), "BULBASAUR", "a duplicate trade_commit does not apply twice") + T.eq(#a.game.save.party, 2, "party size unchanged by the duplicate") + T.check(not a.txn:onCommit(dup), "onCommit refuses outside commit_wait") +end + +do + wipeDisk() + local w, a, b = setup() + local first = a.txn:readyDigest() + a.prep:ready(first) + w:pump() + a.prep:poll(); b.prep:poll() + T.check(b.prep.peer.ready ~= nil, "the peer sees seat 0 ready") + b.model:choose(1) + b.model:stage(1, 0) + local payload, digest = b.model:payload() + b.prep:offer(payload, digest) + w:pump() + a.prep:poll(); b.prep:poll() + T.eq(a.prep.mine.ready, nil, "a changed offer clears my ready") + T.eq(b.prep.peer.ready, nil, "a changed offer clears the peer's ready on the other side") + a.model:receive(a.prep.peer.offer.payload, function(f) return a.adapter:validate(f) end) + local second = a.txn:readyDigest() + T.check(second ~= first, "the agreed digest changes when an offer changes") + a.prep:ready(first) + b.prep:ready(b.txn:readyDigest()) + w:pump() + local ea = a.prep:poll() + b.prep:poll() + T.check(a.prep.state ~= "go", "a ready bound to the old offer cannot open the trade") + local nacked = false + for _, e in ipairs(ea) do if e.kind == "nack" then nacked = true end end + T.check(nacked, "the stale approval is nacked") +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.game.save.party[1].level = 26 + T.check(not a.txn:confirm(), "a changed outgoing record refuses to confirm") + T.eq(a.txn.state, "aborted", "the round aborts locally") + T.eq(#Txn.pending("red"), 0, "no journal entry without a confirm") + local confirms = 0 + for _, l in ipairs(w.relay.log) do + if l.msg.type == "room_msg" and l.msg.msg and l.msg.msg.type == "trade_confirm" then confirms = confirms + 1 end + end + T.eq(confirms, 0, "no trade_confirm was sent") + w:pump() + b.prep:poll() + T.eq(b.prep.state, "closed", "the peer sees the room close") + T.eq(species(b.game, 1), "BULBASAUR", "the peer keeps its original") +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.txn:confirm() + T.eq(#Txn.pending("red"), 1, "journal written") + local written = Txn.pending("red")[1] + Txn.reset() + local reloaded = { save = diskSave("red"), data = F.raw("red") } + OUTCOME.value = "abort" + local st = Txn.resumePending(reloaded, { manual = true }) + T.check(st ~= nil, "a pending journal starts the resolver at boot") + for _ = 1, 4 do if Txn.step(st, 1) then break end end + T.eq(#Txn.pending("red"), 0, "an aborted outcome drops the journal") + T.eq(reloaded.save.party[1].species, "PIKACHU", "the original stays") + T.eq(reloaded.save.party[1].nickname, "ZAPPY", "the original is unchanged") + T.check(OUTCOME.last and OUTCOME.last.room == written.room and OUTCOME.last.digest == written.digest, + "the outcome was asked by room and digest") + OUTCOME.value = "commit" +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.adapter.writer = function() return false end + a.txn:confirm(); b.txn:confirm() + w:pump() + a.txn:pump(0); b.txn:pump(0) + T.eq(a.txn.state, "saving", "a failed save leaves the round in saving") + T.eq(#Txn.pending("red"), 1, "the journal is kept while the save fails") + T.eq(Txn.pending("red")[1].state, "committed", "the journal records the commit") + Txn.reset() + local reloaded = { save = diskSave("red"), data = F.raw("red") } + T.eq(reloaded.save.party[1].species, "PIKACHU", "the disk still has the pre-trade save after the crash") + local asked = OUTCOME.asked + local st = Txn.resumePending(reloaded, { manual = true }) + T.eq(reloaded.save.party[1].species, "BULBASAUR", "a committed journal applies at boot without the network") + T.eq(OUTCOME.asked, asked, "no outcome query is needed for a recorded commit") + T.eq(#Txn.pending("red"), 0, "journal dropped once saved") + T.eq(diskSave("red").party[1].species, "BULBASAUR", "the applied trade reached disk") + Txn.reset() + local again = { save = diskSave("red"), data = F.raw("red") } + T.eq(Txn.resumePending(again, { manual = true }), nil, "nothing left to resume") + T.eq(#again.save.party, 2, "applied exactly once") + local _ = st +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.txn:confirm(); b.txn:confirm() + w:pump() + b.txn:pump(0) + T.eq(a.txn.state, "commit_wait", "seat 0 never read the commit") + Txn.reset() + local reloaded = { save = diskSave("red"), data = F.raw("red") } + OUTCOME.value = "commit" + local st = Txn.resumePending(reloaded, { manual = true }) + for _ = 1, 4 do if Txn.step(st, 1) then break end end + T.eq(reloaded.save.party[1].species, "BULBASAUR", "the server ledger's commit applies after a restart") + T.eq(#Txn.pending("red"), 0, "journal settled") + T.eq(species(b.game, 1), "PIKACHU", "the peer has the traded mon") +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.txn:confirm(); b.txn:confirm() + local kept = Txn.pending("red")[1] + w:pump() + a.txn:pump(0); b.txn:pump(0) + T.eq(a.txn.state, "done", "applied and saved") + kept.state = "committed" + Txn.journalPut("red", kept) + Txn.reset() + local reloaded = { save = diskSave("red"), data = F.raw("red") } + Txn.resumePending(reloaded, { manual = true }) + T.eq(#reloaded.save.party, 2, "the save marker stops a second apply") + T.eq(reloaded.save.party[1].species, "BULBASAUR", "the received mon stays") + T.eq(reloaded.save.party[2].species, "PIKACHU", "the other mon is not taken") + T.eq(#Txn.pending("red"), 0, "the stale journal is dropped") + Txn.journalPut("red", kept) + Txn.reset() + local noMarker = { save = diskSave("red"), data = F.raw("red") } + noMarker.save.unionTrades = nil + Txn.resumePending(noMarker, { manual = true }) + T.eq(noMarker.save.party[2].species, "PIKACHU", "without a marker the received mon's identity still stops a second apply") + T.eq(#Txn.pending("red"), 0, "and the journal is dropped") +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + local fails = 2 + a.adapter.writer = function(ad) + if fails > 0 then fails = fails - 1 return false end + return SaveData.save(ad:save()) == true + end + a.txn:confirm(); b.txn:confirm() + w:pump() + local ev = a.txn:pump(0) + T.eq(ev[#ev].kind, "save_failed", "the failed save is reported") + T.eq(#Txn.pending("red"), 1, "the journal survives the failed save") + a.txn:pump(Txn.SAVE_RETRY_SECONDS + 0.1) + T.eq(a.txn.state, "saving", "a second failure keeps retrying") + a.txn:pump(Txn.SAVE_RETRY_SECONDS + 0.1) + T.eq(a.txn.state, "done", "the retry lands") + T.eq(#Txn.pending("red"), 0, "journal dropped after the retry") + T.eq(#a.game.save.party, 2, "applied once across retries") +end + +do + wipeDisk() + local w, a, b = setup() + persist(a); persist(b) + readyBoth(w, a, b) + a.txn:confirm() + b.txn.confirm = function() return false end + w.relay:handle(w.relay.sessions[pid(2)], { type = "room_leave" }) + w:pump() + a.txn:pump(0) + T.check(a.txn.state == "aborted" or a.txn.state == "unresolved", "a peer leaving mid-barrier never commits") + if a.txn.state == "unresolved" then + OUTCOME.value = "abort" + local st = Txn._resolver + st.adapter.ready = function() return true end + for _ = 1, 4 do if Txn.step(st, 1) then break end end + OUTCOME.value = "commit" + end + T.eq(#Txn.pending("red"), 0, "the journal is settled as aborted") + T.eq(species(a.game, 1), "PIKACHU", "seat 0 keeps its original") + T.eq(species(b.game, 1), "BULBASAUR", "seat 1 keeps its original") +end + +T.finish("union_trade_txn") diff --git a/tests/engine/union_yellow_receptionist.lua b/tests/engine/union_yellow_receptionist.lua new file mode 100644 index 00000000..27d825b0 --- /dev/null +++ b/tests/engine/union_yellow_receptionist.lua @@ -0,0 +1,66 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() +local GameVersion = require("src.core.GameVersion") +local OW = require("src.world.OverworldController") +local PikachuFollower = require("src.world.PikachuFollower") + +Data.text._CableClubNPCWelcomeText = "WELCOME" +Data.text._CableClubNPCMakingPreparationsText = "PREPARATIONS" +Data.text._CableClubNPCPleaseApplyHereHaveToSaveText = "APPLY" +Data.text._LooksContentText = "CONTENT" + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val) return true end + i = i + 1 + end +end + +local pushed = {} +local fakeGame = { + data = Data, + save = { flags = { EVENT_GOT_POKEDEX = true }, party = {} }, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, +} +local textBoxStub = { + new = function(_, text, onDone, opts) return { text = text, onDone = onDone, opts = opts } end, +} +T.check(setUpvalue(OW.cableClubReceptionist, "TextBox", textBoxStub), "TextBox upvalue") +T.check(setUpvalue(OW.cableClubReceptionist, "Game", fakeGame), "Game upvalue") + +local previous = GameVersion.get() +GameVersion.set("yellow") + +local function talk(mapId, sleeping) + pushed = {} + local ow = setmetatable({ map = { id = mapId }, npcs = {}, entities = {}, + pikachuPewterSleepScene = sleeping or nil }, { __index = OW }) + ow:cableClubReceptionist(function() end) + return pushed[1] or {} +end + +local box = talk("PEWTER_POKECENTER", true) +T.eq(box.text, "WELCOME\fPREPARATIONS", + "Pewter with Pikachu asleep: CableClubNPC welcomes then takes the didNotConnect path") +box = talk("POKECENTER_2F", true) +T.eq(box.text, "WELCOME\fPREPARATIONS", "the shared 2F desk reads the same follower state") +box = talk("POKECENTER_2F", false) +T.eq(box.text, "WELCOME\fAPPLY", "an awake follower gets the apply prompt on 2F") +T.check(box.opts and box.opts.choice ~= nil, "and the YES/NO") +box = talk("PEWTER_POKECENTER", false) +T.eq(box.text, "WELCOME\fAPPLY", "Pewter with Pikachu awake gets the apply prompt") + +local ow = { map = { id = "POKECENTER_2F" }, npcs = {}, entities = {}, pikachuPewterSleepScene = true } +PikachuFollower.onMapEntered({ save = { flags = {}, party = {} }, data = Data }, ow, nil, true) +T.eq(ow.pikachuPewterSleepScene, nil, "a map load re-enables following, so the stairs end the sleep scene") +ow.pikachuPewterSleepScene = true +PikachuFollower.onMapEntered({ save = { flags = {}, party = {} }, data = Data }, ow, nil, false) +T.eq(ow.pikachuPewterSleepScene, true, "a scripted respawn without a map load keeps it") + +GameVersion.set(previous) +T.finish("union_yellow_receptionist") diff --git a/tests/engine/xgen_compat_battle_test.lua b/tests/engine/xgen_compat_battle_test.lua new file mode 100644 index 00000000..184f8972 --- /dev/null +++ b/tests/engine/xgen_compat_battle_test.lua @@ -0,0 +1,126 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Compat = require("src.online.xgen.Compat") +local Policy = require("src.online.xgen.Policy") +local Messages = require("src.online.xgen.Messages") + +local emerald = F.data("emerald") +local ruleset = { ruleset = "g3u-gen1", dexMax = 151, moveMax = 165 } + +local function mon3(national, level, moves, extra) + local m = { species = emerald.nationalToLocal[national], level = level, personality = 7, otId = 1, otSecretId = 0, + otName = "MAY", ivs = { hp = 1, atk = 1, def = 1, spe = 1, spa = 1, spd = 1 }, evs = {}, moves = moves } + for k, v in pairs(extra or {}) do m[k] = v end + return m +end + +local treecko = mon3(252, 20, { 33, 345 }) +local pika = mon3(25, 20, { 84, 45, 98, 57 }) +local bulba = mon3(1, 10, { 33 }) +local owned = { + { rec = mon3(197, 30, { 33 }), ref = "pc:1" }, + { rec = mon3(25, 18, { 84 }), ref = "pc:2" }, + { rec = mon3(152, 25, { 33 }), ref = "pc:3" }, + { rec = mon3(1, 22, { 33 }), ref = "pc:4" }, + { rec = mon3(2, 21, { 33 }), ref = "pc:5" }, + { rec = mon3(150, 60, { 94 }), ref = "pc:6" }, +} + +local args = { op = "battle", source = { game = "emerald", gen = 3, data = emerald }, target = ruleset, + mons = { treecko, pika, bulba }, owned = owned, opponentSize = 3, + versions = { policy = Policy.VERSION, proto = Policy.PROTO } } +local snapshot = F.copy(args.mons) +local r = Compat.report(args) +T.check(F.deepEqual(args.mons, snapshot), "report does not mutate the team") +T.check(not r.ok, "Treecko and an illegal move block the team") +local byCode = {} +for _, b in ipairs(r.blocks) do byCode[b.code] = byCode[b.code] or {}; table.insert(byCode[b.code], b) end +T.check(byCode.species_not_in_ruleset and byCode.species_not_in_ruleset[1].slot == 1, "Treecko is outside dex 1-151") +T.eq(byCode.species_not_in_ruleset[1].detail.dexMax, 151, "block explains the dex limit") +local reps = r.options.replacements[1] +T.check(reps and #reps > 0, "replacement candidates offered") +T.eq(reps[1].national, 2, "shares Treecko's grass type, closest level first (Ivysaur 21)") +T.eq(reps[2].national, 1, "then Bulbasaur 22") +T.eq(reps[3].sharesType, false, "non-sharing types after the sharing ones") +T.eq(reps[3].national, 25, "then by level closeness (Pikachu 18)") +local seen152 = false +for _, row in ipairs(reps) do if row.national == 152 then seen152 = true end end +T.check(not seen152, "Chikorita (dex 152) is not an eligible replacement") +local lastShares = true +local order_ok = true +for _, row in ipairs(reps) do + if row.sharesType and not lastShares then order_ok = false end + lastShares = row.sharesType +end +T.check(order_ok, "shares-type candidates are ranked before the rest") + +local codesForPika = {} +for _, b in ipairs(r.blocks) do if b.slot == 2 then codesForPika[b.detail.index] = b.code end end +T.eq(codesForPika[4], "move_not_legal", "Surf exists in the ruleset but Pikachu cannot learn it") +T.eq(codesForPika[3], nil, "Quick Attack (98) is legal for Pikachu") +local trA = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { mon3(1, 10, { 33, 345 }) }, opponentSize = 1 }) +T.eq(trA.blocks[1].code, "move_not_in_ruleset", "Magical Leaf (345) does not exist in a Gen 1 ruleset") +local sugg = r.options.moves[2][4] +T.check(sugg and #sugg > 0, "legal replacement moves suggested") +T.eq(sugg[1].move, 85, "no water move: same category and power band first (Thunderbolt)") +T.eq(sugg[2].move, 34, "then by name (Body Slam)") + +local unsupported = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { mon3(25, 20, { 84, 85 }) }, + opponentSize = 1, unsupported = { 85 } }) +T.eq(unsupported.blocks[1].code, "move_unsupported", "unsupported list from the match table makes a move illegal") + +local fixed = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { treecko, pika, bulba }, + owned = owned, opponentSize = 3, + adjustments = { replace = { [1] = { owned = 4 } }, moves = { [2] = { [4] = 0 } } } }) +T.check(fixed.ok, "replacement plus an emptied slot makes the team legal") +T.eq(#fixed.result.team, 3, "three battlers") +T.eq(fixed.result.team[1].species, 1, "slot 1 replaced by Bulbasaur from the PC") +T.eq(#fixed.result.team[2].moves, 3, "emptied move slot dropped") +T.eq(fixed.result.team[2].moves[1].id, 84, "move order kept (1)") +T.eq(fixed.result.team[2].moves[3].id, 98, "move order kept (3)") +local emptied = false +for _, c in ipairs(fixed.changes) do if c.field == "moves" and c.to == 0 then emptied = true end end +T.check(emptied, "emptied move reported as a change") + +local swapped = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { pika }, opponentSize = 1, + adjustments = { moves = { [1] = { [4] = 85 } } } }) +T.check(swapped.ok, "Thunderbolt (TM, learnable) can replace Surf") +T.eq(swapped.result.team[1].moves[4].id, 85, "replacement lands in the same slot") +local bad = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { pika }, opponentSize = 1, + adjustments = { moves = { [1] = { [4] = 57 } } } }) +T.eq(bad.blocks[1].code, "replacement_not_legal", "replacement must itself be legal") + +local none = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { mon3(25, 20, { 57 }) }, opponentSize = 1, + adjustments = { moves = { [1] = { [1] = 0 } } } }) +T.eq(none.blocks[1].code, "no_legal_moves", "a battler needs at least one legal move") + +local big = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { pika, bulba, mon3(25, 30, { 84 }) }, + opponentSize = 2 }) +T.eq(big.blocks[1].code, "choose_sit_out", "larger roster must choose who sits out") +T.eq(big.blocks[1].detail.need, 1, "one must sit out") +T.eq(big.result, nil, "no team is auto-picked") +local sat = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { pika, bulba, mon3(25, 30, { 84 }) }, + opponentSize = 2, adjustments = { sitOut = { 1 }, moves = {} } }) +T.check(sat.ok, "explicit sit-out resolves the size") +T.eq(sat.result.team[1].species, 1, "remaining order kept (Bulbasaur first)") +T.eq(sat.result.slots[1], 2, "slot indices recorded") +T.eq(Compat.teamSize(6, 3), 3, "equal teams default to the smaller roster") +T.eq(Compat.teamSize(6, 6, 4), 4, "both may agree a smaller size") +T.eq(Compat.teamSize(2, 6, 4), 2, "never above the smaller roster") + +local version = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { bulba }, opponentSize = 1, + versions = { policy = 99 } }) +T.eq(version.blocks[1].code, "policy_mismatch", "policy version mismatch blocks") + +local egg = Compat.report({ op = "battle", source = args.source, target = ruleset, mons = { mon3(1, 5, { 33 }, { isEgg = true }) }, opponentSize = 1 }) +T.eq(egg.blocks[1].code, "egg", "eggs cannot battle") + +for _, rep in ipairs({ r, trA, unsupported, bad, none, big, version, egg }) do + for _, b in ipairs(rep.blocks) do + T.check(Messages.known(b.code), "message exists for " .. b.code) + end +end + +T.finish("xgen_compat_battle") diff --git a/tests/engine/xgen_identity_test.lua b/tests/engine/xgen_identity_test.lua new file mode 100644 index 00000000..c10fd8f1 --- /dev/null +++ b/tests/engine/xgen_identity_test.lua @@ -0,0 +1,73 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Identity = require("src.online.xgen.Identity") +local Datasets = require("src.online.xgen.Datasets") + +T.eq(Identity.normalize("NIDORAN\226\153\128"), "NIDORANF", "female symbol normalizes") +T.eq(Identity.normalize("MR. MIME"), Identity.normalize("MR.MIME"), "punctuation and spaces ignored") +T.eq(Identity.normalize("POK\195\169 BALL"), "POKEBALL", "accented e normalizes") +T.eq(Identity.canonType("PSYCHIC_TYPE", 1), "PSYCHIC", "Gen 1 PSYCHIC_TYPE is PSYCHIC") +T.eq(Identity.canonType("CURSE_TYPE", 2), "MYSTERY", "Gen 2 curse type is MYSTERY") +T.eq(Identity.canonType(13, 3), "ELECTRIC", "Gen 3 type id 13 is ELECTRIC") +T.eq(Identity.category("FIRE", 90), "special", "fire is special") +T.eq(Identity.category("DARK", 80), "special", "dark is special") +T.eq(Identity.category("GHOST", 30), "physical", "ghost is physical") +T.eq(Identity.category("NORMAL", 0), "status", "zero power is status") + +local red, gold, silver, emerald = F.data("red"), F.data("gold"), F.data("silver"), F.data("emerald") +T.eq(Identity.speciesOf(red, "PIKACHU"), 25, "Gen 1 local key maps to national") +T.eq(Identity.localSpecies(emerald, 252), 277, "Gen 3 national 252 maps to internal 277") +T.eq(Identity.speciesOf(emerald, 277), 252, "Gen 3 internal maps to national") +T.eq(Identity.moveOf(red, "PSYCHIC_M"), 94, "Gen 1 move key maps to canonical id") +T.eq(Identity.localMove(gold, 202), "GIGA_DRAIN", "canonical id maps back to local key") +T.eq(red.moves[94].type, "PSYCHIC", "move type canonical") +T.eq(emerald.species[25].types[1], "ELECTRIC", "Gen 3 species types canonical and deduped") +T.eq(#emerald.species[25].types, 1, "single-type species has one type") +T.eq(#emerald.problems, 0, "fixture Gen 3 type names agree with canonical") + +local okA = Identity.agree(red, emerald) +T.check(okA, "fixture Gen 1 and Gen 3 identities agree") +local okB = Identity.agree(gold, silver) +T.check(okB, "two Gen 2 learnset layouts agree on identity") + +local function sameLevel(a, b) + if #a ~= #b then return false end + for i = 1, #a do if a[i].level ~= b[i].level or a[i].move ~= b[i].move then return false end end + return true +end +for _, n in ipairs({ 1, 2, 25, 152 }) do + T.check(sameLevel(gold.species[n].levelMoves, silver.species[n].levelMoves), "levelMoves and learnset layouts give the same level-up list for " .. n) + T.check(F.deepEqual(gold.species[n].egg, silver.species[n].egg), "egg moves agree across layouts for " .. n) +end +T.check(Datasets.learnable(red, 2, 22, 30), "Ivysaur keeps Bulbasaur's Vine Whip through the evolution chain") +T.check(not Datasets.learnable(red, 1, 22, 5), "level gate applies to level-up moves") +T.check(Datasets.learnable(red, 1, 33, 1), "level 1 moves are legal") +T.check(Datasets.learnable(gold, 1, 75, 5), "Gen 2 egg moves are legal") +T.check(Datasets.learnable(gold, 2, 75, 30), "prevo egg moves are legal") +T.check(Datasets.learnable(emerald, 25, 85, 5), "Gen 3 TM bitfield decoded") +T.check(Datasets.learnable(emerald, 25, 34, 5), "Gen 3 tutor bitfield decoded") +T.check(not Datasets.learnable(emerald, 25, 57, 5), "unset tutor bit is not legal") + +local real, list = F.allReal() +if #list < 2 then + print("[skip] xgen identity: fewer than two imported caches") +else + for i = 1, #list do + local d = real[list[i]] + T.eq(#d.problems == 0 or d.generation == 1, true, list[i] .. " dataset builds without identity problems") + for j = i + 1, #list do + local ok, mismatches = Identity.agree(d, real[list[j]]) + T.check(ok, ("%s and %s agree on species and move identity (%d mismatches)"):format(list[i], list[j], #mismatches)) + end + end + for _, v in ipairs(list) do + local d = real[v] + local count = 0 + for _ in pairs(d.species) do count = count + 1 end + T.eq(count, d.dexMax, v .. " has every national dex entry up to its dex max") + end +end + +T.finish("xgen_identity") diff --git a/tests/engine/xgen_messages_test.lua b/tests/engine/xgen_messages_test.lua new file mode 100644 index 00000000..5a7676be --- /dev/null +++ b/tests/engine/xgen_messages_test.lua @@ -0,0 +1,48 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Messages = require("src.online.xgen.Messages") + +local codes = { "missing_import", "policy_mismatch", "proto_mismatch", "bad_record", "species_unknown", "move_unknown", + "egg", "mail", "item_unknown", "item_unrepresentable", "species_missing", "species_not_in_ruleset", + "move_not_in_ruleset", "move_unsupported", "move_not_legal", "move_missing", "replacement_not_legal", + "no_legal_moves", "no_moves", "nickname_unencodable", "ot_unencodable", "ot_invalid", + "personality_unrepresentable", "traits_unrepresentable", "choose_sit_out", "team_too_small", + "native_needs_same_gen", "unknown_ruleset" } +local names = { species = function(n) return n == 252 and "TREECKO" or "No." .. n end, + move = function(m) return m == 345 and "MAGICAL LEAF" or "MOVE" .. m end } +for _, code in ipairs(codes) do + T.check(Messages.known(code), code .. " has text") + local detail = { national = 252, dexMax = 151, move = 345, item = "PINK BOW", need = 1, size = 3 } + for _, line in ipairs(Messages.render(code, 1, detail, names)) do + T.check(#line <= Messages.GB_WIDTH, code .. " Gen 1 line fits: " .. line) + T.eq(line, line:upper(), code .. " Gen 1 line is uppercase") + end + local gba = Messages.render(code, 3, detail, names) + T.eq(#gba, 1, code .. " Gen 3 is one mixed-case string") + T.check(gba[1]:find("%l") ~= nil, code .. " Gen 3 keeps lower case") + T.check(not gba[1]:find("{", 1, true), code .. " placeholders filled") +end +local line = table.concat(Messages.render("species_not_in_ruleset", 3, { national = 252, dexMax = 151 }, names), " ") +T.check(line:find("TREECKO", 1, true) and line:find("151", 1, true), "species and dex max rendered") +T.check(#Messages.change({ field = "ribbons", kind = "loss" }, 2) > 0, "loss renders for Gen 2") +T.check(Messages.change({ field = "ribbons", kind = "loss" }, 3)[1]:find("lost", 1, true), "loss wording") + +T.eq(Messages.about("PIKACHU", "move_not_legal", { national = 25, move = 57 }, + { species = function() return "PIKACHU" end, move = function() return "SURF" end }), "PIKACHU can't learn SURF.", + "a per-move line names the Pokemon once") +T.eq(Messages.about("PIKACHU", "move_missing", { move = 57 }, { move = function() return "SURF" end }), + "PIKACHU: SURF doesn't exist in the other game.", "a line without the species gets the name prefix") +for _, f in ipairs({ "caughtLevel", "caughtTime", "caughtLocation", "caughtByGender", "caughtGender", + "fatefulEncounter", "modernFatefulEncounter", "championRibbon", "extra", "egg", "eggSteps", "eggCycles" }) do + for _, kind in ipairs({ "change", "loss" }) do + local text = Messages.change({ field = f, kind = kind }, 3)[1] + T.check(not text:find("Some data", 1, true), f .. " " .. kind .. " has a field name: " .. text) + local gb = Messages.change({ field = f, kind = kind }, 1) + for _, l in ipairs(gb) do + T.check(#l <= Messages.GB_WIDTH and l == l:upper(), f .. " Gen 1 line fits and is uppercase: " .. l) + end + end +end + +T.finish("xgen_messages") diff --git a/tests/engine/xgen_project_test.lua b/tests/engine/xgen_project_test.lua new file mode 100644 index 00000000..2921511f --- /dev/null +++ b/tests/engine/xgen_project_test.lua @@ -0,0 +1,79 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Project = require("src.online.xgen.Project") +local TradeConvert = require("src.online.xgen.TradeConvert") + +local red, gold, emerald = F.data("red"), F.data("gold"), F.data("emerald") + +local gen1 = { species = "MEWTWO", level = 70, exp = 427000, nickname = "MEWTWO", ot = "RED", otId = 1234, + dvs = { attack = 15, defense = 14, speed = 13, special = 12 }, + statExp = { hp = 65535, attack = 65535, defense = 40000, speed = 100, special = 10000 }, + moves = { { id = "PSYCHIC_M", pp = 3, ppUps = 2 } }, hp = 12, status = "PSN" } +local before = F.copy(gen1) +local a = Project.mon(gen1, red, { legacyPresent = true }) +local b = Project.mon(gen1, red, { legacyPresent = true }) +T.check(F.deepEqual(gen1, before), "projection leaves the Gen 1 record untouched") +T.eq(TradeConvert.canonical(a), TradeConvert.canonical(b), "same Gen 1 input gives byte-equal projection") +T.check(not F.shares(gen1, a), "projection shares no table with its input") +T.eq(a.ivs.atk, 31, "DV 15 -> IV 31") +T.eq(a.ivs.def, 29, "DV 14 -> IV 29") +T.eq(a.ivs.spa, 25, "Special DV 12 -> SpA IV 25") +T.eq(a.ivs.spd, 25, "Special DV 12 -> SpD IV 25") +T.eq(a.ivs.hp, Project.hpDv({ attack = 15, defense = 14, speed = 13, special = 12 }) * 2 + 1, "HP IV from the derived HP DV") +T.eq(a.evs.hp, 255, "Stat Exp 65535 -> 255 EV") +T.eq(a.evs.atk, 255, "Stat Exp 65535 -> 255 EV") +T.eq(a.evs.def, 0, "510 cap applied in slot order") +T.eq(a.evs.spa, 0, "cap reaches SpA") +T.eq(a.spAtk, a.spDef, "Gen 1 SpA and SpD both from Special") +T.eq(a.nature, 0, "nature neutral") +T.eq(a.ability, 0, "ability off") +T.eq(a.item, 0, "item off") +T.eq(a.types, nil, "battle record carries no typing") +T.eq(a.hp, a.maxHp, "HP full") +T.eq(a.status, nil, "status clear") +T.eq(a.moves[1].id, 94, "move by canonical id") +T.eq(a.moves[1].ppUps, 2, "PP Up count kept") +T.eq(a.moves[1].pp, 10 + 2 * 2, "PP is max PP with the same PP Up count") +T.eq(a.species, 150, "species is the national dex number") +local expectHp = math.floor(((2 * 106 + a.ivs.hp + math.floor(255 / 4)) * 70) / 100) + 70 + 10 +T.eq(a.maxHp, expectHp, "Gen 3 HP formula from owner's base stats") + +local gen3 = { species = 25, level = 30, exp = 27000, personality = 0x12345678 + 3, otId = 1000, otSecretId = 2, + otName = "Max", nickname = "SPARKY", ivs = { hp = 31, atk = 10, def = 11, spe = 30, spa = 20, spd = 21 }, + evs = { hp = 4, atk = 0, def = 0, spe = 252, spa = 252, spd = 0 }, moves = { 84, 98 }, pp = { 1, 2 }, + ppBonusesPacked = 1 + 3 * 4, heldItem = 202, friendship = 200 } +local before3 = F.copy(gen3) +local c = Project.mon(gen3, emerald, { legacyPresent = true }) +local d = Project.mon(gen3, emerald, { legacyPresent = false }) +T.check(F.deepEqual(gen3, before3), "projection leaves the Gen 3 record untouched") +T.check(not F.shares(gen3, c), "Gen 3 projection shares no table with its input") +T.eq(c.ivs.spa, 20, "Gen 3 IVs kept") +T.eq(c.evs.spe, 252, "Gen 3 EVs kept") +T.eq(c.nature, 0, "legacy present: neutral nature") +T.eq(c.ability, 0, "legacy present: ability off") +T.eq(c.item, 0, "legacy present: item off") +T.eq(d.nature, gen3.personality % 25, "Gen 3 only: nature kept") +T.eq(d.item, 202, "Gen 3 only: item kept") +T.eq(d.ability, 9, "Gen 3 only: ability kept") +T.eq(c.moves[1].ppUps, 1, "packed PP Up slot 1") +T.eq(c.moves[2].ppUps, 3, "packed PP Up slot 2") +T.eq(c.moves[2].pp, 30 + 3 * 6, "PP Up 3 on 30 PP") +local neutral = Project.stats3({ hp = 50, atk = 50, def = 50, spe = 50, spa = 50, spd = 50 }, 50, + { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }, { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, 0, 1) +local adamant = Project.stats3({ hp = 50, atk = 50, def = 50, spe = 50, spa = 50, spd = 50 }, 50, + { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }, { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, 3, 1) +T.eq(neutral.atk, 70, "neutral Atk") +T.eq(adamant.atk, 77, "Adamant raises Atk") +T.eq(adamant.spa, 63, "Adamant lowers SpA") + +local gen2 = { species = "UMBREON", level = 40, experience = 64000, dvs = { attack = 10, defense = 10, speed = 10, special = 10 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, moves = { { id = "TACKLE", pp = 35 } }, + item = "LEFTOVERS", happiness = 255, ot = "GOLD", otId = 5 } +local e = Project.mon(gen2, gold, { legacyPresent = true }) +T.check(e.shiny, "Gen 2 DV shiny rule") +T.eq(e.gender, Project.genderDv(31, gen2.dvs), "Gen 2 DV gender rule") +T.eq(e.item, 0, "Gen 2 held item off in g3u") + +T.finish("xgen_project") diff --git a/tests/engine/xgen_rentals_test.lua b/tests/engine/xgen_rentals_test.lua new file mode 100644 index 00000000..71eb216c --- /dev/null +++ b/tests/engine/xgen_rentals_test.lua @@ -0,0 +1,66 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Rentals = require("src.online.xgen.Rentals") +local Identity = require("src.online.xgen.Identity") +local Policy = require("src.online.xgen.Policy") + +T.eq(Rentals.VERSION, 1, "rental table version") +T.eq(Rentals.VERSION, Policy.RENTALS, "policy advertises the rental version") +local expected = { ["g3u-gen1"] = Identity.GEN1_TYPES, ["g3u-gen2"] = Identity.TYPES, ["g3u-gen3"] = Identity.TYPES } +for id, types in pairs(expected) do + local defs = Rentals.DEFINITIONS[id] + local ruleset = Policy.ruleset(id) + T.eq(#defs, #types, id .. " has one rental per ordinary type") + local byType, species = {}, {} + for _, def in ipairs(defs) do + byType[def.type] = (byType[def.type] or 0) + 1 + T.check(not species[def.species], id .. " rental species distinct: " .. def.species) + species[def.species] = true + T.check(def.species <= ruleset.dexMax, id .. " rental species within dex max") + for _, m in ipairs(def.moves) do T.check(m <= ruleset.moveMax, id .. " rental move within move max: " .. m) end + end + for _, t in ipairs(types) do T.eq(byType[t], 1, id .. " covers " .. t) end + T.eq(byType.MYSTERY, nil, id .. " has no ??? rental") +end + +local fixture = F.data("red") +local built = Rentals.build("g3u-gen1", fixture) +T.check(#built.excluded > 0, "fixture data cannot support every rental") +local tauros +for _, ex in ipairs(built.excluded) do + T.check(ex.code ~= nil and ex.type ~= nil, "excluded rental reports type and code") + if ex.species == 128 then tauros = ex end +end +T.eq(tauros and tauros.code, "species_not_in_ruleset", "rental for a species missing from the data is excluded and reported") +local unknown = Rentals.build("g3u-nope", fixture) +T.eq(unknown.excluded[1].code, "unknown_ruleset", "unknown ruleset reported") + +local real, list = F.allReal() +if #list == 0 then + print("[skip] xgen rentals: no imported caches") +else + for _, version in ipairs(list) do + local d = real[version] + for id, rs in pairs(Policy.RULESETS) do + if d.generation >= rs.gen then + local out = Rentals.build(id, d) + T.eq(#out.excluded, 0, ("%s rentals all legal in %s"):format(id, version)) + for _, ex in ipairs(out.excluded) do print(" excluded", id, version, ex.type, ex.species, ex.code, ex.detail and ex.detail.move) end + T.eq(#out.rentals, #Rentals.DEFINITIONS[id], ("%s full rental set in %s"):format(id, version)) + for _, r in ipairs(out.rentals) do + T.eq(r.record.rental, true, "rental record tagged") + T.eq(r.record.level, 50, "rental level 50") + T.eq(#r.moves, #r.record.moves, "rental moves disclosed") + T.check(r.stats.hp > 0 and r.stats.speed > 0, "rental stats disclosed") + end + local again = Rentals.build(id, d) + T.eq(require("src.online.xgen.TradeConvert").canonical(again.rentals), + require("src.online.xgen.TradeConvert").canonical(out.rentals), id .. " rental build deterministic in " .. version) + end + end + end +end + +T.finish("xgen_rentals") diff --git a/tests/engine/xgen_trade_test.lua b/tests/engine/xgen_trade_test.lua new file mode 100644 index 00000000..74518f27 --- /dev/null +++ b/tests/engine/xgen_trade_test.lua @@ -0,0 +1,215 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local TradeConvert = require("src.online.xgen.TradeConvert") +local Compat = require("src.online.xgen.Compat") +local Project = require("src.online.xgen.Project") +local Messages = require("src.online.xgen.Messages") + +local function convert(src, dst, mon, adjustments) + return TradeConvert.convert({ source = { data = src }, target = { data = dst }, mon = mon, adjustments = adjustments }) +end + +local function accountedAll(report, rec, label) + local missing = {} + for key in pairs(rec) do + if report.accounting[key] == nil then missing[#missing + 1] = key end + end + T.eq(#missing, 0, label .. ": every source field is carried, changed, derived or lost (" .. table.concat(missing, ",") .. ")") + local reported = {} + for _, c in ipairs(report.changes) do reported[c.field] = c.kind end + for key, fate in pairs(report.accounting) do + if fate == "lost" then T.eq(reported[key], "loss", label .. ": lost field " .. key .. " reported as a loss") end + if fate == "changed" then T.eq(reported[key], "change", label .. ": changed field " .. key .. " reported as a change") end + end +end + +local red, gold, silver, emerald = F.data("red"), F.data("gold"), F.data("silver"), F.data("emerald") + +local g1 = { species = "PIKACHU", level = 25, exp = 15625, hp = 10, status = "PAR", nickname = "ZAPPY", ot = "RED", otId = 4242, + dvs = { attack = 10, defense = 10, speed = 10, special = 10 }, statExp = { hp = 100, attack = 400, defense = 0, speed = 900, special = 2500 }, + moves = { { id = "THUNDERSHOCK", pp = 10, ppUps = 1 }, { id = "GROWL", pp = 40 } }, catchRate = 163, + stats = { hp = 50 }, extra = { mod = 1 } } +local snap = F.copy(g1) +local r13 = convert(red, emerald, g1) +local r13b = convert(red, emerald, g1) +T.check(r13.ok, "Gen 1 Pikachu converts to Gen 3") +T.check(F.deepEqual(g1, snap), "conversion does not mutate the source") +T.eq(r13.canonical, r13b.canonical, "Gen 1 -> Gen 3 conversion is byte-deterministic") +T.check(not F.shares(g1, r13.result), "converted record shares no table with the source") +local out = r13.result +T.eq(out.personality % 25, 0, "personality gives a neutral (Hardy) nature") +T.eq(out.personality % 2, 0, "ability slot 0 parity") +T.check(Project.shiny3(out.personality, out.otId, out.otSecretId), "DV shininess kept in the personality") +T.eq(Project.gender3(127, out.personality), Project.genderDv(127, g1.dvs), "DV gender kept in the personality") +T.eq(out.ivs.atk, 21, "IV = 2*DV+1") +T.eq(out.ivs.spa, 21, "SpA IV from Special") +T.eq(out.evs.spa, 50, "SpA EV from Special Stat Exp") +T.eq(out.evs.spd, 50, "SpD EV from Special Stat Exp") +T.eq(out.metLocation, 0xFE, "met in a trade") +T.eq(out.metGame, 3, "met game is the destination game") +T.eq(out.abilityNum, 0, "ability slot 0") +T.eq(out.nickname, "ZAPPY", "nickname kept") +T.eq(out.otName, "RED", "OT kept") +T.eq(out.moves[1].ppUps, 1, "PP Ups kept") +T.eq(out.moves[1].pp, 30 + 6, "PP full at destination max") +T.eq(out.status, "", "status cleared") +T.eq(out.friendship, 70, "Gen 1 -> Gen 3 friendship is the species base") +accountedAll(r13, g1, "Gen 1 -> Gen 3") +local lostExtra = false +for _, c in ipairs(r13.changes) do if c.field == "extra" and c.kind == "loss" then lostExtra = true end end +T.check(lostExtra, "mod extra data reported lost across generations") + +local back = convert(emerald, red, out) +T.check(back.ok, "and back to Gen 1") +T.check(F.deepEqual(back.result.dvs, { attack = 10, defense = 10, speed = 10, special = 10, hp = 0 }), "DVs round-trip through Gen 3") +accountedAll(back, out, "Gen 3 -> Gen 1") + +local r12 = convert(red, gold, g1) +T.check(r12.ok, "Gen 1 -> Gen 2") +T.eq(r12.result.item, "LIGHT_BALL", "Gen 1 catch rate 163 becomes the Time Capsule held item") +T.eq(r12.result.happiness, 70, "Gen 1 -> Gen 2 friendship 70") +T.eq(r12.result.caughtLevel, 0, "Time Capsule clears caught data") +accountedAll(r12, g1, "Gen 1 -> Gen 2") + +local g2 = { species = "UMBREON", level = 40, experience = 64000, nickname = "MOON", ot = "GOLD", otId = 9, + dvs = { attack = 15, defense = 10, speed = 3, special = 7 }, statExp = { hp = 65535, attack = 0, defense = 0, speed = 0, special = 400 }, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "CRUNCH", pp = 1, maxPp = 15 } }, item = "LEFTOVERS", + happiness = 255, pokerus = 0x31, caughtLevel = 25, caughtTime = 1, caughtLocation = 4, caughtByGender = 1 } +local r23 = convert(gold, emerald, g2) +T.check(r23.ok, "Gen 2 Umbreon -> Gen 3") +T.eq(r23.result.item, 200, "Leftovers kept by canonical name") +T.eq(r23.result.friendship, 255, "friendship kept") +T.eq(r23.result.pokerus, 0x31, "Pokerus kept") +T.eq(r23.result.otGender, 1, "Crystal-style caught gender becomes OT gender") +accountedAll(r23, g2, "Gen 2 -> Gen 3") +local r21 = convert(gold, red, g2) +T.eq(r21.blocks[1].code, "species_missing", "Umbreon cannot go to Gen 1") +local g2b = F.copy(g2); g2b.species = "PIKACHU"; g2b.moves = { { id = "THUNDERSHOCK", pp = 30 } }; g2b.item = "LIGHT_BALL" +local r21b = convert(gold, red, g2b) +T.check(r21b.ok, "Gen 2 Pikachu -> Gen 1") +T.eq(r21b.result.catchRate, 163, "Gen 2 held item stored as Gen 1 catch rate") +accountedAll(r21b, g2b, "Gen 2 -> Gen 1") +local lost = {} +for _, c in ipairs(r21b.changes) do if c.kind == "loss" then lost[c.field] = true end end +T.check(lost.happiness and lost.pokerus and lost.caughtLevel, "friendship, Pokerus and caught data reported lost in Gen 1") + +local g2mail = F.copy(g2); g2mail.item = "FLOWER_MAIL" +T.eq(convert(gold, emerald, g2mail).blocks[1].code, "mail", "mail refused") +local g2bow = F.copy(g2); g2bow.item = "PINK_BOW" +local bow = convert(gold, emerald, g2bow) +T.eq(bow.blocks[1].code, "item_unrepresentable", "item missing in the destination refused until removed") +T.eq(bow.result, nil, "no result while blocked") +local g2egg = F.copy(g2); g2egg.isEgg = true +T.eq(convert(gold, emerald, g2egg).blocks[1].code, "egg", "eggs refused") +local g2nick = F.copy(g2); g2nick.nickname = "MOON\226\130\172" +local nick = convert(gold, emerald, g2nick) +local hasNick = false +for _, b in ipairs(nick.blocks) do if b.code == "nickname_unencodable" then hasNick = true end end +T.check(hasNick, "a nickname the destination cannot write is refused, never truncated") +local g2long = F.copy(g2); g2long.ot = "ABCDEFGHIJ" +local long = convert(gold, emerald, g2long) +T.eq(long.blocks[1].code, "ot_unencodable", "an OT name longer than 7 is refused") + +local g2same = convert(gold, silver, g2) +T.check(g2same.ok, "Gen 2 cross-version (different learnset layout)") +T.eq(g2same.result.caughtLevel, 25, "same-gen keeps caught data") +accountedAll(g2same, g2, "Gold -> Silver") + +local g3 = { species = 25, level = 30, exp = 27000, personality = 0xABCD1234, otId = 77, otSecretId = 88, otName = "May", + nickname = "Volt", ivs = { hp = 31, atk = 20, def = 21, spe = 30, spa = 19, spd = 5 }, evs = { hp = 4, atk = 0, def = 0, spe = 252, spa = 252, spd = 0 }, + moves = { 84, 98, 345 }, pp = { 30, 30, 20 }, ppBonusesPacked = 0, heldItem = 202, friendship = 120, pokerus = 0, + metLocation = 16, metLevel = 5, metGame = 3, pokeball = 4, otGender = 1, language = 2, markings = 3, ribbons = 1, + contest = { cool = 10 }, abilityNum = 0, isEgg = false } +local r32 = convert(emerald, gold, g3) +T.eq(r32.blocks[1].code, "move_missing", "Magical Leaf does not exist in Gen 2") +T.check(r32.options.moves and r32.options.moves[3] and #r32.options.moves[3] > 0, "replacement moves offered") +for _, m in ipairs(r32.options.moves[3]) do + T.check(require("src.online.xgen.Datasets").learnable(gold, 25, m, 30), "offered replacement is legal for the species in the destination") +end +local r32b = convert(emerald, gold, g3, { moves = { [3] = 0 } }) +T.check(r32b.ok, "removing the move allows the trade") +local d = r32b.result.dvs +T.eq(Project.shinyDv(d), Project.shiny3(g3.personality, 77, 88), "shininess kept in DVs") +T.eq(Project.genderDv(127, d), Project.gender3(127, g3.personality), "gender kept in DVs") +T.eq(r32b.result.item, "LIGHT_BALL", "Light Ball kept by name") +T.eq(r32b.result.happiness, 120, "friendship kept") +accountedAll(r32b, g3, "Gen 3 -> Gen 2") +local lostRibbons = false +for _, c in ipairs(r32b.changes) do if c.field == "ribbons" and c.kind == "loss" then lostRibbons = true end end +T.check(lostRibbons, "ribbons reported lost") +local illegal = convert(emerald, gold, g3, { moves = { [3] = 57 } }) +T.eq(illegal.blocks[1].code, "replacement_not_legal", "staged replacement must be legal for the species (no free editor)") + +local cmp = Compat.report({ op = "trade", source = { data = emerald }, target = { data = gold }, mon = g3, adjustments = { moves = { [3] = 0 } } }) +T.check(cmp.ok and cmp.canonical == r32b.canonical, "Compat.report op=trade matches TradeConvert") + +local real, list = F.allReal() +local pick = { [1] = nil, [2] = nil, [3] = nil } +for _, v in ipairs(list) do + local g = real[v].generation + if not pick[g] then pick[g] = v end +end +local function sampleFor(data) + local n = 25 + local sp = data.species[n] + local moves = {} + for _, row in ipairs(sp.levelMoves) do + if row.level <= 20 and #moves < 2 and row.move <= 165 then moves[#moves + 1] = row.move end + end + if data.generation == 3 then + return { species = sp.localKey, level = 20, exp = sp.exp[20], personality = 0x00C0FFEE, otId = 321, otSecretId = 654, + otName = "ASH", nickname = "PIKA", ivs = { hp = 10, atk = 11, def = 12, spe = 13, spa = 14, spd = 15 }, + evs = { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 }, moves = moves, friendship = 90, heldItem = 0, + metLocation = 1, metLevel = 3, metGame = 4, pokeball = 4, otGender = 0, language = 2, ribbons = 0, contest = {} } + end + local list2 = {} + for _, m in ipairs(moves) do list2[#list2 + 1] = { id = data.moves[m].localKey, pp = 1, ppUps = 0 } end + local rec = { species = sp.localKey, level = 20, nickname = "PIKA", ot = "ASH", otId = 321, + dvs = { attack = 9, defense = 8, speed = 7, special = 6 }, statExp = { hp = 1, attack = 4, defense = 9, speed = 16, special = 25 }, + moves = list2 } + if data.generation == 2 then rec.experience = sp.exp[20]; rec.happiness = 80 else rec.exp = sp.exp[20] end + return rec +end +local combos = 0 +for gs = 1, 3 do + for gd = 1, 3 do + local sv, dv = pick[gs], pick[gd] + if sv and dv then + local s, dd = real[sv], real[dv] + local rec = sampleFor(s) + local rep = convert(s, dd, rec) + if gs == gd and sv == dv then + local other + for _, v in ipairs(list) do if v ~= sv and real[v].generation == gs then other = v break end end + if other then dv, dd = other, real[other]; rep = convert(s, dd, rec) end + end + T.check(rep.ok, ("%s -> %s converts a level 20 Pikachu (%s)"):format(sv, dv, rep.blocks[1] and rep.blocks[1].code or "ok")) + if rep.ok then + combos = combos + 1 + T.eq(rep.canonical, convert(s, dd, rec).canonical, sv .. " -> " .. dv .. " deterministic") + accountedAll(rep, rec, sv .. " -> " .. dv) + local view = Project.read(rep.result, dd) + T.check(view ~= nil and view.national == 25, sv .. " -> " .. dv .. " result reads back as Pikachu in the destination") + end + end + end +end +if combos == 0 then print("[skip] xgen trade: no imported caches for the 9 combos") end +local cross = { { "red", "yellow" }, { "gold", "crystal" }, { "firered", "emerald" }, { "ruby", "firered" }, { "emerald", "ruby" } } +for _, pair in ipairs(cross) do + local s, dd = real[pair[1]], real[pair[2]] + if s and dd then + local rec = sampleFor(s) + local rep = convert(s, dd, rec) + T.check(rep.ok, pair[1] .. " -> " .. pair[2] .. " same-gen cross-version") + if rep.ok then accountedAll(rep, rec, pair[1] .. " -> " .. pair[2]) end + end +end + +for _, rep in ipairs({ r21, bow, nick, long, r32, illegal }) do + for _, b in ipairs(rep.blocks) do T.check(Messages.known(b.code), "message exists for " .. b.code) end +end + +T.finish("xgen_trade") diff --git a/tests/engine/yellow_pokecenter_chansey.lua b/tests/engine/yellow_pokecenter_chansey.lua new file mode 100644 index 00000000..23ebf10f --- /dev/null +++ b/tests/engine/yellow_pokecenter_chansey.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local previous = GameVersion.get() +GameVersion.set("yellow") +require("data.scripts") +local MapScripts = require("src.script.MapScripts") + +local function isChansey(script) + if type(script) ~= "table" then return false end + local cry, text + for _, row in ipairs(script) do + if row[1] == "play_cry" then cry = row[2] end + if row[1] == "show_text" then text = row[2] end + end + return cry == "CHANSEY" and text == "_NurseChanseyText" +end + +check(isChansey(MapScripts.talkScript("VIRIDIAN_POKECENTER", "TEXT_VIRIDIANPOKECENTER_CHANSEY")), + "Viridian's Chansey runs PokecenterChanseyText") +check(isChansey(MapScripts.talkScript("INDIGO_PLATEAU_LOBBY", "TEXT_INDIGOPLATEAULOBBY_CHANSEY")), + "the Indigo lobby Chansey runs PokecenterChanseyText") + +local function cacheFile(name) + local home = os.getenv("HOME") + if not home or home == "" then return nil end + local ids = { os.getenv("POKEPORT_IDENTITY"), "g1r-yellow", "pokeport-test-caches" } + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for i = 1, 3 do + local id = ids[i] + if id and id ~= "" then + local chunk = loadfile(base .. "/" .. id .. "/yellow/data/generated/" .. name .. ".lua") + local ok, mod = pcall(chunk or error) + if ok and type(mod) == "table" then return mod end + end + end + end + return nil +end + +local maps, pointers, text = cacheFile("maps"), cacheFile("text_pointers"), cacheFile("text") +if maps and pointers and text then + check(type(text._NurseChanseyText) == "string", "the Yellow cache carries _NurseChanseyText") + local found = 0 + for id, def in pairs(maps) do + for _, o in ipairs(type(def) == "table" and def.objects or {}) do + local entry = pointers[def.label] and pointers[def.label][o.text] + if o.sprite == "SPRITE_CHANSEY" and entry and entry.asm and not entry.text then + found = found + 1 + check(isChansey(MapScripts.talkScript(id, o.text)), id .. " " .. o.text .. " answers") + end + end + end + eq(found, 12, "twelve Yellow centers call PokecenterChanseyText") +else + print("[skip] yellow cache checks: no Yellow cache") +end + +GameVersion.set(previous) +T.finish("yellow_pokecenter_chansey") diff --git a/tests/game3_link_relay_union_test.lua b/tests/game3_link_relay_union_test.lua index bb7009f3..902c7f67 100644 --- a/tests/game3_link_relay_union_test.lua +++ b/tests/game3_link_relay_union_test.lua @@ -169,7 +169,7 @@ end local function ascii(key) return RomText.ascii(key) end local function member(id, slot, name, trainerId, gender) return { id = id, name = name, slot = slot, online = true, status = "idle", - avatar = { name = name, trainerId = trainerId, gender = gender, version = "firered" } } + avatar = { name = name, trainerId = trainerId, gender = gender, version = "firered", gen = 3 } } end print("[test] 1. RunUnionRoom joins the union plaza when the adapter is connected") diff --git a/tests/game3_union_rs_test.lua b/tests/game3_union_rs_test.lua new file mode 100644 index 00000000..a846d980 --- /dev/null +++ b/tests/game3_union_rs_test.lua @@ -0,0 +1,155 @@ +#!/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 = { version = "ruby", map = "RU_OLDALE_TOWN_POKEMON_CENTER_2F", x = 2, y = 2, party = {} } +package.loaded["src.core.game3.runtime"] = { getSession = function() return session end, isActive = function() return true end } + +local Plaza = require("src.core.game3.link.union_plaza_map") +local UnionRs = require("src.core.game3.rse.union_rs") +local Union = require("src.core.game3.link.union_room") + +print("[test] RS map ids") +eq(Plaza.KIND, "rs", "Ruby builds the RS room") +eq(Plaza.SOURCE_ID, nil, "Ruby has no cart Union Room to redirect") +eq(Plaza.TILE_SOURCE_ID, "RU_RECORD_CORNER", "tiles come from Ruby's own Record Corner") +eq(Plaza.MAP_ID, "RU_UNION_ROOM", "the added room id") +check(Union.isUnionMap("RU_UNION_ROOM"), "the RS room is a Union Room map") +check(not Union.isUnionMap("RU_RECORD_CORNER"), "the Record Corner is not") + +print("[test] RS role grid") +local roles, source, blocked = Plaza.tables("rs") +eq(#roles, 25, "25 rows") +local bad +for y, row in ipairs(roles) do + if #row ~= 25 then bad = y end + for i = 1, #row do if not source[row:sub(i, i)] then bad = y end end +end +check(bad == nil, "every row is 25 wide with known roles: " .. tostring(bad)) +for slot = 1, Plaza.CAP do + local x, y = Plaza.cellFor(slot) + local r = Plaza.roleAt(x, y, "rs") + if r ~= "." then check(false, "slot " .. slot .. " is on floor") end +end +local ex, ey = Plaza.entry() +eq(Plaza.roleAt(ex, ey, "rs"), "p", "entry is an exit pad") +eq(#Plaza.EXITS, 3, "three exit pads") +local function key(x, y) return y * 64 + x end +local cells = {} +for _, c in ipairs(Plaza.CELLS) do cells[key(c.x, c.y)] = true end +local function walk(x, y) + local r = Plaza.roleAt(x, y, "rs") + return r ~= nil and not blocked[r] and not cells[key(x, y)] +end +local reach, queue, head = { [key(ex, ey)] = true }, { { ex, ey } }, 1 +while queue[head] do + local p = queue[head] + head = head + 1 + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = p[1] + d[1], p[2] + d[2] + if walk(nx, ny) and not reach[key(nx, ny)] then + reach[key(nx, ny)] = true + queue[#queue + 1] = { nx, ny } + end + end +end +local sides = true +for _, c in ipairs(Plaza.CELLS) do + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if not reach[key(c.x + d[1], c.y + d[2])] then sides = false end + end +end +check(sides, "every member cell is reachable from all four sides with the room full") + +print("[test] 2F discovery and the added door") +local LayoutNative = require("src.core.game3.layout_native") +local function grid(w, h, fill) + local cells2 = {} + for i = 1, w * h do cells2[i] = { mid = fill, coll = 0, elev = 3 } end + return cells2 +end +local twoF = grid(14, 9, 0x202) +twoF[0 * 14 + 5 + 1] = { mid = 0x25c, coll = 1, elev = 0 } +twoF[1 * 14 + 5 + 1] = { mid = 0x264, coll = 1, elev = 0 } +twoF[1 * 14 + 2 + 1] = { mid = 0x004, coll = 1, elev = 0 } +local maps = { + RU_OLDALE_TOWN_POKEMON_CENTER_2F = { + warps = { { x = 1, y = 5, destMap = "RU_OLDALE_TOWN_POKEMON_CENTER_1F", destWarp = 3 }, + { x = 5, y = 1, destMap = "RU_SINGLE_BATTLE_COLOSSEUM", destWarp = 1 } }, + objects = { { x = 4, y = 2, graphicsId = 85 } }, + midLayout = LayoutNative.fromDecoded({ width = 14, height = 9, cells = twoF }, "RU_OLDALE_TOWN_POKEMON_CENTER_2F", "p"), + }, + RU_OLDALE_TOWN_POKEMON_CENTER_1F = { + warps = { { x = 1, y = 6, destMap = "RU_OLDALE_TOWN_POKEMON_CENTER_2F", destWarp = 1 } }, + objects = { { x = 7, y = 2, graphicsId = 58 } }, + midLayout = LayoutNative.fromDecoded({ width = 14, height = 9, cells = grid(14, 9, 0x202) }, "RU_OLDALE_TOWN_POKEMON_CENTER_1F", "p"), + }, + RU_SINGLE_BATTLE_COLOSSEUM = { warps = {} }, + RU_TRADE_CENTER = { warps = { { x = 5, y = 8, destMap = "RU_OLDALE_TOWN_POKEMON_CENTER_2F" } } }, +} +maps.RU_OLDALE_TOWN_POKEMON_CENTER_1F.midLayout.cells[3 * 14 + 7 + 1].coll = 1 +local found = UnionRs.discover(maps) +check(found.RU_OLDALE_TOWN_POKEMON_CENTER_2F ~= nil, "the 2F with a Colosseum bay is found") +eq(found.RU_OLDALE_TOWN_POKEMON_CENTER_1F, nil, "the 1F is not") +eq(found.RU_OLDALE_TOWN_POKEMON_CENTER_2F and found.RU_OLDALE_TOWN_POKEMON_CENTER_2F.oneF, + "RU_OLDALE_TOWN_POKEMON_CENTER_1F", "its origin is the 1F the stairs lead to") +local def = maps.RU_OLDALE_TOWN_POKEMON_CENTER_2F +local before = def.midLayout +UnionRs.patchLayout(def, "RU_OLDALE_TOWN_POKEMON_CENTER_2F") +check(def.midLayout ~= before, "the patch builds a fresh layout copy") +eq(before:midAt(2, 1), 0x004, "the cached layout is untouched") +eq(def.midLayout:midAt(2, 1), 0x264, "the added door copies the bay door") +eq(def.midLayout:midAt(2, 0), 0x25c, "with the wall above it") +eq(def.midLayout:midAt(4, 4), 0x202, "the rest of the floor is the same") +local again = def.midLayout +UnionRs.patchLayout(def, "RU_OLDALE_TOWN_POKEMON_CENTER_2F") +eq(def.midLayout, again, "patching twice is a no-op") +UnionRs._game = { data = { maps = maps } } +UnionRs.centers = found +eq(UnionRs.attendantGfx("RU_OLDALE_TOWN_POKEMON_CENTER_2F"), 85, "the attendant wears the bay attendant's graphics") +local o = UnionRs.originFor("RU_OLDALE_TOWN_POKEMON_CENTER_2F") +check(o and o.map == "RU_OLDALE_TOWN_POKEMON_CENTER_1F" and o.x == 7 and o.y == 4 and o.facing == "up", + "the origin is the cell in front of the 1F nurse desk") +eq(UnionRs.countMons({ party = { { species = 1 }, { species = 412, isEgg = true }, { species = 4 } } }), 2, + "eggs do not count toward entry") + +print("[test] saves in the added room land in front of the nurse") +local Rules = require("src.core.game3.rse.union_rs_rules") +local Origin = require("src.online.union.Origin") +local s = { version = "ruby", map = "RU_UNION_ROOM", x = 12, y = 20, specialSaveWarpFlags = 0 } +Origin.record(s, { gen = 3, version = "ruby", map = o.map, x = o.x, y = o.y, facing = "up" }) +local flags, warp = Rules.saveWarpFields(s) +eq(flags, 1, "a save in the room sets the continue warp") +check(warp and warp.map == o.map and warp.x == 7 and warp.y == 4, "pointing at the nurse front") +local m, x, y, f = Rules.saveLocation(s) +check(m == o.map and x == 7 and y == 4 and f == "up", "the saved location is the nurse front") +local s2 = { version = "ruby", map = "RU_OLDALE_TOWN", x = 5, y = 5, specialSaveWarpFlags = 0 } +local f2 = Rules.saveWarpFields(s2) +eq(f2, 0, "saves elsewhere keep the cart rules") +eq(Rules.saveLocation(s2), nil, "and their own location") +local loaded = { version = "ruby", map = "RU_UNION_ROOM", x = 12, y = 20, healMap = o.map } +Rules.useContinueGameWarp(loaded) +check(loaded.map == o.map and loaded.x == 7 and loaded.y == 4 and loaded.facing == "up", + "a save inside the room without a continue warp still loads at the nurse front") +local cont = { version = "ruby", map = "RU_UNION_ROOM", x = 12, y = 20, specialSaveWarpFlags = 1, + continueGameWarp = { map = o.map, x = 7, y = 4 } } +Rules.useContinueGameWarp(cont) +check(cont.map == o.map and cont.x == 7 and cont.y == 4 and cont.facing == "up", "the continue warp path faces the desk") + +if failed > 0 then + print(("[FAIL] %d check(s) failed"):format(failed)) + os.exit(1) +end +print("[PASS] union rs") diff --git a/tests/game3_union_xgen_test.lua b/tests/game3_union_xgen_test.lua new file mode 100644 index 00000000..b97702ee --- /dev/null +++ b/tests/game3_union_xgen_test.lua @@ -0,0 +1,287 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.fixture_data.game3_items").install() + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end +local function eq(a, b, msg) + check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) +end + +local FakeRelay = require("tests.g3link_fake_relay") +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_MAP = Plaza.MAP_ID + +local store = { flags = {}, vars = {} } +local session = { + store = store, map = UNION_MAP, x = 12, y = 24, name = "LEAF", gender = 1, trainerId = 0x1235, + party = { { species = 1, level = 12 }, { species = 4, level = 9 } }, + bag = { pockets = { items = {} } }, version = "firered", +} +local input = { pressed = {} } +function input:wasPressed(k) return self.pressed[k] == true end +local game = { data = { maps = { [UNION_MAP] = { warps = {} } } }, session = session, input = input, + save = { player = { name = "LEAF" }, options = {} } } +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + _game = game, +} +local Player = { cellX = 12, cellY = 23, facing = "down" } +package.loaded["src.core.game3.player"] = Player +package.loaded["src.core.game3.map"] = { load = function() end } +package.loaded["src.core.game3.objects"] = { + addObject = function() return true end, removeObject = function() return true end, + find = function() return nil end, refreshGraphics = function() return 0 end, +} +local VirtualObjects = require("src.core.game3.virtual_objects") + +local romBundle = require("tests.game3_cache").bundle() +if not romBundle then + package.loaded["src.core.game3.rom_text"] = { + plain = function(key) return key end, box = function(key) return key end, + ascii = function(key) return key end, has = function() return true end, + ir = function(key) return { { t = "text", s = key } } end, + key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, + at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, + count = function() return 0 end, list = function() return {} end, + lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, + } +end + +local ctx = { specialVars = {}, stringVars = {} } +local adapters = { log = function() end, playSe = function() end } +package.loaded["src.core.game3.scripting.space"] = { + store = store, mapId = UNION_MAP, vm = { ctx = ctx, adapters = adapters }, + ensureBundle = function() return romBundle end, +} + +local LIVE = { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla", version = "firered" } +package.loaded["src.online.ArenaData"] = { + liveProfile3 = function(_, rulesetId) + local p = {} + for k, v in pairs(LIVE) do p[k] = v end + p.rulesetId = rulesetId + return p + end, + onlineBlockers3 = function() return {} end, +} +local Client = FakeRelay.client({ state = "online", id = "0000beef" }) +Client._profiles = { LIVE } +local joinOpts +function Client.joinPlaza(kind, profile, avatar, cap, opts) + Client.calls[#Client.calls + 1] = { name = "joinPlaza", args = { kind, profile, avatar, cap, opts } } + joinOpts = opts +end +package.loaded["src.online.Client"] = Client + +local Natives = require("src.core.game3.scripting.natives") +local NativesLink = require("src.core.game3.scripting.natives_link") +local Link = require("src.core.game3.link") +local Union = require("src.core.game3.link.union_room") +local Screen = require("src.ui.game3.union_room") +local Message = require("src.ui.game3.message") +local Choice = require("src.ui.game3.choice") +local Avatars = require("src.online.union.Avatars") +Union._avatars = FakeRelay.avatars() +Avatars.setReader(function() return nil end) + +local function run(n) + for _ = 1, n or 1 do + if Message.isOpen() and not Message.isWaiting() then Message.skipReveal() end + Message.tick() + Link.update(1 / 60) + end +end +local function page() return Message.isOpen() and Message.currentPage() or "" end +local function member(id, slot, name, version, gen, tid, gender, status) + return { id = id, name = name, slot = slot, online = true, status = status or "idle", + avatar = { name = name, trainerId = tid or 1, gender = gender or 0, version = version, gen = gen, + style = gen == 3 and ("g3:" .. ((tid or 1) % 8)) or "player" } } +end + +print("[test] 1. the Gen 3 room joins the cross-gen plaza") +Link.reset() +Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) +local join = Client.last("joinPlaza") +check(join ~= nil, "joinPlaza sent") +eq(joinOpts and joinOpts.xgen, require("src.online.Protocol2").XGEN, "with xgen") +check(joinOpts and type(joinOpts.caps) == "table" and joinOpts.caps.proto ~= nil, "and presence caps") +eq(join and join[3] and join[3].style, "g3:" .. (0x1235 % 8), "FRLG avatars send their class style") +eq(join and join[3] and join[3].version, "firered", "and the version") + +print("[test] 2. Gen 1 and Gen 2 members get a foreign avatar, Gen 3 keeps its class sprite") +Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 1, you = 3, members = { + member("aaaa0001", 1, "RED", "red", 1, 11), + member("aaaa0002", 2, "KRIS", "crystal", 2, 22, 1), + member("0000beef", 3, "LEAF", "firered", 3, 0x1235, 1), + member("aaaa0004", 4, "MAY", "emerald", 3, 44, 1), +} } +run(2) +eq(Union.playerCount(), 3, "three other members") +eq(Union.players[1].sourceGen, 1, "RED is Gen 1") +eq(Union.players[2].sourceGen, 2, "KRIS is Gen 2") +eq(Union.players[4].sourceGen, 3, "MAY is Gen 3") +local v1 = Union.vobj(1) +check(v1 and v1.foreign and v1.foreign.game == "red" and v1.gfx == nil and v1.foreign.host.version == "firered", + "RED draws through Avatars with this game as the stand-in host") +check(Union.vobj(4).foreign == nil and Union.vobj(4).gfx == Union.graphicsIdFor(1, 44), "MAY keeps her class graphics") +local realResolve = Avatars.resolve +Avatars.resolve = function(p) + if p.game == "crystal" then return { key = "k", layout = "gb", w = 16, h = 16, frames = 6, gen = 2 } end + return realResolve(p) +end +Union.hideAvatar(2) +run(Union.FLY_HEIGHT / Union.FLY_STEP + 2) +Client._plaza.rev = 2 +Union.players[2] = nil +run(2) +local v2 = Union.vobj(2) +check(v2 and v2.foreign and v2.foreign.game == "crystal" and v2.gfx == nil, "KRIS with Crystal imported draws her real sprite") +run(30) +local rec2 = VirtualObjects.get(Union.vobjId(2)) +check(rec2 and rec2.foreign and rec2.foreign.gen == 2 and rec2.solid == true, "the foreign record blocks like any avatar") +Avatars.resolve = realResolve +eq(Union._serverOutdated, nil, "a new server does not trip the update notice") + +print("[test] 3. talking to a Gen 1 member names the import and offers BATTLE / TRADE") +local cx, cy = Plaza.cellFor(1) +Player.cellX, Player.cellY, Player.facing = cx, cy + 1, "up" +input.pressed.a = true +run(1) +input.pressed.a = false +eq(Union.vobj(1).dir, Union.DIR.SOUTH, "RED turns toward the player") +local sawStandin = false +for _ = 1, 400 do + if page():find("RED, BLUE or YELLOW", 1, true) then sawStandin = true end + if Screen.isOpen() then break end + if Message.isOpen() and Message.isWaiting() and not Message._stay then Message.advance() end + run(1) +end +check(sawStandin, "the stand-in line names the Gen 1 imports") +check(Screen.isOpen(), "the Gen 3 activity menu opens") +eq(#Screen.items, 3, "three entries") +eq(Screen.items[1].wire, "xg_battle", "BATTLE sends a cross-gen battle invite") +eq(Screen.items[2].wire, "xg_trade", "TRADE sends a cross-gen trade invite") +Screen.confirm() +local inv = Client.last("invite") +eq(inv and inv[1], "aaaa0001", "the invite goes to RED") +eq(inv and inv[2], "xg_battle", "as xg_battle") +eq(Union.state, "send_activity_request", "waiting for the answer") + +print("[test] 4. an accepted invite opens prep through beginXg, and no screen means a clean cancel") +local sent = {} +Union.xgInstalled = true +Union.xgScreens.battle, Union.xgScreens.trade = nil, nil +local snapshot = { mode = "battle", rev = 1, rosters = {}, sizeReq = {}, offers = {}, ready = {}, caps = {} } +local xgRoom = { room = "r1", intent = "xg", mode = "battle", stage = "prep", xg = snapshot, + players = { { seat = 0, id = "0000beef", gen = 3, avatar = { name = "LEAF", version = "firered" } }, + { seat = 1, id = "aaaa0001", gen = 1, avatar = { name = "RED", version = "red" } } } } +local rs = { closed = false, left = false } +function rs:send(msg) sent[#sent + 1] = msg end +function rs:takeWhere() return nil end +function rs:close() self.left = true end +Client.room = function() return xgRoom end +Client.roomSession = function() return rs end +Client.seat = function() return 0 end +Client.handles[#Client.handles].state = "accepted" +Client.handles[#Client.handles].room = "r1" +run(2) +local act = Union._xgSession +check(act ~= nil and act.prep ~= nil, "beginXg built the activity with a prep session") +eq(act and act.mode, "battle", "battle mode") +eq(act and act.peer and act.peer.name, "RED", "the peer comes from the room") +eq(act and act.peer and act.peer.gen, 1, "with its generation") +check(page():find("Getting ready to battle RED", 1, true) ~= nil, "a preparing line shows") +for _ = 1, 120 do run(1) end +eq(sent[#sent] and sent[#sent].type, "xg_cancel", "the prep is canceled when no screen exists") +check(rs.left, "and the room is left") +check(act.done and act.why == "unavailable", "the activity ends as unavailable") +for _ = 1, 400 do + if not Message.isOpen() and Union.state == "main" then break end + if Message.isOpen() and Message.isWaiting() then Message.advance() end + run(1) +end +eq(Union.state, "main", "back to the room") + +print("[test] 5. a registered screen is called once prep is live") +local called = 0 +Union.xgScreens.battle = function(a) + called = called + 1 + a:finish("done") +end +sent, rs.left = {}, false +xgRoom.room = "r2" +local act2 = Union.beginXg(xgRoom, "battle") +act2.prep.state = "prep" +act2.prep.rules = { ruleset = "g3u" } +for _ = 1, 10 do run(1) end +eq(called, 1, "the battle screen ran once") +check(act2.done and act2.why == "done", "the screen finished the activity") +Union.xgScreens.battle = nil + +print("[test] 6. an incoming cross-gen trade asks in Gen 3 style and accepts") +Union.toMain() +xgRoom.room = "r3" +Client._invites = { { id = "inv9", activity = "xg_trade", from = { id = "aaaa0002", name = "KRIS", + avatar = { name = "KRIS", version = "crystal", gen = 2 } } } } +run(2) +local asked = false +for _ = 1, 400 do + if page():find("KRIS from POKéMON CRYSTAL", 1, true) then asked = true end + if Choice.isOpen() then break end + if Message.isOpen() and Message.isWaiting() and (Message._page or 1) < #(Message._pages or {}) then + Message.advance() + end + run(1) +end +check(asked, "the trade request names KRIS and CRYSTAL") +check(Choice.isOpen(), "YES/NO is offered") +Choice.cursor = 1 +Choice.confirm() +local reply = Client.last("replyInvite") +eq(reply and reply[1], "inv9", "the invite is answered") +eq(reply and reply[2], true, "with yes") +run(3) +check(Union._xgSession ~= nil and Union._xgSession.mode == "trade", "prep opens in trade mode") +Union.endXg(nil, "test") + +print("[test] 7. a legacy shard row trips the server update notice") +Union.toMain() +Client._plaza = { kind = "union", instance = 2, cap = 40, rev = 9, you = 3, members = { + { id = "0000beef", name = "LEAF", slot = 3, online = true, status = "idle", + avatar = { name = "LEAF", trainerId = 1, gender = 1, version = "firered" } }, +} } +Union._upgradeShown = nil +run(3) +eq(Union._serverOutdated, "legacy_shard", "an own row without a gen means the server is old") +check(page():find("UNION ROOM server", 1, true) ~= nil, "the update notice shows: " .. page()) + +print("[test] 8. badges ride on the name tags") +local LinkTags = require("src.ui.game3.link_tags") +local plain = LinkTags.layout({ name = "RED" }, 100, 50, 240, function() return 18 end, {}) +local tagged = LinkTags.layout({ name = "RED", badge = 1 }, 100, 50, 240, function() return 18 end, {}) +eq(tagged.w - plain.w, require("src.online.union.Badge").SIZE + LinkTags.BADGE_GAP, "a badge widens the plate") +check(tagged.badgeX and tagged.badgeX >= tagged.textX + 18, "the badge sits after the name") + +local Badge = require("src.online.union.Badge") +local near = LinkTags.unionLayout({ name = "RED", badge = 1, near = true }, 100, 50, 240, function() return 18 end, {}) +check(near.plate and near.badgeX + Badge.SIZE == near.x + near.w - 1, "a nearby member's badge sits at the plate's right edge") +check(near.y + near.h < 50, "the plate stays clear of the head") +check(near.x == math.floor(near.x) and near.badgeY == math.floor(near.badgeY), "integer pixel positions") +local far = LinkTags.unionLayout({ name = "RED", badge = 1, near = false }, 100, 50, 240, function() return 18 end, {}) +check(not far.plate and far.w == Badge.SIZE, "a distant member shows only the badge") + +pcall(Link.reset) +if failed > 0 then + print(("[FAIL] %d check(s) failed"):format(failed)) + os.exit(1) +end +print("[PASS] union xgen") diff --git a/tests/support/fake_relay.lua b/tests/support/fake_relay.lua index de0c91aa..02af4a99 100644 --- a/tests/support/fake_relay.lua +++ b/tests/support/fake_relay.lua @@ -45,6 +45,7 @@ function FakeRelay.new(opts) queue = {}, directWatch = {}, minProtocol = opts.minProtocol or 3, + legacy = opts.legacy == true, epoch = opts.epoch or 1000, log = {}, }, FakeRelay) @@ -151,9 +152,14 @@ function FakeRelay:roomStateMsg(room) local players, spectators = {}, {} for _, p in ipairs(room.players) do local s = self.sessions[p.id] - players[#players + 1] = { id = p.id, name = p.name, verified = true, - ready = false, online = s and s.online or false, - seat = p.seat } + local row = { id = p.id, name = p.name, verified = true, + ready = false, online = s and s.online or false, + seat = p.seat } + if room.xg then + row.gen = room.xg.gens[p.seat + 1] + row.avatar = s and copy(s.avatar) or nil + end + players[#players + 1] = row end for _, sp in ipairs(room.spectators) do spectators[#spectators + 1] = { id = sp.id, name = sp.name, verified = true, @@ -165,7 +171,9 @@ function FakeRelay:roomStateMsg(room) origin = room.origin, players = players, spectators = spectators, stage = room.stage, host = room.host, seed = room.seed, match = room.match, maxSpectators = room.maxSpectators, - leader = room.leader, deadlines = {} } + leader = room.leader, deadlines = {}, + mode = room.xg and room.xg.mode or nil, + xg = room.xg and self:xgSnapshot(room) or nil } end function FakeRelay:roomBroadcast(room, msg) @@ -245,6 +253,7 @@ function FakeRelay:fanout(s, clientSeq, inner) if not room or type(inner) ~= "table" then return end local seat = self:seatOf(room, s.id) if seat == nil then return end + if room.xg and self:xgRoomMsg(room, s, seat, clientSeq, inner) then return end if RELAY_ONLY[inner.type] then return end if inner.type == "game3_mg_state" and room.leader ~= seat then return end room.clientSeq = room.clientSeq or {} @@ -375,11 +384,54 @@ function FakeRelay:handleRoomJoin(s, msg) end end +local XG_FAMILIES = { + [1] = { red = true, blue = true, yellow = true }, + [2] = { gold = true, silver = true, crystal = true }, + [3] = { firered = true, leafgreen = true, emerald = true, ruby = true, sapphire = true }, +} +FakeRelay.XG_FAMILIES = XG_FAMILIES +local XG_DEX = { [1] = { 151, 165 }, [2] = { 251, 251 }, [3] = { 386, 354 } } +local XG_MODE = { xg_battle = "battle", xg_trade = "trade" } +local XG_STATUS = { battle = "battling", trade = "trading" } + local function memberOf(s, slot) - return { id = s.id, name = s.name, verified = true, slot = slot, - online = s.online, status = s.status or (s.recruiting and "recruiting" or "idle"), - avatar = copy(s.avatar), recruiting = copy(s.recruiting), - board = copy(s.presence.board), group = copy(s.group) } + local status = s.status or (s.recruiting and "recruiting" or "idle") + if s.xgen then status = s.xgStatus or s.presence.status or status end + local out = { id = s.id, name = s.name, verified = true, slot = slot, + online = s.online, status = status, + avatar = copy(s.avatar), recruiting = copy(s.recruiting), + board = copy(s.presence.board), group = copy(s.group) } + if s.xgen then out.caps = copy(s.caps) end + return out +end + +local function xgAvatar(raw, gen) + if type(raw) ~= "table" or type(raw.version) ~= "string" or not XG_FAMILIES[gen][raw.version] then + return nil + end + local style = raw.style + if style == nil then style = "player" end + if type(style) ~= "string" or #style > 16 or not style:match("^[a-z0-9:_%-]+$") then return nil end + local name = tostring(raw.name or "") + return { name = name:sub(1, 10), trainerId = math.floor(tonumber(raw.trainerId) or 0) % 65536, + gender = raw.gender == 1 and 1 or 0, version = raw.version, style = style, gen = gen } +end + +local function xgCaps(raw) + if type(raw) ~= "table" then return nil end + if type(raw.proto) ~= "number" or type(raw.policy) ~= "number" then return nil end + local gens = {} + for k, list in pairs(raw.gens or {}) do + local gen = tonumber(k) + if not XG_FAMILIES[gen] or type(list) ~= "table" then return nil end + local out = {} + for _, e in ipairs(list) do + if type(e) ~= "table" or not XG_FAMILIES[gen][e.version] or type(e.fp) ~= "string" then return nil end + out[#out + 1] = { version = e.version, fp = e.fp:lower() } + end + gens[tostring(gen)] = out + end + return { proto = raw.proto, policy = raw.policy, gens = gens } end function FakeRelay:plazaInstance(s) @@ -418,29 +470,63 @@ function FakeRelay:plazaJoin(s, msg) self.wireless[s.id] = true return end - if type(msg.profile) ~= "table" or msg.profile.engine ~= 3 then - return self:joinError(s, "bad_profile") + local xgen = not self.legacy and (msg.xgen == 1 or msg.xgen == true) + if xgen then + if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then + self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil, + text = "This build is too old for online play. Please update." }) + s.transport.closed = true + return + end + local p = msg.profile + local engine = type(p) == "table" and tonumber(p.engine) or nil + if not XG_FAMILIES[engine] or not XG_FAMILIES[engine][p.version] + or (engine ~= 3 and p.rulesetId ~= "union") then + return self:joinError(s, "bad_profile") + end + local av = xgAvatar(msg.avatar, engine) + if not av then return self:joinError(s, "bad_avatar") end + if msg.caps ~= nil then + local caps = xgCaps(msg.caps) + if not caps then return self:joinError(s, "bad_caps") end + s.caps = caps + end + s.avatar = av + s.xgen = true + s.plazaProfile = copy(p) + else + if type(msg.profile) ~= "table" or msg.profile.engine ~= 3 then + return self:joinError(s, "bad_profile") + end + if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then + self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil, + text = "This build is too old for online play. Please update." }) + s.transport.closed = true + return + end + s.avatar = copy(msg.avatar) + if self.legacy and s.avatar then s.avatar.style, s.avatar.gen = nil, nil end + s.xgen = false + s.plazaProfile = copy(msg.profile) end - if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then - self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil, - text = "This build is too old for online play. Please update." }) - s.transport.closed = true - return - end - s.avatar = copy(msg.avatar) + local shard = xgen and "u" or "g3" s.where = "union" + local current = self:plazaInstance(s) + if current and current.shard ~= shard then self:plazaLeave(s, "union") end if self:plazaInstance(s) then return self:to(s, self:plazaStateMsg(s)) end local best, bestIndex for i, inst in ipairs(self.plazas.union) do local n = 0 for _ in pairs(inst.slots) do n = n + 1 end - if n < FakeRelay.PLAZA_CAP and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end + if inst.shard == shard and n < FakeRelay.PLAZA_CAP and (not best or n > best.n) then + best, bestIndex = { inst = inst, n = n }, i + end end local inst, index if best then inst, index = best.inst, bestIndex else - inst = { slots = {}, rev = 0 } + inst = { slots = {}, rev = 0, shard = shard } self.plazas.union[#self.plazas.union + 1] = inst index = #self.plazas.union end @@ -507,6 +593,7 @@ function FakeRelay:acceptInvite(inv) end return end + if XG_MODE[inv.activity or ""] then return self:xgAccept(inv, from, to) end local profile = copy(inv.profile or profileOf(from, 3)) local room = self:newRoom({ intent = Protocol2.ACTIVITY_INTENT[inv.activity] or "battle", engine = profile and profile.engine or 3, @@ -518,6 +605,8 @@ function FakeRelay:acceptInvite(inv) if room.engine == 3 then self:startRoom(room) else self:roomState(room) end end +local INVITABLE = { idle = true, recruiting = true, waiting = true } + function FakeRelay:handleInvite(s, msg) local refuse = function(why) self:to(s, { type = "invite_closed", why = why, to = msg.to, @@ -526,7 +615,16 @@ function FakeRelay:handleInvite(s, msg) if msg.to == s.id then return refuse("self") end local target = self.sessions[msg.to or ""] if not target or not target.online then return refuse("offline") end - if target.room or target.presence.where == "game" then return refuse("busy") end + if XG_MODE[msg.activity or ""] then + if self.legacy then return refuse("bad_activity") end + local a, b = self:plazaInstance(s), self:plazaInstance(target) + if not (s.xgen and target.xgen and a and a == b) then return refuse("not_plaza") end + if s.room then return refuse("busy") end + local status = target.xgStatus or target.presence.status or "idle" + if target.room or not INVITABLE[status] then return refuse("busy") end + elseif target.room or target.presence.where == "game" then + return refuse("busy") + end for _, inv in pairs(self.invites) do if inv.from == target.id and inv.to == s.id and inv.activity == msg.activity then self.inviteNo = self.inviteNo + 1 @@ -819,6 +917,306 @@ function FakeRelay:handleDirect(s, msg) end end +function FakeRelay:xgSnapshot(room) + local x = room.xg + local rosters, offers = {}, {} + for i = 1, 2 do + local r, o = x.rosters[i], x.offers[i] + rosters[i] = r and { size = r.size, digest16 = r.digest16 } or nil + offers[i] = o and { offerRev = o.offerRev, digest16 = o.digest16 } or nil + end + return { mode = x.mode, rev = x.rev, gens = copy(x.gens), rules = copy(x.rules), + blocked = x.blocked, size = x.size, caps = { x.caps[1] ~= nil, x.caps[2] ~= nil }, + rosters = rosters, sizeReq = { x.sizeReq[1], x.sizeReq[2] }, offers = offers, + ready = { x.ready[1] ~= nil, x.ready[2] ~= nil } } +end + +function FakeRelay:xgAccept(inv, from, to) + local a, b = self:plazaInstance(from), self:plazaInstance(to) + if not (from.xgen and to.xgen and a and a == b) then return self:closeInvite(inv, "not_plaza") end + if from.room or to.room then return self:closeInvite(inv, "busy") end + local pf, pt = from.plazaProfile, to.plazaProfile + local room = self:newRoom({ intent = "xg", engine = pf.engine, profile = pf, seats = 2, + listed = false, origin = "invite" }) + room.stage = "prep" + room.maxSpectators = 0 + room.activity = inv.activity + room.xg = { mode = XG_MODE[inv.activity], gens = { pf.engine, pt.engine }, + fps = { pf.fingerprint, pt.fingerprint }, rev = 0, + caps = { copy(from.caps), copy(to.caps) }, rules = nil, rulesSig = nil, + blocked = nil, counter = nil, rosters = {}, sizeReq = {}, size = nil, + offers = {}, ready = {}, agreed = nil, closed = false } + self:closeInvite(inv, "accepted", room.room) + self:seatPlayer(room, from) + self:seatPlayer(room, to) + room.host = from.id + for _, who in ipairs({ from, to }) do + who.xgStatus = XG_STATUS[room.xg.mode] + self:plazaChanged(who) + end + self:roomState(room) + self:xgResolve(room, false) + return room +end + +local function rulesSig(r) + if not r then return "" end + if r.blocked then return "blocked:" .. r.blocked end + local t = r.rules + return table.concat({ t.mode, tostring(t.ruleset), tostring(t.gen), tostring(t.dexMax), + tostring(t.moveGen), tostring(t.policy) }, "|") +end + +function FakeRelay:xgRulesFor(room) + local x = room.xg + local c0, c1 = x.caps[1], x.caps[2] + if not c0 or not c1 then return nil end + if c0.proto ~= c1.proto then return { blocked = "proto" } end + local gens = copy(x.gens) + if x.mode == "trade" then + if c0.policy ~= c1.policy then return { blocked = "policy_mismatch" } end + return { rules = { mode = "trade", gens = gens, policy = c0.policy } } + end + if gens[1] == gens[2] then + if gens[1] ~= 3 and x.fps[1] ~= x.fps[2] then return { blocked = "fingerprint" } end + return { rules = { mode = "battle", ruleset = "native", gen = gens[1], gens = gens } } + end + local low = math.min(gens[1], gens[2], x.counter or 3) + return { rules = { mode = "battle", ruleset = "g3u", dexMax = XG_DEX[low][1], + moveMax = XG_DEX[low][2], moveGen = low, gens = gens } } +end + +function FakeRelay:xgBump(room) + local x = room.xg + x.rev = x.rev + 1 + x.ready = {} + return x.rev +end + +function FakeRelay:xgResolve(room, bumped) + local x = room.xg + local r = self:xgRulesFor(room) + if not r then return end + local sig = rulesSig(r) + if sig == x.rulesSig then return end + x.rulesSig = sig + if not bumped then self:xgBump(room) end + if r.rules then + x.rules, x.blocked = r.rules, nil + local msg = copy(r.rules) + msg.type, msg.rev = "xg_rules", x.rev + self:relayInner(room, msg) + else + x.rules, x.blocked = nil, r.blocked + self:relayInner(room, { type = "xg_blocked", rev = x.rev, why = r.blocked }) + end +end + +function FakeRelay:xgNack(room, s, seat, kind, why, rev) + self.nacks = (self.nacks or 0) + 1 + self:to(s, { type = "room_msg", seq = nil, seat = -1, relay = true, to = seat, + msg = { type = "xg_nack", of = kind, why = why, + rev = type(rev) == "number" and rev or nil, current = room.xg.rev } }) +end + +function FakeRelay:xgForward(room, s, seat, clientSeq, out) + local entry = self:appendLog(room, { seat = seat, clientSeq = clientSeq, msg = copy(out) }) + for _, p in ipairs(room.players) do + if p.id ~= s.id then + self:to(self.sessions[p.id], { type = "room_msg", seq = entry.seq, clientSeq = clientSeq, + seat = seat, msg = copy(out) }) + end + end +end + +function FakeRelay:xgSizeOf(x) + local a, b = x.rosters[1], x.rosters[2] + if not a or not b then return nil end + local q0, q1 = x.sizeReq[1], x.sizeReq[2] + if q0 ~= nil and q0 == q1 and q0 <= a.size and q0 <= b.size then return q0 end + return math.min(a.size, b.size) +end + +local XG_SEAT = { xg_caps = true, xg_counter = true, xg_roster = true, xg_size_req = true, + xg_offer = true, xg_ready = true, xg_cancel = true } +local DIGEST = "^" .. ("[0-9a-f]"):rep(16) .. "$" + +local function smallInt(v, lo, hi) + if type(v) ~= "number" or v ~= math.floor(v) or v < lo or v > hi then return nil end + return v +end + +function FakeRelay:xgGo(room) + local x = room.xg + room.matchNo = room.matchNo + 1 + room.match = room.room .. "-m" .. room.matchNo + room.seed = 777 + local go = { type = "xg_go", rev = x.rev, seed = room.seed, match = room.match, mode = x.mode, + ruleset = x.mode == "trade" and "trade" or x.rules.ruleset } + if x.mode == "battle" then + for _, k in ipairs({ "gen", "dexMax", "moveMax", "moveGen" }) do go[k] = x.rules[k] end + go.size = x.size + room.engine = x.rules.ruleset == "native" and x.rules.gen or 3 + room.stage = "battling" + else + x.agreed = x.ready[1] + room.stage = "trading" + end + self:relayInner(room, go) + self:roomState(room) +end + +function FakeRelay:xgAfterBarrier(room, cause) + local x = room.xg + if x.closed or room.stage ~= "trading" then return end + room.stage = "prep" + x.offers, x.agreed = {}, nil + self:xgBump(room) + self:relayInner(room, { type = "xg_rev", rev = x.rev, seat = -1, cause = cause }) + self:roomState(room) +end + +function FakeRelay:xgClose(room, why, extra) + local x = room.xg + if not x or x.closed then return end + x.closed = true + local msg = { type = "xg_closed", why = why } + for k, v in pairs(extra or {}) do msg[k] = v end + self:relayInner(room, msg) + self:roomBroadcast(room, { type = "room_closed", reason = why, room = room.room }) + for _, p in ipairs(room.players) do + local member = self.sessions[p.id] + if member then + member.room = nil + member.xgStatus = nil + self:plazaChanged(member) + end + end + self.rooms[room.room] = nil +end + +local function xgWhy(v) + if type(v) ~= "string" or v == "" then return nil end + return v:sub(1, 40) +end + +function FakeRelay:xgRoomMsg(room, s, seat, clientSeq, inner) + local x = room.xg + local kind = inner.type + if room.stage ~= "prep" then + if kind == "xg_cancel" then + if room.stage ~= "battling" then self:xgClose(room, "cancel", { seat = seat, detail = xgWhy(inner.why) }) end + return true + end + if room.stage == "trading" and kind == "trade_confirm" and inner.digest ~= x.agreed then + self:xgNack(room, s, seat, kind, "digest_unagreed", nil) + return true + end + if room.stage == "trading" and kind == "trade_confirm" then + local b = room.barrier + b.confirms[seat] = inner.digest + self:xgForward(room, s, seat, clientSeq, inner) + if b.confirms[0] and b.confirms[1] then + self:relayInner(room, { type = "trade_commit", n = b.n, digests = { b.confirms[0], b.confirms[1] } }) + room.barrier = { n = b.n + 1, confirms = {} } + self:xgAfterBarrier(room, "trade_commit") + end + return true + end + return false + end + if not XG_SEAT[kind] then return true end + room.clientSeq = room.clientSeq or {} + if clientSeq and room.clientSeq[s.id] and clientSeq <= room.clientSeq[s.id] then return true end + if clientSeq then room.clientSeq[s.id] = clientSeq end + local rev = inner.rev + if kind == "xg_cancel" then + self:xgClose(room, "cancel", { seat = seat, detail = xgWhy(inner.why) }) + return true + end + if kind == "xg_caps" then + local caps = xgCaps(inner.caps) + if not caps then return self:xgNack(room, s, seat, kind, "bad", rev) or true end + x.caps[seat + 1] = caps + self:xgForward(room, s, seat, clientSeq, { type = "xg_caps", caps = caps }) + self:xgResolve(room, false) + return true + end + if type(rev) ~= "number" then return self:xgNack(room, s, seat, kind, "bad", rev) or true end + if rev ~= x.rev then return self:xgNack(room, s, seat, kind, "stale_rev", rev) or true end + local battleOnly = kind == "xg_roster" or kind == "xg_size_req" or kind == "xg_counter" + if (battleOnly and x.mode ~= "battle") or (kind == "xg_offer" and x.mode ~= "trade") then + return self:xgNack(room, s, seat, kind, "mode", rev) or true + end + local out + if kind == "xg_roster" then + local size = smallInt(inner.size, 1, 6) + if not size or type(inner.digest16) ~= "string" or not inner.digest16:match(DIGEST) then + return self:xgNack(room, s, seat, kind, "bad", rev) or true + end + x.rosters[seat + 1] = { size = size, digest16 = inner.digest16 } + out = { type = kind, rev = rev, size = size, digest16 = inner.digest16 } + elseif kind == "xg_size_req" then + local size = smallInt(inner.size, 1, 6) + if not size then return self:xgNack(room, s, seat, kind, "bad", rev) or true end + x.sizeReq[seat + 1] = size + out = { type = kind, rev = rev, size = size } + elseif kind == "xg_counter" then + local low = math.min(x.gens[1], x.gens[2]) + local gen = smallInt(inner.gen, 1, low) + if not gen then return self:xgNack(room, s, seat, kind, "bad", rev) or true end + if x.gens[1] == x.gens[2] then return self:xgNack(room, s, seat, kind, "same_gen", rev) or true end + x.counter = gen ~= low and gen or nil + out = { type = kind, rev = rev, gen = gen } + elseif kind == "xg_offer" then + local offerRev = smallInt(inner.offerRev, 0, 2147483647) + if not offerRev or type(inner.payload) ~= "table" or type(inner.digest16) ~= "string" + or not inner.digest16:match(DIGEST) then + return self:xgNack(room, s, seat, kind, "bad", rev) or true + end + local prev = x.offers[seat + 1] + if prev and offerRev <= prev.offerRev then + return self:xgNack(room, s, seat, kind, "stale_offer", rev) or true + end + x.offers[seat + 1] = { offerRev = offerRev, digest16 = inner.digest16 } + out = { type = kind, rev = rev, offerRev = offerRev, payload = copy(inner.payload), + digest16 = inner.digest16 } + elseif kind == "xg_ready" then + if type(inner.digest16) ~= "string" or not inner.digest16:match(DIGEST) then + return self:xgNack(room, s, seat, kind, "bad", rev) or true + end + if not x.rules then return self:xgNack(room, s, seat, kind, "no_rules", rev) or true end + if x.mode == "battle" and x.size == nil then + return self:xgNack(room, s, seat, kind, "no_roster", rev) or true + end + if x.mode == "trade" and not (x.offers[1] and x.offers[2]) then + return self:xgNack(room, s, seat, kind, "no_offer", rev) or true + end + if x.ready[seat + 1] then return true end + x.ready[seat + 1] = inner.digest16 + self:xgForward(room, s, seat, clientSeq, { type = kind, rev = x.rev, digest16 = inner.digest16 }) + if not (x.ready[1] and x.ready[2]) then return true end + if x.mode == "trade" and x.ready[1] ~= x.ready[2] then + x.ready = {} + for _, p in ipairs(room.players) do + self:xgNack(room, self.sessions[p.id], p.seat, kind, "digest", x.rev) + end + return true + end + self:xgGo(room) + return true + end + self:xgBump(room) + self:xgForward(room, s, seat, clientSeq, out) + self:relayInner(room, { type = "xg_rev", rev = x.rev, seat = seat, cause = kind }) + if kind == "xg_counter" then self:xgResolve(room, true) end + if kind == "xg_roster" or kind == "xg_size_req" then + x.size = self:xgSizeOf(x) + if x.size then self:relayInner(room, { type = "xg_size", rev = x.rev, size = x.size }) end + end + return true +end + function FakeRelay:welcome(s, resumed) s.online = true s.forgotten = false @@ -881,6 +1279,7 @@ function FakeRelay:handle(s, msg) for _, k in ipairs({ "where", "status", "version", "engine" }) do if msg[k] ~= nil then s.presence[k] = msg[k] end end + if s.xgen and msg.status ~= nil and msg.board == nil then self:plazaChanged(s) end if msg.board == false then s.presence.board = nil self:plazaChanged(s) @@ -916,6 +1315,14 @@ function FakeRelay:handle(s, msg) elseif kind == "room_leave" then local room = s.room and self.rooms[s.room] s.room = nil + if room and room.xg then + local seat = self:seatOf(room, s.id) + for i = #room.players, 1, -1 do + if room.players[i].id == s.id then table.remove(room.players, i) end + end + self:xgClose(room, "left", { seat = seat }) + return + end if room then for i = #room.players, 1, -1 do if room.players[i].id == s.id then table.remove(room.players, i) end @@ -952,6 +1359,13 @@ function FakeRelay:handle(s, msg) self:handleInvite(s, msg) elseif kind == "invite_reply" then self:handleInviteReply(s, msg) + elseif kind == "set_caps" then + local caps = not self.legacy and xgCaps(msg.caps) or nil + if not self.legacy and not caps then return self:joinError(s, "bad_caps") end + if caps then + s.caps = caps + self:plazaChanged(s) + end elseif kind == "plaza_join" then self:plazaJoin(s, msg) elseif kind == "plaza_leave" then diff --git a/tests/support/g3u_loopback.lua b/tests/support/g3u_loopback.lua new file mode 100644 index 00000000..31581b5f --- /dev/null +++ b/tests/support/g3u_loopback.lua @@ -0,0 +1,94 @@ +local Json = require("src.link.Json") + +local L = {} + +local Net = {} +Net.__index = Net + +function Net:send(msg) + if self.closed then return end + self.sent[#self.sent + 1] = msg + local wire = Json.encode(msg) + if self.tamper then + local out = self.tamper(Json.decode(wire)) + if out == nil then return end + wire = Json.encode(out) + end + local other = self.other + if other.dropAll then return end + if self.hold then + self.held[#self.held + 1] = wire + return + end + other.inbox[#other.inbox + 1] = wire +end + +function Net:flush() + for _, wire in ipairs(self.held) do self.other.inbox[#self.other.inbox + 1] = wire end + self.held = {} +end + +function Net:poll() + local out = {} + for i, wire in ipairs(self.inbox) do out[i] = Json.decode(wire) end + self.inbox = {} + return out +end + +function Net:takeWhere(pred) + for i, wire in ipairs(self.inbox) do + local msg = Json.decode(wire) + if pred(msg) then + table.remove(self.inbox, i) + return msg + end + end + return nil +end + +function Net:update() end + +function Net:peerOnline() + return self.other.online ~= false +end + +function Net:close() + self.closed = true +end + +function L.pair() + local a = setmetatable({ inbox = {}, sent = {}, held = {}, closed = false }, Net) + local b = setmetatable({ inbox = {}, sent = {}, held = {}, closed = false }, Net) + a.other, b.other = b, a + return a, b +end + +function L.lcg(seed) + local r = seed % 2147483648 + return function(n) + r = (r * 1103515245 + 12345) % 2147483648 + return math.floor(r / 65536) % n + 1 + end +end + +function L.bot(bs, opts) + opts = opts or {} + local rnd = opts.rnd or L.lcg(opts.seed or 7) + return function() + bs:update() + if bs.phase == "choose" then + local list = {} + for _, a in ipairs(bs:legal()) do + if a.kind ~= "forfeit" and (opts.switches or a.kind ~= "switch") then list[#list + 1] = a end + end + if #list == 0 then list = bs:legal() end + local pick = opts.pick and opts.pick(bs, list) or list[rnd(#list)] + if pick then bs:choose(pick) end + elseif bs.phase == "replace" then + local list = bs:legal() + if #list > 0 then bs:pickReplacement(list[rnd(#list)].index) end + end + end +end + +return L diff --git a/tests/support/union_prep_driver.lua b/tests/support/union_prep_driver.lua new file mode 100644 index 00000000..348e9238 --- /dev/null +++ b/tests/support/union_prep_driver.lua @@ -0,0 +1,172 @@ +local U = require("tests.drivers.util") +local Loop = require("tests.support.union_prep_loopback") + +local D = {} + +local function deepCopy(v, seen) + if type(v) ~= "table" then return v end + seen = seen or {} + if seen[v] then return seen[v] end + local out = {} + seen[v] = out + for k, x in pairs(v) do out[k] = deepCopy(x, seen) end + return out +end +D.copy = deepCopy + +local function deepEqual(a, b, seen) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + seen = seen or {} + if seen[a] == b then return true end + seen[a] = b + for k, v in pairs(a) do if not deepEqual(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +D.equal = deepEqual + +function D.run(game, cfg) + local fails = 0 + local version = cfg.version + local dir = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/union-prep-" .. version) + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. version .. " " .. line) + return cond + end + local function finish() + print((fails == 0 and "PASS" or "FAIL") .. " union_prep_battle " .. version .. " failures=" .. fails) + love.event.quit(fails == 0 and 0 or 1) + U.wait(10) + end + local function shot(name) + U.wait(4) + U.still(game, ("%s/%s_%s.png"):format(dir, version, name)) + end + + local before = deepCopy(cfg.snapshot()) + local loop = Loop.new() + local prep = loop:prep() + loop:setRules(cfg.rules) + local Open = require("src.ui.union.prep.Open") + local called, result, outcome = false, nil, nil + local screen, model = Open.battle(game, nil, prep, function(r, o) + called, result, outcome = true, r, o + end, { version = version, opponent = cfg.opponent, gameplayMods = false }) + ok(screen ~= nil and model ~= nil, "the prep screen opens") + U.wait(10) + + local function labels(pg) + local out = {} + for _, it in ipairs(pg.items) do out[#out + 1] = it.label end + return table.concat(out, ",") + end + local function find(id, label) + local pg = model:page() + for i, it in ipairs(pg.items) do + if it.id == id and (label == nil or it.label:find(label, 1, true)) then return i, it end + end + return nil, nil, pg + end + local function choose(id, label) + local idx, _, pg = find(id, label) + if not ok(idx ~= nil, ("%s has %s %s"):format(model.step, id, tostring(label or ""))) then + print(" items: " .. labels(pg or model:page())) + return false + end + for _ = 1, 60 do + if model.cursor == idx then break end + U.tap(game, "down") + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + return true + end + local function lines() + return table.concat(model:page().lines or {}, " ") + end + + ok(model.step == "rules", "starts on the opponent and rules") + ok(lines():find(cfg.opponent.name, 1, true) ~= nil, "names the opponent") + shot("01_rules") + choose("changes") + ok(model.view and model.view.kind == "changes", "the rules list opens") + shot("02_rules_list") + U.tap(game, "b") + U.wait(4) + choose("rentals") + ok(model.view and model.view.kind == "rentals", "the rental list opens") + local rp = model:page() + ok(#rp.items - 1 == cfg.rentalCount, ("all %d rentals are listed (%d)"):format(cfg.rentalCount, #rp.items - 1)) + shot("03_rentals") + U.tap(game, "down") + U.wait(2) + U.tap(game, "down") + U.wait(2) + shot("04_rental_detail") + U.tap(game, "b") + U.wait(4) + choose("continue") + ok(model.step == "problems", "team check follows") + for _, want in ipairs(cfg.problems or {}) do + ok(lines():upper():find(want:upper(), 1, true) ~= nil, "explains: " .. want) + end + shot("05_problems") + choose("continue") + if cfg.substitute then + ok(model.step == "substitute", "the incompatible Pokemon gets a substitute screen") + shot("06_substitute") + if cfg.substitute.owned then + local i = find("swap_owned", cfg.substitute.owned) + ok(i == 1, "an owned " .. cfg.substitute.owned .. " ranks first") + end + choose(cfg.substitute.id, cfg.substitute.label) + end + if cfg.pickMove then + ok(model.step == "moves", "move adjustments follow") + local _, first = find("move") + ok(first ~= nil, "a legal replacement is offered") + local _, empty = find("empty") + ok(empty ~= nil, "an empty slot is offered") + shot("07_moves") + choose("move", first and first.label) + end + ok(model.step == "size", "team size follows") + shot("08_size_waiting") + loop:peerRoster(cfg.peerSize) + U.wait(8) + ok(prep.size == cfg.peerSize, "the relay agrees on " .. cfg.peerSize) + local _, cont = find("continue") + ok(cont and cont.disabled, "continue needs a sit-out choice first") + shot("09_size_sit_out") + choose("toggle", cfg.sitOut) + shot("10_size_chosen") + choose("continue") + ok(model.step == "confirm", "final confirmation follows") + shot("11_confirm") + choose("mon") + ok(model.view and model.view.kind == "mon", "a battler's data opens") + shot("12_battler") + U.tap(game, "b") + U.wait(4) + choose("ready") + ok(model.step == "waiting", "confirm waits for the opponent") + shot("13_waiting") + loop:peerReady() + U.wait(12) + ok(called and outcome == "go", "both confirmations finish the prep with go") + ok(result and #result.records == cfg.peerSize, "the result has the agreed team size") + local rental = false + for _, rec in ipairs(result and result.records or {}) do if rec.rental then rental = true end end + ok(rental == (cfg.expectRental == true), "the rental is " .. (cfg.expectRental and "in" or "not in") .. " the result") + ok(deepEqual(before, cfg.snapshot()), "the active save's party and PC are untouched") + ok(cfg.closed(screen), "the prep screen is gone") + U.wait(10) + shot("14_after") + return finish() +end + +return D diff --git a/tests/support/union_prep_loopback.lua b/tests/support/union_prep_loopback.lua new file mode 100644 index 00000000..12b6aa91 --- /dev/null +++ b/tests/support/union_prep_loopback.lua @@ -0,0 +1,137 @@ +local Loop = {} +Loop.__index = Loop + +Loop.PEER_DIGEST = "00000000000000b2" + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end + +function Loop.new(opts) + opts = opts or {} + local self = setmetatable({ seat = opts.seat or 0, rev = 0, inbox = {}, rosters = {}, sizeReq = {}, + ready = {}, size = nil, rules = nil, closed = false, stage = "prep", sent = {} }, Loop) + local me = self + self.session = { + send = function(m) + me.sent[#me.sent + 1] = copy(m) + me:fromSeat(me.seat, m) + return not me.closed + end, + take = function(pred) + for i, m in ipairs(me.inbox) do + if pred(m) then return table.remove(me.inbox, i) end + end + return nil + end, + seat = function() return me.seat end, + open = function() return not me.closed or #me.inbox > 0 end, + leave = function() me.closed = true end, + } + return self +end + +function Loop:peer() return 1 - self.seat end + +function Loop:push(m) + m.relay = true + self.inbox[#self.inbox + 1] = m +end + +function Loop:bump() + self.rev = self.rev + 1 + self.ready = {} +end + +function Loop:setRules(r) + self:bump() + self.rules = copy(r) + local m = copy(r) + m.type, m.rev = "xg_rules", self.rev + m.mode = m.mode or "battle" + self:push(m) +end + +function Loop:sizeOf() + local a, b = self.rosters[0], self.rosters[1] + if not a or not b then return nil end + local q0, q1 = self.sizeReq[0], self.sizeReq[1] + if q0 and q0 == q1 and q0 <= a.size and q0 <= b.size then return q0 end + return math.min(a.size, b.size) +end + +function Loop:nack(seat, kind, why, rev) + if seat == self.seat then + self:push({ type = "xg_nack", of = kind, why = why, rev = rev, current = self.rev }) + end +end + +function Loop:fromSeat(seat, m) + if self.closed or type(m) ~= "table" then return end + local kind = m.type + if kind == "xg_cancel" then return self:close("cancel", seat) end + if self.stage ~= "prep" then return end + if m.rev ~= self.rev then return self:nack(seat, kind, "stale_rev", m.rev) end + local fwd + if kind == "xg_roster" then + self.rosters[seat] = { size = m.size, digest16 = m.digest16 } + fwd = { type = kind, rev = m.rev, size = m.size, digest16 = m.digest16 } + elseif kind == "xg_size_req" then + self.sizeReq[seat] = m.size + fwd = { type = kind, rev = m.rev, size = m.size } + elseif kind == "xg_ready" then + if not self.rules then return self:nack(seat, kind, "no_rules", m.rev) end + if not self.size then return self:nack(seat, kind, "no_roster", m.rev) end + if self.ready[seat] then return end + self.ready[seat] = m.digest16 + if seat ~= self.seat then self:push({ type = kind, rev = self.rev, digest16 = m.digest16 }) end + if self.ready[0] and self.ready[1] then self:go() end + return + else + return + end + self:bump() + if seat ~= self.seat then self:push(fwd) end + self:push({ type = "xg_rev", rev = self.rev, seat = seat, cause = kind }) + self.size = self:sizeOf() + if self.size then self:push({ type = "xg_size", rev = self.rev, size = self.size }) end +end + +function Loop:go() + self.stage = "battling" + local r = self.rules or {} + self:push({ type = "xg_go", rev = self.rev, seed = 777, match = "loop-m1", mode = "battle", + ruleset = r.ruleset, size = self.size, gen = r.gen, dexMax = r.dexMax, moveMax = r.moveMax, moveGen = r.moveGen }) +end + +function Loop:close(why, seat) + if self.closed then return end + self:push({ type = "xg_closed", why = why, seat = seat }) + self.closed = true +end + +function Loop:peerRoster(size, digest) + self:fromSeat(self:peer(), { type = "xg_roster", rev = self.rev, size = size, digest16 = digest or Loop.PEER_DIGEST }) +end + +function Loop:peerSizeReq(size) + self:fromSeat(self:peer(), { type = "xg_size_req", rev = self.rev, size = size }) +end + +function Loop:peerReady(digest) + self:fromSeat(self:peer(), { type = "xg_ready", rev = self.rev, digest16 = digest or Loop.PEER_DIGEST }) +end + +function Loop:peerCancel() + self:close("cancel", self:peer()) +end + +function Loop:prep() + local Prep = require("src.online.union.Prep") + return Prep.new(self.session, { mode = "battle" }) +end + +return Loop diff --git a/tests/support/union_trade_driver.lua b/tests/support/union_trade_driver.lua new file mode 100644 index 00000000..6c912d69 --- /dev/null +++ b/tests/support/union_trade_driver.lua @@ -0,0 +1,267 @@ +local U = require("tests.drivers.util") +local FakeRelay = require("tests.support.fake_relay") +local Participant = require("src.online.union.Participant") +local Room = require("src.online.union.Room") +local Datasets = require("src.online.xgen.Datasets") +local GameVersion = require("src.core.GameVersion") +local Model = require("src.online.union.TradePrepModel") +local Txn = require("src.online.union.TradeTxn") +local Open = require("src.ui.union.prep.OpenTrade") +local SaveData = require("src.core.SaveData") + +local D = {} + +local FP = "1234123412341234" + +local function identityRoot(version) + local home = os.getenv("HOME") or "" + local prefix = GameVersion.cachePrefix(version) + local probe = GameVersion.generation(version) == 3 and "data/generated/gba/pokemon/names.lua" or "data/generated/pokemon.lua" + local ids = { os.getenv("POKEPORT_IDENTITY") or "", "g1r-" .. version, "pokeport-test-caches" } + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + for _, id in ipairs(ids) do + if id ~= "" then + local f = io.open(base .. "/" .. id .. "/" .. prefix .. probe, "rb") + if f then f:close() return base .. "/" .. id end + end + end + end + return nil +end +D.identityRoot = identityRoot + +function D.installDatasets() + local roots = {} + Datasets.setReader(function(v, rel) + if roots[v] == nil then roots[v] = identityRoot(v) or false end + if not roots[v] then return nil end + return Datasets.directoryReader(roots[v])(v, rel) + end) + Model.datasetSource = nil +end + +function D.installGen3(version) + local root = identityRoot(version) + if not root then return false end + local Dataset = require("src.core.game3.dataset") + Dataset.cacheRootOverride = root .. "/" .. GameVersion.cachePrefix(version) .. "data/generated/gba" + Dataset.mountExtractRoots() + require("src.core.game3.pokemon").install(nil) + return require("src.core.game3.pokemon")._names ~= nil +end + +function D.gameData(version) + local d = Datasets.get(version) + return d and { pokemon = d.raw.pokemon, moves = d.raw.moves, items = d.raw.items or {} } +end + +function D.peerGame(version, species, level, moves) + local P = require("src.link.Protocol") + local gen = GameVersion.generation(version) + if gen == 3 then + local Pokemon = require("src.core.game3.pokemon") + local sp = Pokemon.speciesFromNational(species) + local list = {} + for _, m in ipairs(moves) do list[#list + 1] = { id = m, pp = Pokemon.movePp(m), ppUps = 0 } end + local pid = 0x12345678 + local exp = require("src.core.game3.summary_data").expForLevel(Pokemon.growthRate(sp), level) + local mon = assert(P.unpackMon3(nil, { species = sp, level = level, exp = exp, personality = pid, otId = 4321, + otSecretId = 0, otGender = 1, otName = "LEAF", nickname = "", ivs = { hp = 10, atk = 10, def = 10, spe = 10, spa = 10, spd = 10 }, + evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }, moves = list, item = 0, nature = pid % 25, gender = Pokemon.gender(sp, pid), + ability = Pokemon.abilityId(sp, pid), friendship = 70, metLocation = 1, metLevel = level, metGame = 4, + pokeball = 4, language = 2 }, { strict = true })) + return { session = { version = version, party = { mon }, dex = { seen = {}, owned = {}, caught = {} }, flags = {}, vars = {} } } + end + local data = D.gameData(version) + local d = Datasets.get(version) + local key = d.species[species].localKey + local list = {} + for _, m in ipairs(moves) do list[#list + 1] = { id = d.moves[m].localKey, pp = d.moves[m].pp } end + local packed = { species = key, level = level, ot = "PEER", otId = 4321, + dvs = { attack = 9, defense = 9, speed = 9, special = 9 }, statExp = {}, moves = list } + if gen == 2 then + packed.experience = d.species[species].exp[level] + packed.happiness = 70 + local mon = assert(P.unpackMon2(data, packed, { strict = true })) + local Save2 = require("src.core.gen2.Save") + local was = GameVersion.get() + GameVersion.set(version) + local save = Save2.newGame({ playerName = "PEER" }) + GameVersion.set(was) + save.version = version + save.party = { mon } + return { save = save, data = data } + end + packed.exp = d.species[species].exp[level] + local mon = assert(P.unpackMon(data, packed, { strict = true })) + mon.catchRate = 45 + return { save = { version = version, party = { mon }, pokedex = { seen = {}, owned = {} } }, data = data } +end + +local function ctxFor(version, name, tid) + local gen = Participant.genOf(version) + return { version = version, name = name, trainerId = tid, gender = 0, + profile = { engine = gen, version = version, engineVersion = "0.0.0-dev", apiVersion = 2, fingerprint = FP, + rulesetId = gen == 3 and "g3_single" or "union", kind = "vanilla" }, + vanillaFingerprint = FP, gameplayMods = false } +end + +function D.world(myVersion, peerVersion) + local clock = 0 + local w = { relay = FakeRelay.new({ clock = function() return clock end }), clients = {} } + local saved = package.loaded["src.online.Client"] + function w:add(n, name) + local seat = self.relay:seat(("%08x"):format(n), name) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end }) + C.connect({ name = name, profiles = {} }) + self.clients[#self.clients + 1] = C + return Room.new({ client = C }) + end + function w:pump(rounds) + for _ = 1, rounds or 1 do + clock = clock + 16 + self.relay:pump() + for _, C in ipairs(self.clients) do C.update(0) end + end + end + local ra = w:add(1, "ME") + local rb = w:add(2, "PEER") + package.loaded["src.online.Client"] = saved + w:pump(4) + ra:join(ctxFor(myVersion, "ME", 1)) + rb:join(ctxFor(peerVersion, "PEER", 2)) + w:pump(4) + ra:poll(); rb:poll() + ra:invite(("%08x"):format(2), "xg_trade") + w:pump(4) + rb:reply(rb:incoming()[1].id, true) + w:pump(4) + w.mine, w.peer = ra, rb + return w +end + +function D.run(game, cfg) + local fails = 0 + local version = GameVersion.get() + local dir = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/union-trade-" .. version) + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + local function ok(cond, line) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. version .. " " .. line) + return cond + end + local function finish() + print((fails == 0 and "PASS" or "FAIL") .. " union_trade " .. version .. "<->" .. cfg.peerVersion .. " failures=" .. fails) + love.event.quit(fails == 0 and 0 or 1) + U.wait(10) + end + D.installDatasets() + local w = D.world(version, cfg.peerVersion) + local peerGame = cfg.peerGame() + local peerAdapter = Txn.newAdapter(peerGame, cfg.peerVersion) + peerAdapter.writer = function() return true end + local peer = Open.controller(peerGame, w.peer, w.peer:prep(), { version = cfg.peerVersion, adapter = peerAdapter, + opponent = { name = "ME", version = version } }) + local called = false + local screen, ctl = Open.trade(game, w.mine, w.mine:prep(), function() called = true end, + { version = version, opponent = { name = "PEER", version = cfg.peerVersion } }) + ok(screen ~= nil and ctl ~= nil, "the trade screen opens") + + local function step(n) + for _ = 1, n or 1 do + w:pump(1) + peer:poll(0) + U.wait(1) + end + end + local function shot(name) + step(6) + U.still(game, ("%s/%s_%s.png"):format(dir, version, name)) + end + local function lines() return table.concat(ctl:page().lines or {}, " ") end + local function find(c, id) + for i, it in ipairs(c:page().items) do if it.id == id then return i end end + return nil + end + local function choose(id) + local idx = find(ctl, id) + if not ok(idx ~= nil, ctl.step .. " has " .. id) then return false end + for _ = 1, 40 do + if ctl.cursor == idx then break end + U.tap(game, "down") + step(2) + end + for _ = 1, 12 do + local was, scroll = ctl.step, ctl.scroll + U.tap(game, "a") + step(4) + if ctl.step ~= was or ctl.scroll == scroll then break end + end + return true + end + local function peerChoose(id) + local idx = find(peer, id) + if idx then peer.cursor = idx; peer:input("a") end + step(4) + end + local function waitStep(want, frames) + for _ = 1, frames or 240 do + if ctl.step == want then return true end + step(1) + end + return ctl.step == want + end + + step(20) + ok(ctl.step == "pick", "starts on the pick page") + ok(lines():find(GameVersion.VERSIONS[cfg.peerVersion].label, 1, true) ~= nil, "names the other player's game") + shot("01_pick") + choose("mon") + ok(ctl.step == "offer", "the offer preview opens") + if cfg.expectMoveFix then + ok(find(ctl, "moves") ~= nil, "a move the other game lacks offers a fix") + shot("02_offer_blocked") + choose("moves") + ok(ctl.step == "moves", "legal replacements are listed") + shot("03_moves") + choose("remove") + end + ok(find(ctl, "offer") ~= nil, "the converted offer can be sent: " .. lines()) + shot("04_offer") + choose("offer") + ok(ctl.step == "wait", "waiting for the peer's offer") + shot("05_wait") + peerChoose("mon") + if find(peer, "moves") then peerChoose("moves") peerChoose("remove") end + peerChoose("offer") + ok(waitStep("confirm", 240), "both offers reach the final check (" .. ctl.step .. ")") + ok(lines():find("feature of this app", 1, true) ~= nil, "the final page says this is an app feature") + shot("06_confirm") + local scrolled = ctl.scroll + U.tap(game, "a") + step(2) + ok(ctl.step == "confirm" and ctl.scroll > scrolled, "A turns the page of a long final check") + shot("07_confirm_more") + local before = cfg.mine() + choose("trade") + ok(ctl.step == "ready", "waiting for the peer to agree") + shot("08_ready") + peerChoose("trade") + ok(waitStep("done", 300), "the trade completes (" .. ctl.step .. " txn=" .. tostring(ctl.txn.state) .. " saveFailed=" .. tostring(ctl.txn.saveFailed) .. " " .. tostring(ctl.closedText) .. ")") + shot("09_done") + local after = cfg.mine() + ok(after ~= before, "the outgoing slot now holds the received mon (" .. tostring(before) .. " -> " .. tostring(after) .. ")") + ok(cfg.expectReceived == nil or after == cfg.expectReceived, "received " .. tostring(cfg.expectReceived)) + ok(#Txn.pending(version) == 0, "no journal remains") + local main = SaveData.saveFilename(version) + ok(love.filesystem.getInfo(main) ~= nil, "the trade was saved") + choose("stop") + step(20) + ok(called, "the screen closes and reports back") + return finish() +end + +return D