Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/maindev.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions lib/card_utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 84 additions & 8 deletions networking/action_handlers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions tests/action_stop_game_test.lua
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 12 additions & 2 deletions ui/lobby/deck_stake_button.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -42,4 +52,4 @@ function MP.UI.create_lobby_deck_button(text_scale, back, stake)
enabled_ref_value = "different_decks",
})
end
end
end
Loading