diff --git a/.github/workflows/maindev.yml b/.github/workflows/maindev.yml new file mode 100644 index 00000000..8f4e02ea --- /dev/null +++ b/.github/workflows/maindev.yml @@ -0,0 +1,38 @@ +name: Test Multiplayer Mod + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.4" + + - name: Setup LuaRocks + run: | + sudo apt-get update + sudo apt-get install -y luarocks lua5.4 liblua5.4-dev + + - name: Install Busted + run: | + luarocks --lua-version=5.4 install --local busted + echo "$HOME/.luarocks/bin" >> "$GITHUB_PATH" + + - name: Syntax of all Lua files + run: | + for file in $(find . -type f -name "*.lua" -not -path "./.git/*"); do + echo "Checking $file" + lua -e "assert(loadfile('$file'))" + done + + - name: Run Busted tests + run: busted --pattern='_test%.lua$' tests diff --git a/core.lua b/core.lua index 8391e4a9..548377d1 100644 --- a/core.lua +++ b/core.lua @@ -19,6 +19,7 @@ MP.LOBBY = { type = "", config = {}, -- Now set in MP.reset_lobby_config deck = { + back_key = "b_red", back = "Red Deck", sleeve = "sleeve_casl_none", stake = 1, @@ -171,6 +172,7 @@ function MP.reset_lobby_config(persist_ruleset_and_gamemode) weekly = nil, custom_seed = "random", different_decks = false, + back_key = "b_red", back = "Red Deck", sleeve = "sleeve_casl_none", stake = 1, @@ -217,6 +219,7 @@ function MP.reset_game_states() prevent_eval = false, round_ended = false, duplicate_end = false, + pvp_blind_started = false, misprint_display = "", spent_total = 0, spent_before_shop = 0, diff --git a/lib/card_utils.lua b/lib/card_utils.lua index a4662688..67457bb0 100644 --- a/lib/card_utils.lua +++ b/lib/card_utils.lua @@ -84,10 +84,40 @@ function MP.UTILS.run_for_each_phantom_joker(key, func) end end -function MP.UTILS.get_deck_key_from_name(_name) +function MP.UTILS.get_deck_key_from_name(deck_name_or_key) + if not deck_name_or_key then + return "b_red" + end + + -- Already a valid key? + if G.P_CENTERS[deck_name_or_key] and G.P_CENTERS[deck_name_or_key].set == "Back" then + return deck_name_or_key + end + + -- Search by name (localized or raw) for k, v in pairs(G.P_CENTERS) do - if v.name == _name then return k end + if v.set == "Back" then + local loc_name = localize({type = 'name_text', key = k, set = 'Back'}) + if v.name == deck_name_or_key or loc_name == deck_name_or_key then + return k + end + end + end + + sendErrorMessage("MP.UTILS.get_deck_key_from_name: Could not find deck for '" .. tostring(deck_name_or_key) .. "'", "MULTIPLAYER") + return "b_red" +end + +function MP.UTILS.get_deck_name_from_key(deck_key) + if not deck_key then + return "Red Deck" end + + if G.P_CENTERS[deck_key] and G.P_CENTERS[deck_key].set == "Back" then + return G.P_CENTERS[deck_key].name or localize({type = 'name_text', key = deck_key, set = 'Back'}) + end + + return "Red Deck" end function MP.UTILS.get_culled_pool(_type, _rarity, _legendary, _append) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 3f52ad00..f601cfff 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -112,6 +112,25 @@ function Game:update(dt) handle_reconnect_timeout("Reconnection failed.\nReturning to main menu.") end end + -- Auto-detect stuck outside PvP blind + if MP.LOBBY.code and MP.is_pvp_boss() and not MP.GAME.pvp_blind_started then + + -- Check if opponent is already in PvP blind (has "playing" in their location) + if MP.GAME.enemy and MP.GAME.enemy.location and string.find(MP.GAME.enemy.location, "playing") then + sendDebugMessage("Auto-detected stuck outside PvP blind, recovering...", "MULTIPLAYER") + + -- Trigger the same unstuck logic that the button uses + if G.FUNCS.mp_unstuck_blind then + G.FUNCS.mp_unstuck_blind() + end + end + end + + -- Also reset the flag when leaving PvP state + if MP.LOBBY.code and not MP.is_pvp_boss() and MP.GAME.pvp_blind_started then + MP.GAME.pvp_blind_started = false + end + return _disconnect_gupdate(self, dt) end @@ -247,9 +266,18 @@ local function begin_pvp_blind() end local function action_start_blind() + + -- Reset state MP.GAME.ready_blind = false MP.GAME.timer_started = false MP.GAME.timer = MP.LOBBY.config.timer_base_seconds + MP.GAME.pvp_blind_started = true -- New flag + + -- Clear any pending stuck state + if MP.GAME.stuck_outside_pvp then + MP.GAME.stuck_outside_pvp = nil + end + MP.UI.start_pvp_countdown(begin_pvp_blind) end @@ -328,11 +356,42 @@ end local function action_stop_game() MP.enemy_disconnect_countdown = nil - if G.STAGE ~= G.STAGES.MAIN_MENU then - G.FUNCS.go_to_menu() - MP.UI.update_connection_status() - MP.reset_game_states() - end + + -- Show the notification + attention_text({ + text = "Opponent left the lobby", + scale = 0.8, + hold = 2, + align = "cm", + offset = { x = 0, y = 0 }, + major = G.ROOM_ATTACH, + }) + + -- Event system for delay + G.E_MANAGER:add_event(Event({ + trigger = "after", + delay = 2.0, + func = function() + -- Clear lobby state first + MP.LOBBY.code = nil + MP.LOBBY.connected = false + MP.LOBBY.in_game = false + + -- Force reset to main menu + G.STATE = G.STATES.MENU + G.STATE_COMPLETE = false + + -- Clear any remaining UI + if G.OVERLAY_MENU then + G.FUNCS.exit_overlay_menu() + end + + -- Refresh the main menu UI + set_main_menu_UI() + + return true + end, + })) end local function action_end_pvp() @@ -427,14 +486,32 @@ local function action_lobby_options(options) parsed_v = tonumber(v) end - MP.LOBBY.config[k] = parsed_v + -- Handle deck keys + if k == "back_key" then + MP.LOBBY.config.back_key = parsed_v + MP.LOBBY.config.back = MP.UTILS.get_deck_name_from_key(parsed_v) or parsed_v + elseif k == "back" then + local key = MP.UTILS.get_deck_key_from_name(parsed_v) + if key then + MP.LOBBY.config.back_key = key + MP.LOBBY.config.back = MP.UTILS.get_deck_name_from_key(key) + else + MP.LOBBY.config.back = parsed_v + end + else + MP.LOBBY.config[k] = parsed_v + end + if MP.UI.update_lobby_option_toggle then MP.UI.update_lobby_option_toggle(k) end ::continue:: end + if different_decks_before ~= MP.LOBBY.config.different_decks then - G.FUNCS.exit_overlay_menu() -- throw out guest from any menu. + G.FUNCS.exit_overlay_menu() end + MP.ACTIONS.update_player_usernames() -- render new DECK button state + end local function action_send_phantom(key) @@ -1178,7 +1255,6 @@ function Game:update(dt) end return end - local parsedAction = json.decode(msg) if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then diff --git a/tests/action_stop_game_test.lua b/tests/action_stop_game_test.lua new file mode 100644 index 00000000..8c43fef4 --- /dev/null +++ b/tests/action_stop_game_test.lua @@ -0,0 +1,102 @@ +local function load_action_stop_game(env) + local f = assert(io.open("networking/action_handlers.lua", "r")) + local src = f:read("*a") + f:close() + + local start_idx = assert(src:find("local function action_stop_game%(%)")) + local next_fn_idx = assert(src:find("\nlocal function action_end_pvp%(%)", start_idx)) + local snippet = src:sub(start_idx, next_fn_idx - 1) + + local chunk = assert(load(snippet .. "\nreturn action_stop_game", "action_stop_game_snippet", "t", env)) + return chunk() +end + +describe("action_stop_game", function() + it("sets message and schedules delayed cleanup", function() + local attention + local added_event + + local env = { + MP = { + enemy_disconnect_countdown = { end_time = 10 }, + LOBBY = { code = "ABCD", connected = true, in_game = true }, + }, + G = { + ROOM_ATTACH = {}, + E_MANAGER = { + add_event = function(_, ev) + added_event = ev + end, + }, + }, + attention_text = function(args) + attention = args + end, + Event = function(def) + return def + end, + set_main_menu_UI = function() + end, + } + + local action_stop_game = load_action_stop_game(env) + action_stop_game() + + assert(env.MP.enemy_disconnect_countdown == nil) + assert(attention ~= nil) + assert(attention.text == "Opponent left the lobby") + assert(added_event.delay == 2.0) + assert(type(added_event.func) == "function") + end) + + it("applies delayed state reset and returns true", function() + local overlay_closed = 0 + local menu_refreshed = 0 + local added_event + + local env = { + MP = { + enemy_disconnect_countdown = nil, + LOBBY = { code = "ABCD", connected = true, in_game = true }, + }, + G = { + ROOM_ATTACH = {}, + OVERLAY_MENU = true, + STATE = "INGAME", + STATE_COMPLETE = true, + STATES = { MENU = "MENU" }, + E_MANAGER = { + add_event = function(_, ev) + added_event = ev + end, + }, + FUNCS = { + exit_overlay_menu = function() + overlay_closed = overlay_closed + 1 + end, + }, + }, + attention_text = function() + end, + Event = function(def) + return def + end, + set_main_menu_UI = function() + menu_refreshed = menu_refreshed + 1 + end, + } + + local action_stop_game = load_action_stop_game(env) + action_stop_game() + local ok = added_event.func() + + assert(ok == true) + assert(env.MP.LOBBY.code == nil) + assert(env.MP.LOBBY.connected == false) + assert(env.MP.LOBBY.in_game == false) + assert(env.G.STATE == "MENU") + assert(env.G.STATE_COMPLETE == false) + assert(overlay_closed == 1) + assert(menu_refreshed == 1) + end) +end) diff --git a/ui/lobby/deck_stake_button.lua b/ui/lobby/deck_stake_button.lua index 340b8323..c227e849 100644 --- a/ui/lobby/deck_stake_button.lua +++ b/ui/lobby/deck_stake_button.lua @@ -2,10 +2,20 @@ local Disableable_Button = MP.UI.Disableable_Button -- Component for deck selection button in lobby function MP.UI.create_lobby_deck_button(text_scale, back, stake) + -- Determine the correct deck key (prefer stored key, fallback to converting name) + local deck_key + if MP.LOBBY.deck.back_key then + deck_key = MP.LOBBY.deck.back_key + elseif MP.LOBBY.config.back_key then + deck_key = MP.LOBBY.config.back_key + else + deck_key = MP.UTILS.get_deck_key_from_name(back) or "b_red" + end + local deck_labels = { localize({ type = "name_text", - key = MP.UTILS.get_deck_key_from_name(back), + key = deck_key, set = "Back", }), localize({ @@ -42,4 +52,4 @@ function MP.UI.create_lobby_deck_button(text_scale, back, stake) enabled_ref_value = "different_decks", }) end -end +end \ No newline at end of file diff --git a/ui/lobby/lobby.lua b/ui/lobby/lobby.lua index 8f899aa9..14a50c49 100644 --- a/ui/lobby/lobby.lua +++ b/ui/lobby/lobby.lua @@ -39,7 +39,15 @@ end function G.UIDEF.create_UIBox_lobby_menu() local text_scale = 0.45 - local back = MP.LOBBY.config.different_decks and MP.LOBBY.deck.back or MP.LOBBY.config.back + + -- Get deck key (prefer back_key, fallback to name) + local back_key + if MP.LOBBY.config.different_decks then + back_key = MP.LOBBY.deck.back_key or MP.LOBBY.deck.back + else + back_key = MP.LOBBY.config.back_key or MP.LOBBY.config.back + end + local back_name = MP.UTILS.get_deck_name_from_key(back_key) or "Red Deck" local stake = MP.LOBBY.config.different_decks and MP.LOBBY.deck.stake or MP.LOBBY.config.stake local t = { @@ -86,7 +94,7 @@ function G.UIDEF.create_UIBox_lobby_menu() col = true, }) or nil, MP.UI.create_spacer(), - MP.UI.create_lobby_deck_button(text_scale, back, stake), + MP.UI.create_lobby_deck_button(text_scale, back_name, stake), MP.UI.create_spacer(), MP.UI.create_players_section(text_scale), MP.UI.create_spacer(), @@ -263,12 +271,15 @@ function G.FUNCS.lobby_start_run(e, args) if MP.LOBBY.config.different_decks == false then G.FUNCS.copy_host_deck() end local challenge = nil - if MP.LOBBY.deck.back == "Challenge Deck" then + local deck_key = MP.LOBBY.deck.back_key or MP.LOBBY.deck.back + if deck_key == "Challenge Deck" or deck_key == "b_challenge_deck" then challenge = G.CHALLENGES[get_challenge_int_from_id(MP.LOBBY.deck.challenge)] else - G.GAME.viewed_back = G.P_CENTERS[MP.UTILS.get_deck_key_from_name(MP.LOBBY.deck.back)] + G.GAME.viewed_back = G.P_CENTERS[deck_key] end + + G.FUNCS.start_run(e, { mp_start = true, challenge = challenge, @@ -297,7 +308,8 @@ function Back:generate_UI(other, ui_scale, min_dims, challenge) end function G.FUNCS.copy_host_deck() - MP.LOBBY.deck.back = MP.LOBBY.config.back + MP.LOBBY.deck.back_key = MP.LOBBY.config.back_key or MP.LOBBY.config.back + MP.LOBBY.deck.back = MP.LOBBY.config.back -- keep for long term MP.LOBBY.deck.cocktail = MP.LOBBY.config.cocktail MP.LOBBY.deck.sleeve = MP.LOBBY.config.sleeve MP.LOBBY.deck.stake = MP.LOBBY.config.stake @@ -375,17 +387,23 @@ G.FUNCS.start_run = function(e, args) chosen_stake = MP.DECK.MAX_STAKE end if MP.LOBBY.is_host then - MP.LOBBY.config.back = args.challenge and "Challenge Deck" - or (args.deck and args.deck.name) - or G.GAME.viewed_back.name - MP.LOBBY.config.stake = chosen_stake - MP.LOBBY.config.sleeve = G.viewed_sleeve - MP.LOBBY.config.challenge = args.challenge and args.challenge.id or "" - send_lobby_options() - end - MP.LOBBY.deck.back = args.challenge and "Challenge Deck" - or (args.deck and args.deck.name) - or G.GAME.viewed_back.name + local deck_key = args.challenge and "Challenge Deck" + or (args.deck and MP.UTILS.get_deck_key_from_name(args.deck.name)) + or (G.GAME.viewed_back and G.GAME.viewed_back.key) + or "b_red" + MP.LOBBY.config.back_key = deck_key + MP.LOBBY.config.back = deck_key -- long + MP.LOBBY.config.stake = chosen_stake + MP.LOBBY.config.sleeve = G.viewed_sleeve + MP.LOBBY.config.challenge = args.challenge and args.challenge.id or "" + send_lobby_options() + end + local deck_key = args.challenge and "Challenge Deck" + or (args.deck and MP.UTILS.get_deck_key_from_name(args.deck.name)) + or (G.GAME.viewed_back and G.GAME.viewed_back.key) + or "b_red" + MP.LOBBY.deck.back_key = deck_key + MP.LOBBY.deck.back = deck_key -- long MP.LOBBY.deck.stake = chosen_stake MP.LOBBY.deck.sleeve = G.viewed_sleeve MP.LOBBY.deck.challenge = args.challenge and args.challenge.id or ""