From d2fed9e178931f03d5fc6ad51627426423984972 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 07:42:48 +0000 Subject: [PATCH 1/6] Initial plan From 04f9e24216fe49ad9befe1546f46622f3e220cda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:16:40 +0000 Subject: [PATCH 2/6] Merge core.lua and key upstream changes while preserving FilPag structure Co-authored-by: FilPag <1493826+FilPag@users.noreply.github.com> --- core.lua | 99 +-- networking/action_handlers.lua | 1037 +++++++++++++++++++----------- networking/socket.lua | 35 +- rulesets/sandbox.lua | 380 +++++++++++ rulesets/weeklies/smallworld.lua | 118 ++++ 5 files changed, 1222 insertions(+), 447 deletions(-) create mode 100644 rulesets/sandbox.lua create mode 100644 rulesets/weeklies/smallworld.lua diff --git a/core.lua b/core.lua index 01a256a1..50546233 100644 --- a/core.lua +++ b/core.lua @@ -1,32 +1,11 @@ MP = SMODS.current_mod -G.FPS_CAP = 60 MP.LOBBY = { connected = false, temp_code = "", temp_seed = "", code = nil, type = "", - config = { - gold_on_life_loss = true, - no_gold_on_round_loss = false, - death_on_round_loss = true, - different_seeds = false, - starting_lives = 4, - pvp_start_round = 2, - timer_base_seconds = 150, - timer_increment_seconds = 60, - showdown_starting_antes = 3, - ruleset = nil, - gamemode = "gamemode_mp_attrition", - custom_seed = "random", - different_decks = false, - back = "Red Deck", - sleeve = "sleeve_casl_none", - stake = 1, - challenge = "", - multiplayer_jokers = true, - timer = true, - }, + config = {}, -- Now set in MP.reset_lobby_config deck = { back = "Red Deck", sleeve = "sleeve_casl_none", @@ -34,20 +13,14 @@ MP.LOBBY = { challenge = "", }, username = "Guest", - ready_text = "Ready", - id = "", blind_col = 1, - players = {}, - isHost = false, -} -MP.FLAGS = { - join_pressed = false, + host = {}, + guest = {}, + is_host = false, + ready_to_start = false, } MP.GAME = {} -MP.NETWORKING = {} MP.UI = {} -MP.UI_UTILS = {} -MP.UIDEF = {} MP.ACTIONS = {} MP.INTEGRATIONS = { TheOrder = SMODS.Mods["Multiplayer"].config.integrations.TheOrder, @@ -93,6 +66,36 @@ end MP.load_mp_file("misc/utils.lua") MP.load_mp_file("misc/insane_int.lua") +function MP.reset_lobby_config(persist_ruleset_and_gamemode) + sendDebugMessage("Resetting lobby options", "MULTIPLAYER") + MP.LOBBY.config = { + gold_on_life_loss = true, + no_gold_on_round_loss = false, + death_on_round_loss = true, + different_seeds = false, + starting_lives = 4, + pvp_start_round = 2, + timer_base_seconds = 150, + timer_increment_seconds = 60, + pvp_countdown_seconds = 3, + showdown_starting_antes = 3, + ruleset = persist_ruleset_and_gamemode and MP.LOBBY.config.ruleset or "ruleset_mp_blitz", + gamemode = persist_ruleset_and_gamemode and MP.LOBBY.config.gamemode or "gamemode_mp_attrition", + weekly = nil, + custom_seed = "random", + different_decks = false, + back = "Red Deck", + sleeve = "sleeve_casl_none", + stake = 1, + challenge = "", + multiplayer_jokers = true, + timer = true, + timer_forgiveness = 0, + forced_config = false, + } +end +MP.reset_lobby_config() + function MP.reset_game_states() sendDebugMessage("Resetting game states", "MULTIPLAYER") MP.GAME = { @@ -105,9 +108,7 @@ function MP.reset_game_states() comeback_bonus_given = true, comeback_bonus = 0, end_pvp = false, - next_coop_boss = nil, - players = {}, --[[@type table]] - --[[enemy = { + enemy = { score = MP.INSANE_INT.empty(), score_text = "0", hands = 4, @@ -118,18 +119,21 @@ function MP.reset_game_states() sells_per_ante = {}, spent_in_shop = {}, highest_score = MP.INSANE_INT.empty(), - }, --]] + }, location = "loc_selecting", next_blind_context = nil, ante_key = tostring(math.random()), antes_keyed = {}, prevent_eval = false, + round_ended = false, + duplicate_end = false, misprint_display = "", spent_total = 0, spent_before_shop = 0, highest_score = MP.INSANE_INT.empty(), timer = MP.LOBBY.config.timer_base_seconds, timer_started = false, + pvp_countdown = 0, real_money = 0, ce_cache = false, furthest_blind = 0, @@ -139,15 +143,13 @@ function MP.reset_game_states() pizza_discards = 0, wait_for_enemys_furthest_blind = false, disable_live_and_timer_hud = false, + timers_forgiven = 0, stats = { reroll_count = 0, reroll_cost_total = 0, -- Add more stats here in the future }, } - - MP.LOBBY.ready_text = localize("b_ready") - MP.LOBBY.ready_to_start = false end MP.reset_game_states() @@ -155,6 +157,8 @@ MP.reset_game_states() MP.LOBBY.username = MP.UTILS.get_username() MP.LOBBY.blind_col = MP.UTILS.get_blind_col() +MP.LOBBY.config.weekly = MP.UTILS.get_weekly() + if not SMODS.current_mod.lovely then G.E_MANAGER:add_event(Event({ no_delete = true, @@ -185,6 +189,16 @@ SMODS.Atlas({ MP.load_mp_dir("compatibility") +MP.load_mp_file("networking/action_handlers.lua") + +MP.load_mp_dir("ui/components") -- Gamemodes and rulesets need these + +MP.load_mp_dir("rulesets") +if MP.LOBBY.config.weekly then -- this could be a function but why bother + MP.load_mp_file("rulesets/weeklies/"..MP.LOBBY.config.weekly..".lua") +end +MP.load_mp_dir("gamemodes") + MP.load_mp_dir("objects/editions") MP.load_mp_dir("objects/enhancements") MP.load_mp_dir("objects/stickers") @@ -193,11 +207,6 @@ MP.load_mp_dir("objects/decks") MP.load_mp_dir("objects/jokers") MP.load_mp_dir("objects/consumables") MP.load_mp_dir("objects/challenges") -MP.load_mp_dir("networking") -MP.load_mp_dir("gamemodes") -MP.load_mp_dir("rulesets") -MP.load_mp_dir("function_overrides") -MP.apply_rulesets() MP.load_mp_dir("ui") MP.load_mp_dir("ui/generic") @@ -205,6 +214,8 @@ MP.load_mp_dir("ui/game") MP.load_mp_dir("ui/lobby") MP.load_mp_dir("ui/main_menu") +MP.load_mp_dir("function_overrides") + MP.load_mp_file("misc/disable_restart.lua") MP.load_mp_file("misc/mod_hash.lua") diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 87e4f898..ed9a007b 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -1,109 +1,43 @@ ---- @class client_data ---- @field username string ---- @field colour string ---- @field modHash string ---- @field isCached boolean ---- @field isReady boolean ---- @field firstReady boolean - ---- @player_state ---- @class player_state ---- @field lives number ---- @field score number ---- @field highest_score number ---- @field hands_left number ---- @field ante number ---- @field skips number ---- @field furthest_blind number ---- @field lives_blocker boolean ---- @field location string - ---- @class lobby_info ---- @field host string ---- @field hostHash string ---- @field hostCached boolean ---- @field isHost boolean ---- @field local_id string ---- @field players? table[] @ Array of player objects: { username: string, modHash: string, isCached: boolean, id: string } - - -local json = require "json" Client = {} function Client.send(msg) - if msg ~= '{"action":"keepAliveAck"}' and msg ~= "action:keepAliveAck" then + if not (msg == "action:keepAliveAck") then sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER") end love.thread.getChannel("uiToNetwork"):push(msg) end -- Server to Client - ---- @alias BossKey string - ---- Handles setting the boss blind in the game. ---- @param bossKey BossKey -local function action_set_boss_blind(bossKey) - if G.GAME.round_resets.blind_choices.Boss == bossKey then - MP.next_coop_boss = nil - return +function MP.ACTIONS.set_username(username) + MP.LOBBY.username = username or "Guest" + if MP.LOBBY.connected then + Client.send( + string.format( + "action:username,username:%s,modHash:%s", + MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col, + MP.MOD_STRING + ) + ) end +end - G.GAME.round_resets.blind_choices.Boss = bossKey - MP.next_coop_boss = bossKey - - if G.blind_select then - G.E_MANAGER:add_event(Event({ - trigger = 'immediate', - func = function() - play_sound('other1') - G.blind_select_opts.boss:set_role({ xy_bond = 'Weak' }) - G.blind_select_opts.boss.alignment.offset.y = 20 - return true - end - })) - G.E_MANAGER:add_event(Event({ - trigger = 'after', - delay = 0.3, - func = function() - local par = G.blind_select_opts.boss.parent - G.blind_select_opts.boss:remove() - G.blind_select_opts.boss = UIBox { - T = { par.T.x, 0, 0, 0, }, - definition = - { n = G.UIT.ROOT, config = { align = "cm", colour = G.C.CLEAR }, nodes = { - UIBox_dyn_container({ create_UIBox_blind_choice('Boss') }, false, get_blind_main_colour('Boss'), mix_colours(G.C.BLACK, get_blind_main_colour('Boss'), 0.8)) - } }, - config = { align = "bmi", - offset = { x = 0, y = G.ROOM.T.y + 9 }, - major = par, - xy_bond = 'Weak' - } - } - par.config.object = G.blind_select_opts.boss - par.config.object:recalculate() - G.blind_select_opts.boss.parent = par - G.blind_select_opts.boss.alignment.offset.y = 0 - MP.next_coop_boss = nil - return true - end - })) - end +function MP.ACTIONS.set_blind_col(num) + MP.LOBBY.blind_col = num or 1 end local function action_connected() MP.LOBBY.connected = true MP.UI.update_connection_status() - Client.send(json.encode({ - action = "username", - username = MP.LOBBY.username, - colour = MP.LOBBY.blind_col, - modHash = MP.MOD_STRING - })) + Client.send( + string.format( + "action:username,username:%s,modHash:%s", + MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col, + MP.MOD_STRING + ) + ) end local function action_joinedLobby(code, type) - MP.FLAGS.join_pressed = false MP.LOBBY.code = code MP.LOBBY.type = type MP.LOBBY.ready_to_start = false @@ -112,13 +46,57 @@ local function action_joinedLobby(code, type) MP.UI.update_connection_status() end ---- @param lobby_info lobby_info -local function action_lobbyInfo(lobby_info) - MP.LOBBY.isHost = lobby_info.isHost - MP.LOBBY.players = lobby_info.players or {} - MP.LOBBY.local_id = lobby_info.local_id +local function action_lobbyInfo(host, hostHash, hostCached, guest, guestHash, guestCached, guestReady, is_host) + MP.LOBBY.players = {} + MP.LOBBY.is_host = is_host == "true" + local function parseName(name) + local username, col_str = string.match(name, "([^~]+)~(%d+)") + username = username or "Guest" + local col = tonumber(col_str) or 1 + col = math.max(1, math.min(col, 25)) + return username, col + end + local hostName, hostCol = parseName(host) + local hostConfig, hostMods = MP.UTILS.parse_Hash(hostHash) + MP.LOBBY.host = { + username = hostName, + blind_col = hostCol, + hash_str = hostMods, + hash = hash(hostMods), + cached = hostCached == "true", + config = hostConfig, + } + + if guest ~= nil then + local guestName, guestCol = parseName(guest) + local guestConfig, guestMods = MP.UTILS.parse_Hash(guestHash) + MP.LOBBY.guest = { + username = guestName, + blind_col = guestCol, + hash_str = guestMods, + hash = hash(guestMods), + cached = guestCached == "true", + config = guestConfig, + } + else + MP.LOBBY.guest = {} + end + + -- Backwards compatibility for old server, assume guest is ready + -- TODO: Remove this once new server gets released + guestReady = guestReady or "true" + + -- TODO: This should check for player count instead + -- once we enable more than 2 players + MP.LOBBY.ready_to_start = guest ~= nil and guestReady == "true" + + if MP.LOBBY.is_host then + MP.ACTIONS.lobby_options() + end - MP.ACTIONS.update_player_usernames() + if G.STAGE == G.STAGES.MAIN_MENU then + MP.ACTIONS.update_player_usernames() + end end local function action_error(message) @@ -128,7 +106,7 @@ local function action_error(message) end local function action_keep_alive() - Client.send(json.encode({ action = "keepAliveAck" })) + Client.send("action:keepAliveAck") end local function action_disconnected() @@ -142,183 +120,215 @@ end ---@param deck string ---@param seed string ---@param stake_str string -local function action_start_game(players, seed, stake_str) +local function action_start_game(seed, stake_str) MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) - MP.GAME.players = players - - for _, player in ipairs(MP.GAME.players) do - player.location = MP.player_state_manager.parse_enemy_location(player.location) - player.score = MP.INSANE_INT.from_string(player.score) or MP.INSANE_INT.empty() - player.highest_score = MP.INSANE_INT.from_string(player.highest_score) or MP.INSANE_INT.empty() - end - if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then seed = MP.LOBBY.config.custom_seed end - G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false end +local function begin_pvp_blind() + if MP.GAME.next_blind_context then + G.FUNCS.select_blind(MP.GAME.next_blind_context) + else + sendErrorMessage("No next blind context", "MULTIPLAYER") + end +end + local function action_start_blind() - MP.GAME.ready_blind = false - MP.GAME.timer_started = false - MP.GAME.timer = MP.LOBBY.config.timer_base_seconds + MP.GAME.ready_blind = false + MP.GAME.timer_started = false + MP.GAME.timer = MP.LOBBY.config.timer_base_seconds + MP.UI.start_pvp_countdown(begin_pvp_blind) +end - if MP.GAME.next_blind_context then - G.FUNCS.select_blind(MP.GAME.next_blind_context) - else - sendErrorMessage("No next blind context", "MULTIPLAYER") +---@param score_str string +---@param hands_left_str string +---@param skips_str string +local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str) + local score = MP.INSANE_INT.from_string(score_str) + + local hands_left = tonumber(hands_left_str) + local skips = tonumber(skips_str) + local lives = tonumber(lives_str) + + if MP.GAME.enemy.skips ~= skips then + for i = 1, skips - MP.GAME.enemy.skips do + MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = 0 + end end -end -local function action_game_state_update(player_id, updates) - MP.player_state_manager.process(player_id, updates) -end + if score == nil or hands_left == nil then + sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER") + return + end -local function action_stop_game() - if G.STAGE ~= G.STAGES.MAIN_MENU then - G.FUNCS.go_to_menu() - MP.UI.update_connection_status() - MP.reset_game_states() + if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then + MP.GAME.enemy.highest_score = score end -end -local function action_end_pvp() - MP.GAME.end_pvp = true - MP.GAME.timer = MP.LOBBY.config.timer_base_seconds - MP.GAME.timer_started = false -end + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 3, + ref_table = MP.GAME.enemy.score, + ref_value = "e_count", + ease_to = score.e_count, + func = function(t) + return math.floor(t) + end, + })) -local function action_win_game() - MP.ACTIONS.sendPlayerDeck() G.E_MANAGER:add_event(Event({ - no_delete = true, - trigger = "immediate", - blockable = true, + blockable = false, blocking = false, - func = function() - MP.end_game_jokers_payload = "" - MP.nemesis_deck_string = "" - MP.end_game_jokers_received = false - MP.nemesis_deck_received = false - win_game() - MP.GAME.won = true - return true + trigger = "ease", + delay = 3, + ref_table = MP.GAME.enemy.score, + ref_value = "coeffiocient", + ease_to = score.coeffiocient, + func = function(t) + return math.floor(t) end, })) -end -local function action_lose_game() - MP.ACTIONS.sendPlayerDeck() G.E_MANAGER:add_event(Event({ - no_delete = true, - trigger = "immediate", - blockable = true, + blockable = false, blocking = false, - func = function() - MP.GAME.won = false - MP.end_game_jokers_payload = "" - MP.nemesis_deck_string = "" - MP.end_game_jokers_received = false - MP.nemesis_deck_received = false - G.STATE_COMPLETE = false - G.STATE = G.STATES.GAME_OVER - return true + trigger = "ease", + delay = 3, + ref_table = MP.GAME.enemy.score, + ref_value = "exponent", + ease_to = score.exponent, + func = function(t) + return math.floor(t) end, })) -end --- Helper: parse option value by type -local function parse_option_value(type_str, v) - if type_str == "boolean" then - return (v == true or v == "true") - elseif type_str == "number" then - return tonumber(v) - elseif type_str == "string" then - return tostring(v) - else - return v + MP.GAME.enemy.hands = hands_left + MP.GAME.enemy.skips = skips + MP.GAME.enemy.lives = lives + if MP.is_pvp_boss() then + G.HUD_blind:get_UIE_by_ID("HUD_blind_count"):juice_up() + G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned"):juice_up() end end --- Helper: check if any of the given keys changed between two tables -local function any_key_changed(keys, old_tbl, new_tbl) - for _, k in ipairs(keys) do - if old_tbl[k] ~= new_tbl[k] then - return true - end +local function action_stop_game() + if G.STAGE ~= G.STAGES.MAIN_MENU then + G.FUNCS.go_to_menu() + MP.UI.update_connection_status() + MP.reset_game_states() end - return false -end - -local config_map = { - starting_lives = { type = "number" }, - pvp_start_round = { type = "number" }, - timer_base_seconds = { type = "number" }, - timer_increment_seconds = { type = "number" }, - showdown_starting_antes = { type = "number" }, - different_decks = { type = "boolean" }, - gold_on_life_loss = { type = "boolean" }, - no_gold_on_round_loss = { type = "boolean" }, - death_on_round_loss = { type = "boolean" }, - different_seeds = { type = "boolean" }, - multiplayer_jokers = { type = "boolean" }, - normal_bosses = { type = "boolean" }, - custom_seed = { type = "string" }, - stake = { type = "number" }, - back = { type = "string" }, - challenge = { type = "string" }, - -- Add more config keys here as needed -} - -local function update_lobby_config(options) - local changed_keys = {} - local old_config = {} - for k, v in pairs(MP.LOBBY.config) do old_config[k] = v end +end - for k, v in pairs(options) do - local entry = config_map[k] - local parsed_v = entry and parse_option_value(entry.type, v) or v - if MP.LOBBY.config[k] ~= parsed_v then - MP.LOBBY.config[k] = parsed_v - changed_keys[k] = true - end - end - return changed_keys, old_config, MP.LOBBY.config +local function action_end_pvp() + MP.GAME.end_pvp = true + MP.GAME.timer = MP.LOBBY.config.timer_base_seconds + MP.GAME.timer_started = false end -local function update_overlay_toggles(changed_keys) - if not G.OVERLAY_MENU then return end - for k in pairs(changed_keys) do - local config_uie = G.OVERLAY_MENU:get_UIE_by_ID(k .. "_toggle") - if config_uie then - G.FUNCS.toggle(config_uie) +---@param lives number +local function action_player_info(lives) + if MP.GAME.lives ~= lives then + if MP.GAME.lives ~= 0 and MP.LOBBY.config.gold_on_life_loss then + MP.GAME.comeback_bonus_given = false + MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1 + end + ease_lives(lives - MP.GAME.lives) + if MP.LOBBY.config.no_gold_on_round_loss and (G.GAME.blind and G.GAME.blind.dollars) then + G.GAME.blind.dollars = 0 end end + MP.GAME.lives = lives +end + +local function action_win_game() + MP.end_game_jokers_payload = "" + MP.nemesis_deck_string = "" + MP.end_game_jokers_received = false + MP.nemesis_deck_received = false + win_game() + MP.GAME.won = true end -local function action_invalidLobby() - MP.FLAGS.join_pressed = false - MP.UTILS.overlay_message("Invalid lobby code") +local function action_lose_game() + MP.end_game_jokers_payload = "" + MP.nemesis_deck_string = "" + MP.end_game_jokers_received = false + MP.nemesis_deck_received = false + G.STATE_COMPLETE = false + G.STATE = G.STATES.GAME_OVER end local function action_lobby_options(options) - local changed_keys, old_config, new_config = update_lobby_config(options) + local different_decks_before = MP.LOBBY.config.different_decks + for k, v in pairs(options) do + if k == "ruleset" then + if not MP.Rulesets[v] then + G.FUNCS.lobby_leave(nil) + MP.UTILS.overlay_message( + localize({ + type = "variable", + key = "k_failed_to_join_lobby", + vars = { localize("k_ruleset_not_found") }, + }) + ) + return + end + local disabled = MP.Rulesets[v].is_disabled() + if disabled then + G.FUNCS.lobby_leave(nil) + MP.UTILS.overlay_message( + localize({ type = "variable", key = "k_failed_to_join_lobby", vars = { disabled } }) + ) + return + end + MP.LOBBY.config.ruleset = v + goto continue + end + if k == "gamemode" then + MP.LOBBY.config.gamemode = v + goto continue + end - -- Only update UI if deck, stake, or different_decks changed - if any_key_changed({ "stake", "back", "different_decks" }, old_config, new_config) then - if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end - set_main_menu_UI() - end + local parsed_v = v + if v == "true" then + parsed_v = true + elseif v == "false" then + parsed_v = false + end - update_overlay_toggles(changed_keys) + if + k == "starting_lives" + or k == "pvp_start_round" + or k == "timer_base_seconds" + or k == "timer_increment_seconds" + or k == "showdown_starting_antes" + or k == "pvp_countdown_seconds" + or k == "timer_forgiveness" + then + parsed_v = tonumber(v) + end - if old_config.different_decks ~= new_config.different_decks then + MP.LOBBY.config[k] = parsed_v + if G.OVERLAY_MENU then + local config_uie = G.OVERLAY_MENU:get_UIE_by_ID(k .. "_toggle") + if config_uie then + G.FUNCS.toggle(config_uie) + end + end + ::continue:: + end + if different_decks_before ~= MP.LOBBY.config.different_decks then G.FUNCS.exit_overlay_menu() -- throw out guest from any menu. end + MP.ACTIONS.update_player_usernames() -- render new DECK button state end local function action_send_phantom(key) @@ -377,63 +387,91 @@ local function action_speedrun() SMODS.calculate_context({ mp_speedrun = true }) end +local function enemyLocation(options) + local location = options.location + local value = "" + + if string.find(location, "-") then + local split = {} + for str in string.gmatch(location, "([^-]+)") do + table.insert(split, str) + end + location = split[1] + value = split[2] + end + + loc_name = localize({ type = "name_text", key = value, set = "Blind" }) + if loc_name ~= "ERROR" then + value = loc_name + else + value = (G.P_BLINDS[value] and G.P_BLINDS[value].name) or value + end + + loc_location = G.localization.misc.dictionary[location] + + if loc_location == nil then + if location ~= nil then + loc_location = location + else + loc_location = "Unknown" + end + end + + MP.GAME.enemy.location = loc_location .. value +end + local function action_version() MP.ACTIONS.version() end local action_asteroid = action_asteroid - or function() - local hand_priority = { - ["Flush Five"] = 1, - ["Flush House"] = 2, - ["Five of a Kind"] = 3, - ["Straight Flush"] = 4, - ["Four of a Kind"] = 5, - ["Full House"] = 6, - ["Flush"] = 7, - ["Straight"] = 8, - ["Three of a Kind"] = 9, - ["Two Pair"] = 11, - ["Pair"] = 12, - ["High Card"] = 13, - } - local hand_type = "High Card" - local max_level = 0 - - for k, v in pairs(G.GAME.hands) do - if v.visible then - if - to_big(v.level) > to_big(max_level) - or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type]) - then - hand_type = k - max_level = v.level - end + or function() + local hand_priority = { + ["Flush Five"] = 1, + ["Flush House"] = 2, + ["Five of a Kind"] = 3, + ["Straight Flush"] = 4, + ["Four of a Kind"] = 5, + ["Full House"] = 6, + ["Flush"] = 7, + ["Straight"] = 8, + ["Three of a Kind"] = 9, + ["Two Pair"] = 11, + ["Pair"] = 12, + ["High Card"] = 13, + } + local hand_type = "High Card" + local max_level = 0 + + for k, v in pairs(G.GAME.hands) do + if v.visible then + if + to_big(v.level) > to_big(max_level) + or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type]) + then + hand_type = k + max_level = v.level end end - update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, { - handname = localize(hand_type, "poker_hands"), - chips = G.GAME.hands[hand_type].chips, - mult = G.GAME.hands[hand_type].mult, - level = G.GAME.hands[hand_type].level, - }) - level_up_hand(nil, hand_type, false, -1) - update_hand_text( - { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 }, - { mult = 0, chips = 0, handname = "", level = "" } - ) end + update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, { + handname = localize(hand_type, "poker_hands"), + chips = G.GAME.hands[hand_type].chips, + mult = G.GAME.hands[hand_type].mult, + level = G.GAME.hands[hand_type].level, + }) + level_up_hand(nil, hand_type, false, -1) + update_hand_text( + { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 }, + { mult = 0, chips = 0, handname = "", level = "" } + ) + end local function action_sold_joker() -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked - local enemy = MP.UTILS.get_nemesis() - if not enemy then return end - enemy.sells = (enemy.sells or 0) + 1 - if not enemy.sells_per_ante then - enemy.sells_per_ante = {} - end - enemy.sells_per_ante[G.GAME.round_resets.ante] = ( - (enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1 + MP.GAME.enemy.sells = MP.GAME.enemy.sells + 1 + MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] = ( + (MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1 ) end @@ -451,35 +489,8 @@ local function action_eat_pizza(discards) ease_discard(discards) end -local function action_spent_last_shop(player_id, amount) - -- TODO make support more than one player - local enemy = MP.UTILS.get_nemesis() - if not enemy then - sendWarnMessage("No enemy found for spent_last_shop action", "MULTIPLAYER") - return - end - - if not enemy.spent_in_shop then - enemy.spent_in_shop = {} - end - - enemy.spent_in_shop[#enemy.spent_in_shop + 1] = tonumber(amount) -end - -local function action_set_lobby_ready(isReady, player_id) - MP.UTILS.get_player_by_id(player_id).isReady = isReady - - if not MP.LOBBY.isHost then return end - local ready_check = true - - for _, player in ipairs(MP.LOBBY.players) do - if player.id ~= MP.LOBBY.local_id then - ready_check = ready_check and player.isReady - end - end - - MP.LOBBY.ready_to_start = ready_check - MP.ACTIONS.update_player_usernames() +local function action_spent_last_shop(amount) + MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = tonumber(amount) end local function action_magnet() @@ -522,7 +533,7 @@ local function action_magnet_response(key) end local card = - Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base) + Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base) -- Avoid crashing if the load function ends up indexing a nil value success, err = pcall(card.load, card, card_save) if not success then @@ -589,7 +600,7 @@ end local function action_get_end_game_jokers() if not G.jokers or not G.jokers.cards then - Client.send(json.encode({ action = "receiveEndGameJokers", keys = "" })) + Client.send("action:receiveEndGameJokers,keys:") return end @@ -603,24 +614,58 @@ local function action_get_end_game_jokers() local jokers_save = G.jokers:save() local jokers_encoded = MP.UTILS.str_pack_and_encode(jokers_save) - Client.send(json.encode({ action = "receiveEndGameJokers", keys = jokers_encoded })) + Client.send(string.format("action:receiveEndGameJokers,keys:%s", jokers_encoded)) +end + +local function action_get_nemesis_deck() + local deck_str = "" + for _, card in ipairs(G.playing_cards) do + deck_str = deck_str .. ";" .. MP.UTILS.card_to_string(card) + end + Client.send(string.format("action:receiveNemesisDeck,cards:%s", deck_str)) end -function G.FUNCS.load_player_deck(player) - if not MP.LOBBY.code or not player.deck_str then +local function action_send_game_stats() + if not MP.GAME.stats then + Client.send("action:nemesisEndGameStats") return end - if not player.cards then player.cards = {} end + local stats_str = string.format( + "reroll_count:%d,reroll_cost_total:%d", + MP.GAME.stats.reroll_count, + MP.GAME.stats.reroll_cost_total + ) + + -- Extract voucher keys where value is true and join them with a dash + local voucher_keys = "" + if G.GAME.used_vouchers then + local keys = {} + for k, v in pairs(G.GAME.used_vouchers) do + if v == true then + table.insert(keys, k) + end + end + voucher_keys = table.concat(keys, "-") + end + + -- Add voucher keys to stats string + if voucher_keys ~= "" then + stats_str = stats_str .. string.format(",vouchers:%s", voucher_keys) + end + + Client.send(string.format("action:nemesisEndGameStats,%s", stats_str)) +end - if not player.deck then - player.deck = CardArea(-100, -100, G.CARD_W, G.CARD_H, { type = 'deck' }) +function G.FUNCS.load_nemesis_deck() + if not MP.nemesis_deck_string or not MP.nemesis_deck or not MP.nemesis_cards or not MP.LOBBY.code then + return end - local card_strings = MP.UTILS.string_split(player.deck_str, ";") + local card_strings = MP.UTILS.string_split(MP.nemesis_deck_string, ";") - for k, _ in pairs(player.cards) do - player.cards[k] = nil + for k, _ in pairs(MP.nemesis_cards) do + MP.nemesis_cards[k] = nil end for _, card_str in pairs(card_strings) do @@ -658,13 +703,10 @@ function G.FUNCS.load_player_deck(player) end -- Create the card - local card = create_playing_card( - { - front = G.P_CARDS[front_key], - center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil - }, - player.deck, true, true, nil, false - ) + local card = create_playing_card({ + front = G.P_CARDS[front_key], + center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil, + }, MP.nemesis_deck, true, true, nil, false) if edition ~= "none" then card:set_edition({ [edition] = true }, true, true) end @@ -674,21 +716,19 @@ function G.FUNCS.load_player_deck(player) -- Remove the card from G.playing_cards and insert into MP.nemesis_cards table.remove(G.playing_cards, #G.playing_cards) - table.insert(player.cards, card) + table.insert(MP.nemesis_cards, card) ::continue:: end end -local function action_receive_player_deck(player_id, cards) - local player = MP.UTILS.get_player_by_id(player_id) - player.deck_str = cards - player.deck_received = true - G.FUNCS.load_player_deck(player) +local function action_receive_nemesis_deck(deck_str) + MP.nemesis_deck_string = deck_str + MP.nemesis_deck_received = true + G.FUNCS.load_nemesis_deck() end --- Special cases since they're used elsewhere -function MP.action_start_ante_timer(time) +local function action_start_ante_timer(time) if type(time) == "string" then time = tonumber(time) end @@ -697,7 +737,7 @@ function MP.action_start_ante_timer(time) G.E_MANAGER:add_event(MP.timer_event) end -function MP.action_pause_ante_timer(time) +local function action_pause_ante_timer(time) if type(time) == "string" then time = tonumber(time) end @@ -705,53 +745,224 @@ function MP.action_pause_ante_timer(time) MP.GAME.timer_started = false end -local action_table = { - connected = function() action_connected() end, - version = function() action_version() end, - disconnected = function() action_disconnected() end, - invalidLobby = function() action_invalidLobby() end, - joinedLobby = function(parsedAction) action_joinedLobby(parsedAction.code, parsedAction.type) end, - lobbyInfo = function(parsedAction) action_lobbyInfo(parsedAction) end, - startGame = function(parsedAction) action_start_game(parsedAction.players, parsedAction.seed, parsedAction.stake) end, - startBlind = function() action_start_blind() end, - setLobbyReady = function(parsedAction) action_set_lobby_ready(parsedAction.isReady, parsedAction.playerId) end, - gameStateUpdate = function(parsedAction) action_game_state_update(parsedAction.id, parsedAction.updates) end, - stopGame = function() action_stop_game() end, - endPvP = function() action_end_pvp() end, - winGame = function() action_win_game() end, - loseGame = function() action_lose_game() end, - lobbyOptions = function(parsedAction) action_lobby_options(parsedAction.options) end, - setBossBlind = function(parsedAction) action_set_boss_blind(parsedAction.bossKey) end, - sendPhantom = function(parsedAction) action_send_phantom(parsedAction.key) end, - removePhantom = function(parsedAction) action_remove_phantom(parsedAction.key) end, - speedrun = function() action_speedrun() end, - asteroid = function() action_asteroid() end, - soldJoker = function() action_sold_joker() end, - letsGoGamblingNemesis = function() action_lets_go_gambling_nemesis() end, - eatPizza = function(parsedAction) action_eat_pizza(parsedAction.discards) end, - spentLastShop = function(parsedAction) action_spent_last_shop(parsedAction.playerId, parsedAction.amount) end, - magnet = function() action_magnet() end, - magnetResponse = function(parsedAction) action_magnet_response(parsedAction.key) end, - getEndGameJokers = function() action_get_end_game_jokers() end, - receiveEndGameJokers = function(parsedAction) action_receive_end_game_jokers(parsedAction.keys) end, - receivePlayerDeck = function(parsedAction) action_receive_player_deck(parsedAction.playerId, parsedAction.cards) end, - startAnteTimer = function(parsedAction) MP.action_start_ante_timer(parsedAction.time) end, - pauseAnteTimer = function(parsedAction) MP.action_pause_ante_timer(parsedAction.time) end, - error = function(parsedAction) action_error(parsedAction.message) end, - keepAlive = function() action_keep_alive() end, -} - -function MP.NETWORKING.update(dt) +-- #region Client to Server +function MP.ACTIONS.create_lobby(gamemode) + Client.send(string.format("action:createLobby,gameMode:%s", gamemode)) +end + +function MP.ACTIONS.join_lobby(code) + Client.send(string.format("action:joinLobby,code:%s", code)) +end + +function MP.ACTIONS.ready_lobby() + Client.send("action:readyLobby") +end + +function MP.ACTIONS.unready_lobby() + Client.send("action:unreadyLobby") +end + +function MP.ACTIONS.lobby_info() + Client.send("action:lobbyInfo") +end + +function MP.ACTIONS.leave_lobby() + Client.send("action:leaveLobby") +end + +function MP.ACTIONS.start_game() + Client.send("action:startGame") +end + +function MP.ACTIONS.ready_blind(e) + MP.GAME.next_blind_context = e + Client.send("action:readyBlind") +end + +function MP.ACTIONS.unready_blind() + Client.send("action:unreadyBlind") +end + +function MP.ACTIONS.stop_game() + Client.send("action:stopGame") +end + +function MP.ACTIONS.fail_round(hands_used) + if MP.LOBBY.config.no_gold_on_round_loss then + G.GAME.blind.dollars = 0 + end + if hands_used == 0 then + return + end + Client.send("action:failRound") +end + +function MP.ACTIONS.version() + Client.send(string.format("action:version,version:%s", MULTIPLAYER_VERSION)) +end + +function MP.ACTIONS.set_location(location) + if MP.GAME.location == location then + return + end + MP.GAME.location = location + Client.send(string.format("action:setLocation,location:%s", location)) +end + +---@param score number +---@param hands_left number +function MP.ACTIONS.play_hand(score, hands_left) + local fixed_score = tostring(to_big(score)) + -- Credit to sidmeierscivilizationv on discord for this fix for Talisman + if string.match(fixed_score, "[eE]") == nil and string.match(fixed_score, "[.]") then + -- Remove decimal from non-exponential numbers + fixed_score = string.sub(string.gsub(fixed_score, "%.", ","), 1, -3) + end + fixed_score = string.gsub(fixed_score, ",", "") -- Remove commas + + local insane_int_score = MP.INSANE_INT.from_string(fixed_score) + if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.highest_score) then + MP.GAME.highest_score = insane_int_score + end + Client.send(string.format("action:playHand,score:" .. fixed_score .. ",handsLeft:%d", hands_left)) +end + +function MP.ACTIONS.lobby_options() + local msg = "action:lobbyOptions" + for k, v in pairs(MP.LOBBY.config) do + msg = msg .. string.format(",%s:%s", k, tostring(v)) + end + Client.send(msg) +end + +function MP.ACTIONS.set_ante(ante) + Client.send(string.format("action:setAnte,ante:%d", ante)) +end + +function MP.ACTIONS.new_round() + + MP.GAME.duplicate_end = false + MP.GAME.round_ended = false + Client.send("action:newRound") +end + +function MP.ACTIONS.set_furthest_blind(furthest_blind) + Client.send(string.format("action:setFurthestBlind,furthestBlind:%d", furthest_blind)) +end + +function MP.ACTIONS.skip(skips) + Client.send("action:skip,skips:" .. tostring(skips)) +end + +function MP.ACTIONS.send_phantom(key) + Client.send("action:sendPhantom,key:" .. key) +end + +function MP.ACTIONS.remove_phantom(key) + Client.send("action:removePhantom,key:" .. key) +end + +function MP.ACTIONS.asteroid() + Client.send("action:asteroid") +end + +function MP.ACTIONS.sold_joker() + Client.send("action:soldJoker") +end + +function MP.ACTIONS.lets_go_gambling_nemesis() + Client.send("action:letsGoGamblingNemesis") +end + +function MP.ACTIONS.eat_pizza(discards) + Client.send("action:eatPizza,whole:" .. tostring(discards)) +end + +function MP.ACTIONS.spent_last_shop(amount) + Client.send("action:spentLastShop,amount:" .. tostring(amount)) +end + +function MP.ACTIONS.magnet() + Client.send("action:magnet") +end + +function MP.ACTIONS.magnet_response(key) + Client.send("action:magnetResponse,key:" .. key) +end + +function MP.ACTIONS.get_end_game_jokers() + Client.send("action:getEndGameJokers") +end + +function MP.ACTIONS.get_nemesis_deck() + Client.send("action:getNemesisDeck") +end + +function MP.ACTIONS.send_game_stats() + Client.send("action:sendGameStats") + action_send_game_stats() +end + +function MP.ACTIONS.request_nemesis_stats() + Client.send("action:endGameStatsRequested") +end + +function MP.ACTIONS.start_ante_timer() + Client.send("action:startAnteTimer,time:" .. tostring(MP.GAME.timer)) + action_start_ante_timer(MP.GAME.timer) +end + +function MP.ACTIONS.pause_ante_timer() + Client.send("action:pauseAnteTimer,time:" .. tostring(MP.GAME.timer)) + action_pause_ante_timer(MP.GAME.timer) -- TODO +end + +function MP.ACTIONS.fail_timer() + Client.send("action:failTimer") +end + +function MP.ACTIONS.sync_client() + Client.send("action:syncClient,isCached:" .. tostring(_RELEASE_MODE)) +end + +-- #endregion Client to Server + +-- Utils +function MP.ACTIONS.connect() + Client.send("connect") +end + +function MP.ACTIONS.update_player_usernames() + if MP.LOBBY.code then + if G.MAIN_MENU_UI then + G.MAIN_MENU_UI:remove() + end + set_main_menu_UI() + end +end + +local function string_to_table(str) + local tbl = {} + for part in string.gmatch(str, "([^,]+)") do + local key, value = string.match(part, "([^:]+):(.+)") + if key and value then + tbl[key] = value + end + end + return tbl +end + +local last_game_seed = nil + +local game_update_ref = Game.update +---@diagnostic disable-next-line: duplicate-set-field +function Game:update(dt) + game_update_ref(self, dt) + repeat local msg = love.thread.getChannel("networkToUi"):pop() - -- if message not starting with { wrap msg string with {} - if msg then - local ok, parsedAction = pcall(json.decode, msg) - if not ok or type(parsedAction) ~= "table" then - sendWarnMessage("Received non-JSON message: " .. tostring(msg), "MULTIPLAYER") - return - end + local parsedAction = string_to_table(msg) if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then local log = string.format("Client got %s message: ", parsedAction.action) @@ -763,17 +974,93 @@ function MP.NETWORKING.update(dt) end end if - (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") - and last_game_seed + (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") + and last_game_seed then log = log .. string.format(" (seed: %s) ", last_game_seed) end sendTraceMessage(log, "MULTIPLAYER") end - local handler = action_table[parsedAction.action] - if handler then - handler(parsedAction) + if parsedAction.action == "connected" then + action_connected() + elseif parsedAction.action == "version" then + action_version() + elseif parsedAction.action == "disconnected" then + action_disconnected() + elseif parsedAction.action == "joinedLobby" then + action_joinedLobby(parsedAction.code, parsedAction.type) + elseif parsedAction.action == "lobbyInfo" then + action_lobbyInfo( + parsedAction.host, + parsedAction.hostHash, + parsedAction.hostCached, + parsedAction.guest, + parsedAction.guestHash, + parsedAction.guestCached, + parsedAction.guestReady, + parsedAction.isHost + ) + elseif parsedAction.action == "startGame" then + action_start_game(parsedAction.seed, parsedAction.stake) + elseif parsedAction.action == "startBlind" then + action_start_blind() + elseif parsedAction.action == "enemyInfo" then + action_enemy_info(parsedAction.score, parsedAction.handsLeft, parsedAction.skips, parsedAction.lives) + elseif parsedAction.action == "stopGame" then + action_stop_game() + elseif parsedAction.action == "endPvP" then + action_end_pvp() + elseif parsedAction.action == "playerInfo" then + action_player_info(parsedAction.lives) + elseif parsedAction.action == "winGame" then + action_win_game() + elseif parsedAction.action == "loseGame" then + action_lose_game() + elseif parsedAction.action == "lobbyOptions" then + action_lobby_options(parsedAction) + elseif parsedAction.action == "enemyLocation" then + enemyLocation(parsedAction) + elseif parsedAction.action == "sendPhantom" then + action_send_phantom(parsedAction.key) + elseif parsedAction.action == "removePhantom" then + action_remove_phantom(parsedAction.key) + elseif parsedAction.action == "speedrun" then + action_speedrun() + elseif parsedAction.action == "asteroid" then + action_asteroid() + elseif parsedAction.action == "soldJoker" then + action_sold_joker() + elseif parsedAction.action == "letsGoGamblingNemesis" then + action_lets_go_gambling_nemesis() + elseif parsedAction.action == "eatPizza" then + action_eat_pizza(parsedAction.whole) -- rename to "discards" when possible + elseif parsedAction.action == "spentLastShop" then + action_spent_last_shop(parsedAction.amount) + elseif parsedAction.action == "magnet" then + action_magnet() + elseif parsedAction.action == "magnetResponse" then + action_magnet_response(parsedAction.key) + elseif parsedAction.action == "getEndGameJokers" then + action_get_end_game_jokers() + elseif parsedAction.action == "receiveEndGameJokers" then + action_receive_end_game_jokers(parsedAction.keys) + elseif parsedAction.action == "getNemesisDeck" then + action_get_nemesis_deck() + elseif parsedAction.action == "receiveNemesisDeck" then + action_receive_nemesis_deck(parsedAction.cards) + elseif parsedAction.action == "endGameStatsRequested" then + action_send_game_stats() + elseif parsedAction.action == "nemesisEndGameStats" then + -- Handle receiving game stats (is only logged now, now shown in the ui) + elseif parsedAction.action == "startAnteTimer" then + action_start_ante_timer(parsedAction.time) + elseif parsedAction.action == "pauseAnteTimer" then + action_pause_ante_timer(parsedAction.time) + elseif parsedAction.action == "error" then + action_error(parsedAction.message) + elseif parsedAction.action == "keepAlive" then + action_keep_alive() end end until not msg diff --git a/networking/socket.lua b/networking/socket.lua index cff0fc64..62d7cc6c 100644 --- a/networking/socket.lua +++ b/networking/socket.lua @@ -4,7 +4,6 @@ -- the necessary modules again return [[ local CONFIG_URL, CONFIG_PORT = ... -local json = require("json") require("love.filesystem") local socket = require("socket") @@ -37,10 +36,6 @@ local uiToNetworkChannel = love.thread.getChannel("uiToNetwork") function Networking.connect() -- TODO: Check first if Networking.Client is not null -- and if it is, skip this function - if Networking.Client and not isSocketClosed then - Networking.Client:close() - isSocketClosed = true - end SEND_THREAD_DEBUG_MESSAGE( string.format("Attempting to connect to multiplayer server... URL: %s, PORT: %d", CONFIG_URL, CONFIG_PORT) @@ -55,13 +50,7 @@ function Networking.connect() if connectionResult ~= 1 then SEND_THREAD_DEBUG_MESSAGE(string.format("%s", errorMessage)) - - local errorMsg = { - action = "error", - message = "Failed to connect to multiplayer server" - } - - networkToUiChannel:push(json.encode(errorMsg)) + networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server") else isSocketClosed = false end @@ -78,11 +67,10 @@ local mainThreadMessageQueue = function() for _ = 1, requestsPerCycle do local msg = uiToNetworkChannel:pop() if msg then - if msg == "connect" then - Networking.connect() - else - -- Send any non-empty message (JSON or otherwise) to the server + if msg:find("^action") ~= nil then Networking.Client:send(msg .. "\n") + elseif msg == "connect" then + Networking.connect() end else -- If there are no more messages, yield @@ -138,12 +126,7 @@ local networkPacketQueue = function() isRetry = false timerCoroutine = coroutine.create(timer) - - local disconnectedAction = { - action = "disconnected", - message = "Connection closed by server", - } - networkToUiChannel:push(json.encode(disconnectedAction)) + networkToUiChannel:push("action:disconnected") else -- If there are no more packets, yield coroutine.yield() @@ -183,17 +166,13 @@ while true do timerCoroutine = coroutine.create(timer) - local disconnectedAction = { - action = "disconnected", - message = "Connection closed due to inactivity", - } - networkToUiChannel:push(json.encode(disconnectedAction)) + networkToUiChannel:push("action:disconnected") end if isRetry then retryCount = retryCount + 1 -- Send keepAlive without cutting the line - uiToNetworkChannel:push(json.encode({ action = "keepAlive" })) + uiToNetworkChannel:push("action:keepAlive") -- Restart the timer timerCoroutine = coroutine.create(timer) diff --git a/rulesets/sandbox.lua b/rulesets/sandbox.lua new file mode 100644 index 00000000..ec4dc1a1 --- /dev/null +++ b/rulesets/sandbox.lua @@ -0,0 +1,380 @@ +MP.SANDBOX = {} + +MP.Ruleset({ + key = "sandbox", + standard = true, + multiplayer_content = true, + banned_jokers = { + "j_cloud_9", + "j_bloodstone", + }, + banned_consumables = { + "c_justice", + }, + banned_vouchers = {}, + banned_enhancements = {}, + banned_tags = { "tag_rare" }, + banned_blinds = {}, + + reworked_jokers = { + "j_mp_cloud_9", + "j_mp_bloodstone", + "j_hanging_chad", + "j_idol", + "j_square", + }, + reworked_consumables = {}, + reworked_vouchers = {}, + reworked_enhancements = { + "m_glass", + }, + reworked_blinds = {}, + reworked_tags = { "tag_mp_sandbox_rare" }, + + create_info_menu = function () + return { + { + n = G.UIT.R, + config = { + align = "tm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize("k_has_multiplayer_content"), { + { + n = G.UIT.T, + config = { + text = localize("k_yes"), + scale = 0.8, + colour = G.C.GREEN, + } + } + }, {col = true, text_scale = 0.6}), + { + n = G.UIT.C, + config = { + minw = 0.1, + minh = 0.1 + } + }, + MP.UI.BackgroundGrouping(localize("k_forces_lobby_options"), { + { + n = G.UIT.T, + config = { + text = localize("k_no"), + scale = 0.8, + colour = G.C.RED, + } + } + }, {col = true, text_scale = 0.6}), + { + n = G.UIT.C, + config = { + minw = 0.1, + minh = 0.1 + } + }, + MP.UI.BackgroundGrouping(localize("k_forces_gamemode"), { + { + n = G.UIT.T, + config = { + text = localize("k_no"), + scale = 0.8, + colour = G.C.RED, + } + } + }, {col = true, text_scale = 0.6}) + }, + }, + { + n = G.UIT.R, + config = { + minw = 0.05, + minh = 0.05 + } + }, + { + n = G.UIT.R, + config = { + align = "cl", + padding = 0.1 + }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_sandbox_description"), + scale = 0.6, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + } + end, + + is_disabled = function(self) + if not MP.INTEGRATIONS.TheOrder then + return localize("k_ruleset_disabled_the_order_required") + end + return false + end, + + -- todo this would be sick + overrides = function() + print("Override for sandbox called") + end, +}):inject() + +-- Oops artwork - no functional changes but visual identity for sandbox +SMODS.Atlas({ + key = "sandbox_oops", + path = "j_sandbox_oops2.png", + px = 71, + py = 95, +}) + +MP.ReworkCenter({ + key = "j_oops", + atlas = "mp_sandbox_oops", + pos = { x = 0, y = 0 }, + ruleset = "sandbox", + silent = true, +}) + +MP.ReworkCenter({ + key = "j_square", + ruleset = "sandbox", + config = { extra = { chips = 64, chip_mod = 4 } }, +}) + +MP.ReworkCenter({ + key = "j_idol", + ruleset = "sandbox", + rarity = 3, + cost = 8, +}) + +-- Global state for persistent bias across bloodstone calls +if not MP.bloodstone_bias then + MP.starting_bloodstone_bias = 0.2 + MP.bloodstone_bias = MP.starting_bloodstone_bias +end + +-- your rng complaints have been noted and filed accordingly +function cope_and_seethe_check(actual_odds) + if actual_odds >= 1 then + return true + end + + -- how much easier (30%) do we make it for each successive roll? + local step = -0.3 + local roll = pseudorandom("bloodstone") + MP.bloodstone_bias + + if roll < actual_odds then + MP.bloodstone_bias = MP.starting_bloodstone_bias + return true + else + MP.bloodstone_bias = MP.bloodstone_bias + step + return false + end +end + +SMODS.Joker({ + key = "bloodstone", + unlocked = true, + discovered = true, + blueprint_compat = true, + perishable_compat = true, + eternal_compat = true, + rarity = 3, + cost = 7, + pos = { x = 0, y = 8 }, + no_collection = true, + in_pool = function(self) + return MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" and MP.LOBBY.code + end, + config = { extra = { odds = 2, Xmult = 1.5 }, mp_sticker_balanced = true }, + loc_vars = function(self, info_queue, card) + return { + vars = { + "" .. (G.GAME and G.GAME.probabilities.normal or 1), + card.ability.extra.odds, + card.ability.extra.Xmult, + }, + } + end, + calculate = function(self, card, context) + if context.cardarea == G.play and context.individual then + if context.other_card:is_suit("Hearts") then + local bloodstone_hit = cope_and_seethe_check(G.GAME.probabilities.normal / card.ability.extra.odds) + if bloodstone_hit then + return { + extra = { x_mult = card.ability.extra.Xmult }, + message = G.GAME.probabilities.normal < 2 and "Cope!" or nil, + sound = "voice2", + volume = 0.3, + card = card, + } + end + end + end + end, +}) + +SMODS.Joker({ + key = "cloud_9", + no_collection = true, + unlocked = true, + discovered = true, + blueprint_compat = false, + perishable_compat = true, + eternal_compat = true, + rarity = 2, + cost = 7, + pos = { x = 7, y = 12 }, + config = { extra = 2, mp_sticker_balanced = true }, + loc_vars = function(self, info_queue, card) + local nine_tally = 0 + if G.playing_cards ~= nil then + for k, v in pairs(G.playing_cards) do + if v:get_id() == 9 then + nine_tally = nine_tally + 1 + end + end + end + + return { + vars = { + card.ability.extra, + (math.min(nine_tally, 4) + math.max(nine_tally - 4, 0) * card.ability.extra) or 0, + }, + } + end, + in_pool = function(self) + return MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" and MP.LOBBY.code + end, + calc_dollar_bonus = function(self, card) + local nine_tally = 0 + for k, v in pairs(G.playing_cards) do + if v:get_id() == 9 then + nine_tally = nine_tally + 1 + end + end + return (math.min(nine_tally, 4) + math.max(nine_tally - 4, 0) * card.ability.extra) or 0 + end, +}) + +SMODS.Atlas({ + key = "sandbox_rare", + path = "tag_rare.png", + px = 32, + py = 32, +}) + +-- Tag: 1 in 2 chance to generate a rare joker in shop +SMODS.Tag({ + key = "sandbox_rare", + atlas = "sandbox_rare", + object_type = "Tag", + dependencies = { + items = {}, + }, + in_pool = function(self) + return MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" and MP.LOBBY.code + end, + name = "Rare Tag", + discovered = true, + order = 2, + min_ante = 2, -- less degeneracy + no_collection = true, + config = { + type = "store_joker_create", + odds = 2, + }, + requires = "j_blueprint", + loc_vars = function(self) + return { vars = { G.GAME.probabilities.normal or 1, self.config.odds } } + end, + apply = function(self, tag, context) + if context.type == "store_joker_create" then + local card = nil + -- 1 in 2 chance to proc + if pseudorandom("tagroll") < G.GAME.probabilities.normal / tag.config.odds then + -- count owned rare jokers to prevent duplicates + local rares_owned = { 0 } + for k, v in ipairs(G.jokers.cards) do + if v.config.center.rarity == 3 and not rares_owned[v.config.center.key] then + rares_owned[1] = rares_owned[1] + 1 + rares_owned[v.config.center.key] = true + end + end + + -- only proc if unowned rares exist + -- funny edge case that i've never seen happen, but if localthunk saw it i will obey + if #G.P_JOKER_RARITY_POOLS[3] > rares_owned[1] then + card = create_card("Joker", context.area, nil, 1, nil, nil, nil, "rta") + create_shop_card_ui(card, "Joker", context.area) + card.states.visible = false + tag:yep("+", G.C.RED, function() + card:start_materialize() + card.ability.couponed = true -- free card + card:set_cost() + return true + end) + else + tag:nope() -- all rares owned + end + else + tag:nope() -- failed roll + end + tag.triggered = true + return card + end + end, +}) + +-- Standard pack card creation for sandbox ruleset +-- Skips glass enhancement (excluded from enhancement pool) +-- 40% chance (0.6 threshold) for any enhancement to be applied (like vanilla) +function sandbox_create_card(self, card, i) + local enhancement_pool = {} + + -- Skip glass + for k, v in pairs(G.P_CENTER_POOLS["Enhanced"]) do + if v.key ~= "m_glass" then + enhancement_pool[#enhancement_pool + 1] = v.key + end + end + + local ante_rng = MP.ante_based() + local roll = pseudorandom(pseudoseed("stdc1" .. ante_rng)) + local enhancement = roll > 0.6 and pseudorandom_element(enhancement_pool, pseudoseed("stdc2" .. ante_rng)) or nil + + local s_append = "" + local b_append = ante_rng .. s_append + + local _edition = poll_edition("standard_edition" .. b_append, 2, true) + local _seal = SMODS.poll_seal({ mod = 10, key = "stdseal" .. ante_rng }) + + return { + set = "Base", + edition = _edition, + seal = _seal, + enhancement = enhancement, + area = G.pack_cards, + skip_materialize = true, + soulable = true, + key_append = "sta" .. s_append, + } +end + +for k, v in ipairs(G.P_CENTER_POOLS.Booster) do + if v.kind and v.kind == "Standard" then + MP.ReworkCenter({ + key = v.key, + ruleset = "sandbox", + silent = true, + create_card = sandbox_create_card, + }) + end +end diff --git a/rulesets/weeklies/smallworld.lua b/rulesets/weeklies/smallworld.lua new file mode 100644 index 00000000..008011f1 --- /dev/null +++ b/rulesets/weeklies/smallworld.lua @@ -0,0 +1,118 @@ +MP.Ruleset({ -- just a copy of ranked... and every weekly ruleset in vault is intended to copy paste like this....... maybe this could be more efficient? + key = "weekly", + multiplayer_content = true, + standard = true, + + banned_jokers = {}, + banned_consumables = { + "c_justice", + }, + banned_vouchers = {}, + banned_enhancements = {}, + banned_tags = {}, + banned_blinds ={}, + reworked_jokers = { + "j_hanging_chad", + "j_mp_conjoined_joker", + "j_mp_defensive_joker", + "j_mp_lets_go_gambling", + "j_mp_pacifist", + "j_mp_penny_pincher", + "j_mp_pizza", + "j_mp_skip_off", + "j_mp_speedrun", + "j_mp_taxes", + }, + reworked_consumables = { + "c_mp_asteroid" + }, + reworked_vouchers = {}, + reworked_enhancements = { + "m_glass" + }, + reworked_tags = {}, + reworked_blinds = { + "bl_mp_nemesis" + }, + create_info_menu = function () + return {{ + n = G.UIT.R, + config = { + align = "tm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = MP.UTILS.wrapText(localize("k_weekly_description") .. localize("k_weekly_smallworld"), 100), + scale = 0.4, + colour = G.C.UI.TEXT_LIGHT, + } + } + } + }} + end, + forced_gamemode = "gamemode_mp_attrition", + forced_lobby_options = true, + is_disabled = function(self) + local required_version = "1.0.0~BETA-0506a" + if SMODS.version ~= required_version then + return localize({type = "variable", key="k_ruleset_disabled_smods_version", vars = {required_version}}) + end + if not MP.INTEGRATIONS.TheOrder then + return localize("k_ruleset_disabled_the_order_required") + end + return false + end +}):inject() + +local apply_bans_ref = MP.ApplyBans +function MP.ApplyBans() + local ret = apply_bans_ref() + if MP.LOBBY.code and MP.UTILS.is_weekly('smallworld') then + local tables = {} + for k, v in pairs(G.P_CENTERS) do + if v.set and (not G.GAME.banned_keys[k]) and not (v.requires or v.hidden) then + local index = v.set..(v.rarity or '') + tables[index] = tables[index] or {} + local t = tables[index] + t[#t+1] = k + end + end + for k, v in pairs(G.P_TAGS) do -- tag exemption + if not G.GAME.banned_keys[k] then + tables['Tag'] = tables['Tag'] or {} + local t = tables['Tag'] + t[#t+1] = k + end + end + for k, v in pairs(tables) do + if k ~= "Back" + and k ~= "Edition" + and k ~= "Enhanced" + and k ~= "Default" then + table.sort(v) + pseudoshuffle(v, pseudoseed(k..'_mp_smallworld')) + local threshold = math.floor( 0.5 + (#v*0.75) ) + local ii = 1 + for i, vv in ipairs(v) do + if ii <= threshold then + G.GAME.banned_keys[vv] = true + ii = ii + 1 + else break end + end + end + end + end + return ret +end + +local find_joker_ref = find_joker +function find_joker(name, non_debuff) + if MP.LOBBY.code and MP.UTILS.is_weekly('smallworld') then + if name == 'Showman' and not next(find_joker_ref('Showman', non_debuff)) then + return {{}} -- surely this doesn't break + end + end + return find_joker_ref(name, non_debuff) +end From 25400334df8b1335f698261a2ec19bd2ef121768 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:19:53 +0000 Subject: [PATCH 3/6] Add missing upstream components and assets, update gamemodes Co-authored-by: FilPag <1493826+FilPag@users.noreply.github.com> --- assets/1x/j_sandbox_oops2.png | Bin 0 -> 7510 bytes assets/1x/tag_rare.png | Bin 0 -> 1793 bytes assets/2x/j_sandbox_oops2.png | Bin 0 -> 15235 bytes assets/2x/tag_rare.png | Bin 0 -> 2059 bytes gamemodes/_gamemodes.lua | 13 + gamemodes/attrition.lua | 226 ++++++++++++++++- gamemodes/showdown.lua | 208 +++++++++++++++- gamemodes/survival.lua | 128 +++++++++- ui/components/background_grouping.lua | 12 + ui/components/blind_chip.lua | 26 ++ ui/components/disableable_button.lua | 17 ++ ui/components/disableable_option_cycle.lua | 12 + ui/components/disableable_toggle.lua | 12 + ui/components/lobby_code_buttons.lua | 28 +++ ui/components/lobby_deck_button.lua | 45 ++++ ui/components/lobby_gamemode_tab.lua | 87 +++++++ ui/components/lobby_main_button.lua | 32 +++ ui/components/lobby_options_tab.lua | 276 +++++++++++++++++++++ ui/components/lobby_status_display.lua | 108 ++++++++ ui/components/main_lobby_options.lua | 106 ++++++++ ui/components/players_section.lua | 75 ++++++ ui/components/utils.lua | 21 ++ 22 files changed, 1417 insertions(+), 15 deletions(-) create mode 100644 assets/1x/j_sandbox_oops2.png create mode 100644 assets/1x/tag_rare.png create mode 100644 assets/2x/j_sandbox_oops2.png create mode 100644 assets/2x/tag_rare.png create mode 100644 ui/components/background_grouping.lua create mode 100644 ui/components/blind_chip.lua create mode 100644 ui/components/disableable_button.lua create mode 100644 ui/components/disableable_option_cycle.lua create mode 100644 ui/components/disableable_toggle.lua create mode 100644 ui/components/lobby_code_buttons.lua create mode 100644 ui/components/lobby_deck_button.lua create mode 100644 ui/components/lobby_gamemode_tab.lua create mode 100644 ui/components/lobby_main_button.lua create mode 100644 ui/components/lobby_options_tab.lua create mode 100644 ui/components/lobby_status_display.lua create mode 100644 ui/components/main_lobby_options.lua create mode 100644 ui/components/players_section.lua create mode 100644 ui/components/utils.lua diff --git a/assets/1x/j_sandbox_oops2.png b/assets/1x/j_sandbox_oops2.png new file mode 100644 index 0000000000000000000000000000000000000000..68feee57e80a9de779a21a3e7bda9f92339bc4d7 GIT binary patch literal 7510 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 F{|AYL`!fIl literal 0 HcmV?d00001 diff --git a/assets/2x/tag_rare.png b/assets/2x/tag_rare.png new file mode 100644 index 0000000000000000000000000000000000000000..f14f5b96b37aa36f3ad44602fbd63a0c708c6d90 GIT binary patch literal 2059 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|j-^I;ruq6Z zXaU(A3~Y=-49p-UK*+!-#lQ+?GcbfPO2gT4j2ciiOh7e;3_y}W42aX(GZ|Q*>T7^B z2zUT7&?E>QkXezMlbcwQU!)LFl&@f{XQpRp%)r1hfdy=a2vFUo1&j#$7cjxib6CKP zU<1`L7#SFuS{ayI85t=UnpzoKSQ%O}*sWQ+04T*-;1OBOz`!jG!i)^F=14FwFmKEZ zi71Ki^|4CM&(%vz$xlkvtH>h?X&sHg;q@=(~U%$M(T(8_% zFTW^V-_X+1Qs2Nx-^fT8s6w~6GOr}DLN~8i8Da>`9GBGMUPVtA$B+ufw^7!9GL8as9c2y_HL-kB6zCLAlCdmeP=X5t}3W=hnU|s$aEgJL}#_p|@>g?|)ylJoCMUNZmi1bI)5>>1EBg>q+i> z*ekSj`;y5qTdxW9H%>U!ajqdkRrN{z-+jF+!)o{auQY$5f2`$PL&D8@>XJXN2~2-< z%*NO$$tj&di*07tpNoz=o+m8dwr57`_azd-R?H0{JeAo;^3NSlY(Dk>iwRHlaWi(G zw3Z_*T#QdYOrP`TP;$mO>q)-(>^br$@@t={|39`}{Bgp`mJQRN=9IE6Sukf)+RUbR zw;D^k-jp5JFjxQAKK`wp z(__DjT~Jtlkyq5vB>fyi8Pk(@X{R^auIKF4y{DkK+vYh??8dE%&&OZB^zw<{FT1|y z{;h`3YmYU|sXG_Bn)O62zeB^yYR9$r++$$Qeh}9n-1j%sd@pBH-hRQNCo3hHB|b`A za+tn!iFs<9s`8nv*M-rCj~={nX}Tn{$`gStVO2NHlN-zqmcCpq*>LZ>lZRH%Ro#uV z7^nT!P}M#g{UgkHf!QaGwyE)^%vtkpPqbOmns3Vdqvw2jsaz9C!LM!w{=A@3P28J8@WzDezPdd}O|u6G}v z{66({%sxr2zjGA?rcRj7q;s{|_FwLWuU9SQ_MSLBKV5(4?5zj7zMfnlaxrLc=j$cw z-?3`Ey&1H3$r|xCW-+@27RFwNHKI}U_BQ>2N+(pXUXTr7h)pl(*k6!aQs1~JW i_jtI@`CRjz=LdJx_2&VhRdYbqFN3G6pUXO@geCwRg3_x1 literal 0 HcmV?d00001 diff --git a/gamemodes/_gamemodes.lua b/gamemodes/_gamemodes.lua index bb4bff48..eb1bc8db 100644 --- a/gamemodes/_gamemodes.lua +++ b/gamemodes/_gamemodes.lua @@ -6,6 +6,19 @@ MP.Gamemode = SMODS.GameObject:extend({ required_params = { "key", "get_blinds_by_ante", -- Define custom logic for determining Small, Big, and Boss Blind based on the ante number. + "banned_jokers", + "banned_consumables", + "banned_vouchers", + "banned_enhancements", + "banned_tags", + "banned_blinds", + "reworked_jokers", + "reworked_consumables", + "reworked_vouchers", + "reworked_enhancements", + "reworked_tags", + "reworked_blinds", + "create_info_menu" }, class_prefix = "gamemode", inject = function(self) diff --git a/gamemodes/attrition.lua b/gamemodes/attrition.lua index 7be9ed88..1dddfbbe 100644 --- a/gamemodes/attrition.lua +++ b/gamemodes/attrition.lua @@ -1,13 +1,217 @@ MP.Gamemode({ - key = "attrition", - get_blinds_by_ante = function(self, ante, choices) - if ante >= MP.LOBBY.config.pvp_start_round then - if not MP.LOBBY.config.normal_bosses then - return choices.Small, choices.Big, "bl_mp_nemesis" - else - G.GAME.round_resets.pvp_blind_choices.Boss = true - end - end - return choices.Small, choices.Big , choices.Boss - end, + key = "attrition", + get_blinds_by_ante = function(self, ante) + if ante >= MP.LOBBY.config.pvp_start_round then + if not MP.LOBBY.config.normal_bosses then + return nil, nil, "bl_mp_nemesis" + else + G.GAME.round_resets.pvp_blind_choices.Boss = true + end + end + return nil, nil, nil + end, + banned_jokers = { + "j_mr_bones", + "j_luchador", + "j_matador", + "j_chicot", + }, + banned_consumables = {}, + banned_vouchers = { + "v_hieroglyph", + "v_petroglyph", + "v_directors_cut", + "v_retcon", + }, + banned_enhancements = {}, + banned_tags = { + "tag_boss" + }, + banned_blinds = { + "bl_wall", + "bl_final_vessel" + }, + reworked_jokers = {}, + reworked_consumables = {}, + reworked_vouchers = {}, + reworked_enhancements = {}, + reworked_tags = {}, + reworked_blinds = {}, + create_info_menu = function() + return { + { + n = G.UIT.R, + config = { + align = "tm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = MP.UTILS.wrapText(localize("k_attrition_description"), 70), + scale = 0.6, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + { + n = G.UIT.R, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.R, + config = { + align = "cm", + padding = 0.3 + }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize({ type = "variable", key = "k_ante_number", vars = { "1" }}), { + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.small() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.big() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.random() + } + }, + }, {text_scale = 0.6}), + } + }, + { + n = G.UIT.R, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize({ type = "variable", key = "k_ante_min", vars = { "2" }}), { + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.small() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.big() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.nemesis() + } + }, + }, {text_scale = 0.6}), + } + }, + }, + }, + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize("k_lives"), { + { + n = G.UIT.T, + config = { + text = "4", + scale = 1.5, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, {text_scale = 0.6}), + } + }, + } + } + }, + }, + { + n = G.UIT.R, + config = { + align = "bm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_values_are_modifiable"), + scale = 0.4, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + } + end }):inject() diff --git a/gamemodes/showdown.lua b/gamemodes/showdown.lua index 92c540d0..dbd8400d 100644 --- a/gamemodes/showdown.lua +++ b/gamemodes/showdown.lua @@ -1,9 +1,213 @@ MP.Gamemode({ key = "showdown", - get_blinds_by_ante = function(self, ante, choices) + get_blinds_by_ante = function(self, ante) if ante >= MP.LOBBY.config.showdown_starting_antes then return "bl_mp_nemesis", "bl_mp_nemesis", "bl_mp_nemesis" end - return choices.Small, choices.Big , choices.Boss + return nil, nil, nil end, + banned_jokers = { + "j_mr_bones", + "j_luchador", + "j_matador", + "j_chicot", + }, + banned_consumables = {}, + banned_vouchers = { + "v_hieroglyph", + "v_petroglyph", + "v_directors_cut", + "v_retcon", + }, + banned_enhancements = {}, + banned_tags = { + "tag_boss" + }, + banned_blinds = { + "bl_wall", + "bl_final_vessel" + }, + reworked_jokers = {}, + reworked_consumables = {}, + reworked_vouchers = {}, + reworked_enhancements = {}, + reworked_tags = {}, + reworked_blinds = {}, + create_info_menu = function() + return { + { + n = G.UIT.R, + config = { + align = "tm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = MP.UTILS.wrapText(localize("k_showdown_description"), 70), + scale = 0.6, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + { + n = G.UIT.R, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.R, + config = { + align = "cm", + padding = 0.3 + }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize({ type = "variable", key = "k_ante_range", vars = { "1", "2" }}), { + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.small() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.big() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.random() + } + }, + }, {text_scale = 0.6}), + } + }, + { + n = G.UIT.R, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize({ type = "variable", key = "k_ante_min", vars = { "3" }}), { + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.nemesis() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.nemesis() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.nemesis() + } + }, + }, {text_scale = 0.6}), + } + }, + }, + }, + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize("k_lives"), { + { + n = G.UIT.T, + config = { + text = "4", + scale = 1.5, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, {text_scale = 0.6}), + } + }, + } + } + }, + }, + { + n = G.UIT.R, + config = { + align = "bm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_values_are_modifiable"), + scale = 0.4, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + } + end }):inject() diff --git a/gamemodes/survival.lua b/gamemodes/survival.lua index d28759a7..33d4cd4e 100644 --- a/gamemodes/survival.lua +++ b/gamemodes/survival.lua @@ -1,6 +1,130 @@ MP.Gamemode({ key = "survival", - get_blinds_by_ante = function(self, ante, choices) - return choices.Small, choices.Big , choices.Boss + get_blinds_by_ante = function(self, ante) + return nil, nil, nil end, + banned_jokers = {}, + banned_consumables = {}, + banned_vouchers = {}, + banned_enhancements = {}, + banned_tags = {}, + banned_blinds = {}, + reworked_jokers = {}, + reworked_consumables = {}, + reworked_vouchers = {}, + reworked_enhancements = {}, + reworked_tags = {}, + reworked_blinds = {}, + create_info_menu = function() + return { + { + n = G.UIT.R, + config = { + align = "tm" + }, + nodes = { + { + n = G.UIT.T, + config = { + text = MP.UTILS.wrapText(localize("k_survival_description"), 70), + scale = 0.6, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, + }, + { + n = G.UIT.R, + config = { + minw = 0.4, + minh = 0.4 + } + }, + { + n = G.UIT.R, + config = { + align = "cm", + padding = 0.3 + }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize({ type = "variable", key = "k_ante_min", vars = { "1" }}), { + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.small() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.big() + } + }, + { + n = G.UIT.C, + config = { + minw = 0.2, + minh = 0.2 + } + }, + { + n = G.UIT.O, + config = { + object = MP.UI.BlindChip.random() + } + }, + }, {text_scale = 0.6}), + } + }, + }, + }, + { + n = G.UIT.C, + config = { + align = "cm" + }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm" + }, + nodes = { + MP.UI.BackgroundGrouping(localize("k_lives"), { + { + n = G.UIT.T, + config = { + text = "1", + scale = 1.5, + colour = G.C.UI.TEXT_LIGHT, + } + }, + }, {text_scale = 0.6}), + } + }, + } + } + }, + }, + } + end }):inject() diff --git a/ui/components/background_grouping.lua b/ui/components/background_grouping.lua new file mode 100644 index 00000000..8471754e --- /dev/null +++ b/ui/components/background_grouping.lua @@ -0,0 +1,12 @@ +function MP.UI.BackgroundGrouping(text, nodes, config) + config = config or {} + config.text_scale = config.text_scale or 0.33 + return { + n = config.col and G.UIT.C or G.UIT.R, + config = { align = "cm", padding = 0.05, r = 0.1, colour = G.C.UI.TRANSPARENT_DARK }, + nodes = { + { n = G.UIT.R, config = { align = "cm" }, nodes = nodes }, + { n = G.UIT.R, config = { align = "cm", padding = 0.05 }, nodes = { { n = G.UIT.T, config = { text = text, colour = lighten(G.C.L_BLACK, 0.5), scale = config.text_scale } } } } + } + } +end diff --git a/ui/components/blind_chip.lua b/ui/components/blind_chip.lua new file mode 100644 index 00000000..8b201b0c --- /dev/null +++ b/ui/components/blind_chip.lua @@ -0,0 +1,26 @@ +MP.UI.BlindChip = {} + +function MP.UI.BlindChip.custom(atlas, x, y) + local blind_chip = AnimatedSprite(0, 0, 1.4, 1.4, G.ANIMATION_ATLAS[atlas], { x = x, y = y }) + blind_chip:define_draw_steps({ + { shader = "dissolve", shadow_height = 0.05 }, + { shader = "dissolve" }, + }) + return blind_chip +end + +function MP.UI.BlindChip.small() + return MP.UI.BlindChip.custom("blind_chips", 0, 0) +end + +function MP.UI.BlindChip.big() + return MP.UI.BlindChip.custom("blind_chips", 0, 1) +end + +function MP.UI.BlindChip.random() + return MP.UI.BlindChip.custom("blind_chips", 0, 30) +end + +function MP.UI.BlindChip.nemesis() + return MP.UI.BlindChip.custom("mp_player_blind_col", 0, 22) +end diff --git a/ui/components/disableable_button.lua b/ui/components/disableable_button.lua new file mode 100644 index 00000000..a8bb7b1e --- /dev/null +++ b/ui/components/disableable_button.lua @@ -0,0 +1,17 @@ +function MP.UI.Disableable_Button(args) + local enabled_table = args.enabled_ref_table or {} + local enabled = enabled_table[args.enabled_ref_value] + args.colour = args.colour or G.C.RED + args.text_colour = args.text_colour or G.C.UI.TEXT_LIGHT + args.disabled_text = args.disabled_text or args.label + args.label = not enabled and args.disabled_text or args.label + + local button_component = UIBox_button(args) + button_component.nodes[1].config.button = enabled and args.button or nil + button_component.nodes[1].config.hover = enabled + button_component.nodes[1].config.shadow = enabled + button_component.nodes[1].config.colour = enabled and args.colour or G.C.UI.BACKGROUND_INACTIVE + button_component.nodes[1].nodes[1].nodes[1].colour = enabled and args.text_colour or G.C.UI.TEXT_INACTIVE + button_component.nodes[1].nodes[1].nodes[1].shadow = enabled + return button_component +end \ No newline at end of file diff --git a/ui/components/disableable_option_cycle.lua b/ui/components/disableable_option_cycle.lua new file mode 100644 index 00000000..b538c0ed --- /dev/null +++ b/ui/components/disableable_option_cycle.lua @@ -0,0 +1,12 @@ +function MP.UI.Disableable_Option_Cycle(args) + local enabled_table = args.enabled_ref_table or {} + local enabled = enabled_table[args.enabled_ref_value] + + if not enabled then + args.options = { args.options[args.current_option] } + args.current_option = 1 + end + + local option_component = create_option_cycle(args) + return option_component +end \ No newline at end of file diff --git a/ui/components/disableable_toggle.lua b/ui/components/disableable_toggle.lua new file mode 100644 index 00000000..0f03b8c6 --- /dev/null +++ b/ui/components/disableable_toggle.lua @@ -0,0 +1,12 @@ +function MP.UI.Disableable_Toggle(args) + local enabled_table = args.enabled_ref_table or {} + local enabled = enabled_table[args.enabled_ref_value] + + local toggle_component = create_toggle(args) + toggle_component.nodes[2].nodes[1].nodes[1].config.id = args.id + toggle_component.nodes[2].nodes[1].nodes[1].config.button = enabled and "toggle_button" or nil + toggle_component.nodes[2].nodes[1].nodes[1].config.button_dist = enabled and 0.2 or nil + toggle_component.nodes[2].nodes[1].nodes[1].config.hover = enabled and true or false + toggle_component.nodes[2].nodes[1].nodes[1].config.toggle_callback = enabled and args.callback or nil + return toggle_component +end \ No newline at end of file diff --git a/ui/components/lobby_code_buttons.lua b/ui/components/lobby_code_buttons.lua new file mode 100644 index 00000000..2c17b9df --- /dev/null +++ b/ui/components/lobby_code_buttons.lua @@ -0,0 +1,28 @@ +-- Component for view/copy code buttons in lobby +function MP.UI.create_lobby_code_buttons(text_scale) + return { + n = G.UIT.C, + config = { + align = "cm", + }, + nodes = { + UIBox_button({ + button = "view_code", + colour = G.C.PALE_GREEN, + minw = 2.15, + minh = 0.65, + label = { localize("b_view_code") }, + scale = text_scale * 1.2, + }), + MP.UI.create_spacer(0.1, true), + UIBox_button({ + button = "copy_to_clipboard", + colour = G.C.PERISHABLE, + minw = 2.15, + minh = 0.65, + label = { localize("b_copy_code") }, + scale = text_scale, + }), + }, + } +end diff --git a/ui/components/lobby_deck_button.lua b/ui/components/lobby_deck_button.lua new file mode 100644 index 00000000..340b8323 --- /dev/null +++ b/ui/components/lobby_deck_button.lua @@ -0,0 +1,45 @@ +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) + local deck_labels = { + localize({ + type = "name_text", + key = MP.UTILS.get_deck_key_from_name(back), + set = "Back", + }), + localize({ + type = "name_text", + key = SMODS.stake_from_index(type(stake) == "string" and tonumber(stake) or stake), + set = "Stake", + }), + } + + if MP.LOBBY.is_host then + return Disableable_Button({ + id = "lobby_choose_deck", + button = "lobby_choose_deck", + colour = G.C.PURPLE, + minw = 2.15, + minh = 1.35, + label = deck_labels, + scale = text_scale * 1.2, + col = true, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "is_host", + }) + else + return Disableable_Button({ + id = "lobby_choose_deck", + button = "lobby_choose_deck", + colour = G.C.PURPLE, + minw = 2.15, + minh = 1.35, + label = deck_labels, + scale = text_scale * 1.2, + col = true, + enabled_ref_table = MP.LOBBY.config, + enabled_ref_value = "different_decks", + }) + end +end diff --git a/ui/components/lobby_gamemode_tab.lua b/ui/components/lobby_gamemode_tab.lua new file mode 100644 index 00000000..a5239b6d --- /dev/null +++ b/ui/components/lobby_gamemode_tab.lua @@ -0,0 +1,87 @@ +local function create_lobby_option_cycle(id, label_key, scale, options, current_option, callback) + return MP.UI.Disableable_Option_Cycle({ + id = id, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "is_host", + label = localize(label_key), + scale = scale, + options = options, + current_option = current_option, + opt_callback = callback, + }) +end + +-- Component for gamemode modifiers tab containing option cycles +function MP.UI.create_gamemode_modifiers_tab() + return { + n = G.UIT.ROOT, + config = { + emboss = 0.05, + minh = 6, + r = 0.1, + minw = 10, + align = "tm", + padding = 0.2, + colour = G.C.BLACK, + }, + nodes = { + { + n = G.UIT.R, + config = { padding = 0, align = "cm" }, + nodes = { + create_lobby_option_cycle( + "starting_lives_option", + "b_opts_lives", + 0.85, + { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }, + MP.LOBBY.config.starting_lives, + "change_starting_lives" + ), + create_lobby_option_cycle( + "pvp_round_start_option", + "k_opts_pvp_start_round", + 0.85, + { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }, + MP.LOBBY.config.pvp_start_round, + "change_starting_pvp_round" + ), + create_lobby_option_cycle( + "pvp_timer_seconds_option", + "k_opts_pvp_timer", + 0.85, + { "30s", "60s", "90s", "120s", "150s", "180s", "210s", "240s" }, + MP.LOBBY.config.timer_base_seconds / 30, + "change_timer_base_seconds" + ), + create_lobby_option_cycle( + "showdown_starting_antes_option", + "k_opts_showdown_starting_antes", + 0.85, + { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }, + MP.LOBBY.config.showdown_starting_antes, + "change_showdown_starting_antes" + ), + create_lobby_option_cycle( + "pvp_timer_increment_seconds_option", + "k_opts_pvp_timer_increment", + 0.85, + { "0s", "30s", "60s", "90s", "120s", "150s", "180s" }, + MP.UTILS.get_array_index_by_value( + { 0, 30, 60, 90, 120, 150, 180 }, + MP.LOBBY.config.timer_increment_seconds + ), + "change_timer_increment_seconds" + ), + create_lobby_option_cycle( + "pvp_countdown_seconds_option", + "k_opts_pvp_countdown_seconds", + 0.85, + { 0, 3, 5, 10 }, + MP.UTILS.get_array_index_by_value({ 0, 3, 5, 10 }, MP.LOBBY.config.pvp_countdown_seconds), + "change_pvp_countdown_seconds" + ), + }, + }, + }, + } +end diff --git a/ui/components/lobby_main_button.lua b/ui/components/lobby_main_button.lua new file mode 100644 index 00000000..2886589b --- /dev/null +++ b/ui/components/lobby_main_button.lua @@ -0,0 +1,32 @@ +local Disableable_Button = MP.UI.Disableable_Button + +-- Component for main start/ready button in lobby +function MP.UI.create_lobby_main_button(text_scale) + if MP.LOBBY.is_host then + return Disableable_Button({ + id = "lobby_menu_start", + button = "lobby_start_game", + colour = G.C.BLUE, + minw = 3.65, + minh = 1.55, + label = { localize("b_start") }, + disabled_text = MP.LOBBY.guest.username and localize("b_wait_for_guest_ready") + or localize("b_wait_for_players"), + scale = text_scale * 2, + col = true, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "ready_to_start", + }) + else + return UIBox_button({ + id = "lobby_menu_start", + button = "lobby_ready_up", + colour = MP.LOBBY.ready_to_start and G.C.GREEN or G.C.RED, + minw = 3.65, + minh = 1.55, + label = { MP.LOBBY.ready_to_start and localize("b_unready") or localize("b_ready") }, + scale = text_scale * 2, + col = true, + }) + end +end diff --git a/ui/components/lobby_options_tab.lua b/ui/components/lobby_options_tab.lua new file mode 100644 index 00000000..2bc7e76c --- /dev/null +++ b/ui/components/lobby_options_tab.lua @@ -0,0 +1,276 @@ +local Disableable_Toggle = MP.UI.Disableable_Toggle +local Disableable_Button = MP.UI.Disableable_Button + +-- TODO repetition but w/e... +local function send_lobby_options(value) + MP.ACTIONS.lobby_options() +end + +function G.FUNCS.custom_seed_overlay(e) + G.FUNCS.overlay_menu({ + definition = G.UIDEF.create_UIBox_custom_seed_overlay(), + }) +end + +function G.FUNCS.custom_seed_reset(e) + MP.LOBBY.config.custom_seed = "random" + send_lobby_options() +end + +function G.UIDEF.create_UIBox_custom_seed_overlay() + return create_UIBox_generic_options({ + back_func = "lobby_options", + contents = { + { + n = G.UIT.R, + config = { align = "cm", colour = G.C.CLEAR }, + nodes = { + { + n = G.UIT.C, + config = { align = "cm", minw = 0.1 }, + nodes = { + create_text_input({ + max_length = 8, + all_caps = true, + ref_table = MP.LOBBY, + ref_value = "temp_seed", + prompt_text = localize("k_enter_seed"), + keyboard_offset = 4, + callback = function(val) + MP.LOBBY.config.custom_seed = MP.LOBBY.temp_seed + send_lobby_options() + end, + }), + { + n = G.UIT.B, + config = { w = 0.1, h = 0.1 }, + }, + { + n = G.UIT.T, + config = { + scale = 0.3, + text = localize("k_enter_to_save"), + colour = G.C.UI.TEXT_LIGHT, + }, + }, + }, + }, + }, + }, + }, + }) +end + +function toggle_different_seeds() + G.FUNCS.lobby_options() + send_lobby_options() +end + +G.FUNCS.change_starting_lives = function(args) + MP.LOBBY.config.starting_lives = args.to_val + send_lobby_options() +end + +G.FUNCS.change_starting_pvp_round = function(args) + MP.LOBBY.config.pvp_start_round = args.to_val + send_lobby_options() +end + +G.FUNCS.change_timer_base_seconds = function(args) + MP.LOBBY.config.timer_base_seconds = tonumber(args.to_val:sub(1, -2)) + send_lobby_options() +end + +G.FUNCS.change_timer_increment_seconds = function(args) + MP.LOBBY.config.timer_increment_seconds = tonumber(args.to_val:sub(1, -2)) + send_lobby_options() +end + +G.FUNCS.change_showdown_starting_antes = function(args) + MP.LOBBY.config.showdown_starting_antes = args.to_val + send_lobby_options() +end + +G.FUNCS.change_pvp_countdown_seconds = function(args) + MP.LOBBY.config.pvp_countdown_seconds = args.to_val + send_lobby_options() +end + +-- This needs to have a parameter because its a callback for inputs +local function send_lobby_options(value) + MP.ACTIONS.lobby_options() +end + +function G.FUNCS.display_custom_seed(e) + local display = MP.LOBBY.config.custom_seed == "random" and localize("k_random") or MP.LOBBY.config.custom_seed + if display ~= e.children[1].config.text then + e.children[2].config.text = display + e.UIBox:recalculate(true) + end +end + +-- Component for lobby options tab containing toggles and custom seed section +local function create_lobby_option_toggle(id, label_key, ref_value, callback) + return { + n = G.UIT.R, + config = { + padding = 0, + align = "cr", + }, + nodes = { + Disableable_Toggle({ + id = id, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "is_host", + label = localize(label_key), + ref_table = MP.LOBBY.config, + ref_value = ref_value, + callback = callback or send_lobby_options, + }), + }, + } +end + +local function create_custom_seed_section() + if MP.LOBBY.config.different_seeds then + return { n = G.UIT.B, config = { w = 0.1, h = 0.1 } } + end + + return { + n = G.UIT.R, + config = { padding = 0, align = "cr" }, + nodes = { + { + n = G.UIT.R, + config = { + padding = 0, + align = "cr", + }, + nodes = { + { + n = G.UIT.C, + config = { + padding = 0, + align = "cm", + }, + nodes = { + { + n = G.UIT.R, + config = { + padding = 0.2, + align = "cr", + func = "display_custom_seed", + }, + nodes = { + { + n = G.UIT.T, + config = { + scale = 0.45, + text = localize("k_current_seed"), + colour = G.C.UI.TEXT_LIGHT, + }, + }, + { + n = G.UIT.T, + config = { + scale = 0.45, + text = MP.LOBBY.config.custom_seed, + colour = G.C.UI.TEXT_LIGHT, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { + padding = 0.2, + align = "cr", + }, + nodes = { + Disableable_Button({ + id = "custom_seed_overlay", + button = "custom_seed_overlay", + colour = G.C.BLUE, + minw = 3.65, + minh = 0.6, + label = { + localize("b_set_custom_seed"), + }, + disabled_text = { + localize("b_set_custom_seed"), + }, + scale = 0.45, + col = true, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "is_host", + }), + { + n = G.UIT.B, + config = { + w = 0.1, + h = 0.1, + }, + }, + Disableable_Button({ + id = "custom_seed_reset", + button = "custom_seed_reset", + colour = G.C.RED, + minw = 1.65, + minh = 0.6, + label = { + localize("b_reset"), + }, + disabled_text = { + localize("b_reset"), + }, + scale = 0.45, + col = true, + enabled_ref_table = MP.LOBBY, + enabled_ref_value = "is_host", + }), + }, + }, + }, + }, + }, + }, + }, + } +end + +-- Creates the lobby options tab UI containing toggles for various multiplayer settings +-- Returns a UI table with lobby configuration options like gold on life loss, different seeds, etc. +function MP.UI.create_lobby_options_tab() + return { + n = G.UIT.ROOT, + config = { + emboss = 0.05, + minh = 6, + r = 0.1, + minw = 10, + align = "tm", + padding = 0.2, + colour = G.C.BLACK, + }, + nodes = { + create_lobby_option_toggle("gold_on_life_loss_toggle", "b_opts_cb_money", "gold_on_life_loss"), + create_lobby_option_toggle( + "no_gold_on_round_loss_toggle", + "b_opts_no_gold_on_loss", + "no_gold_on_round_loss" + ), + create_lobby_option_toggle("death_on_round_loss_toggle", "b_opts_death_on_loss", "death_on_round_loss"), + create_lobby_option_toggle( + "different_seeds_toggle", + "b_opts_diff_seeds", + "different_seeds", + toggle_different_seeds + ), + create_lobby_option_toggle("different_decks_toggle", "b_opts_player_diff_deck", "different_decks"), + create_lobby_option_toggle("multiplayer_jokers_toggle", "b_opts_multiplayer_jokers", "multiplayer_jokers"), + create_lobby_option_toggle("timer_toggle", "b_opts_timer", "timer"), + create_lobby_option_toggle("normal_bosses_toggle", "b_opts_normal_bosses", "normal_bosses"), + create_custom_seed_section(), + }, + } +end diff --git a/ui/components/lobby_status_display.lua b/ui/components/lobby_status_display.lua new file mode 100644 index 00000000..0b65474a --- /dev/null +++ b/ui/components/lobby_status_display.lua @@ -0,0 +1,108 @@ +local function get_warnings() + local warnings = {} + + -- Check the other player (guest if we're host, host if we're guest) + local other_player = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host + + if other_player and other_player.cached == false then + table.insert(warnings, { localize("k_warning_cheating1"), SMODS.Gradients.warning_text, 0.4 }) + table.insert( + warnings, + { string.format(localize("k_warning_cheating2"), MP.UTILS.random_message()), SMODS.Gradients.warning_text } + ) + end + + if other_player and other_player.config and other_player.config.unlocked == false then + table.insert(warnings, { + localize("k_warning_nemesis_unlock"), + SMODS.Gradients.warning_text, + 0.25, + }) + end + + local current_player = MP.LOBBY.is_host and MP.LOBBY.host or MP.LOBBY.guest + local current_has_order = current_player and current_player.config and current_player.config.TheOrder + local other_has_order = other_player and other_player.config and other_player.config.TheOrder + + if (MP.LOBBY.ready_to_start or not MP.LOBBY.is_host) and current_has_order ~= other_has_order then + table.insert(warnings, { + localize("k_warning_no_order"), + SMODS.Gradients.warning_text, + }) + end + + if MP.LOBBY.ready_to_start or not MP.LOBBY.is_host then + local hostSteamoddedVersion = MP.LOBBY.host and MP.LOBBY.host.config and MP.LOBBY.host.config.Mods["Steamodded"] + local guestSteamoddedVersion = MP.LOBBY.guest + and MP.LOBBY.guest.config + and MP.LOBBY.guest.config.Mods["Steamodded"] + + if hostSteamoddedVersion ~= guestSteamoddedVersion then + table.insert(warnings, { + localize("k_steamodded_warning"), + SMODS.Gradients.warning_text, + }) + end + end + + SMODS.Mods["Multiplayer"].config.unlocked = MP.UTILS.unlock_check() + if not SMODS.Mods["Multiplayer"].config.unlocked then + table.insert(warnings, { + localize("k_warning_unlock_profile"), + SMODS.Gradients.warning_text, + 0.25, + }) + end + + -- ???: What is this supposed to accomplish? + if MP.LOBBY.username == "Guest" then + table.insert(warnings, { + localize("k_set_name"), + G.C.UI.TEXT_LIGHT, + }) + end + + if #warnings == 0 then + table.insert(warnings, { + " ", + G.C.UI.TEXT_LIGHT, + }) + end + + return warnings +end + +function MP.UI.lobby_status_display() + local warnings = get_warnings() + + local warning_texts = {} + for k, v in pairs(warnings) do + table.insert(warning_texts, { + n = G.UIT.R, + config = { + padding = -0.25, + align = "cm", + }, + nodes = { + { + n = G.UIT.T, + config = { + text = v[1], + colour = v[2], + shadow = true, + scale = v[3] or 0.25, + }, + }, + }, + }) + end + + return { + n = G.UIT.R, + config = { + padding = 0.35, + align = "cm", + }, + nodes = warning_texts, + } +end diff --git a/ui/components/main_lobby_options.lua b/ui/components/main_lobby_options.lua new file mode 100644 index 00000000..4a06dd80 --- /dev/null +++ b/ui/components/main_lobby_options.lua @@ -0,0 +1,106 @@ +local function create_main_lobby_options_title(info_area_id) + local title_colour = mix_colours(G.C.RED, G.C.BLACK, 0.6) + local title = "ERROR" + + if info_area_id == "ruleset_area" then + title_colour = mix_colours(G.C.BLUE, G.C.BLACK, 0.6) + title = localize("k_rulesets") + end + + if info_area_id == "gamemode_area" then + title_colour = mix_colours(G.C.ORANGE, G.C.BLACK, 0.6) + title = localize("k_gamemodes") + end + + if title == "ERROR" then + return nil + end + + return { + n = G.UIT.R, + config = { id = 'ruleset_name', align = "cm", padding = 0.07 }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", r = 0.1, outline = 1, outline_colour = title_colour, colour = darken(title_colour, 0.3), minw = 2.9, emboss = 0.1, padding = 0.07, line_emboss = 1 }, + nodes = { + { n = G.UIT.O, config = { object = DynaText({ string = title, colours = { G.C.WHITE }, shadow = true, float = true, y_offset = -4, scale = 0.45, maxw = 2.8 }) } }, + } + }, + } + } +end + +function MP.UI.Main_Lobby_Options(info_area_id, default_info_area, button_func, buttons_data) + local categories = { + create_main_lobby_options_title(info_area_id) + } + for cat_idx, category in ipairs(buttons_data) do + local buttons = {} + for btn_idx, data in ipairs(category.buttons) do + local col = data.button_col or G.C.RED + if data.button_id == "weekly_ruleset_button" then -- putting the logic here because whatever + if (not MP.LOBBY.config.weekly) or (MP.LOBBY.config.weekly ~= MP.LOBBY.fetched_weekly) then + col = G.C.DARK_EDITION + end + end + local button = UIBox_button({ + id = data.button_id, + col = true, + chosen = (cat_idx == 1 and btn_idx == 1 and "vert" or false), + label = { localize(data.button_localize_key) }, + button = + button_func, + colour = col, + minw = 4, + scale = 0.4, + minh = 0.6 + }) + buttons[#buttons + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.05 }, nodes = { button } } + end + categories[#categories + 1] = MP.UI.BackgroundGrouping(localize(category.name), buttons) + end + + return create_UIBox_generic_options({ + back_func = "play_options", + contents = { + { n = G.UIT.C, config = { align = "tm", minh = 8, minw = 4, padding = 0.1 }, nodes = categories }, + { + n = G.UIT.C, + config = { align = "cm", minh = 8, maxh = 8, minw = 11 }, + nodes = { + { n = G.UIT.O, config = { id = info_area_id, object = default_info_area } } + } + } + } + }) +end + +function MP.UI.Change_Main_Lobby_Options(e, info_area_id, info_area_func, default_button_id, update_lobby_config_func) + if not G.OVERLAY_MENU then return end + + local info_area = G.OVERLAY_MENU:get_UIE_by_ID(info_area_id) + if not info_area then return end + + -- Switch 'chosen' status from the previously-chosen button to this one: + if info_area.config.prev_chosen then + info_area.config.prev_chosen.config.chosen = nil + else -- The previously-chosen button should be the default one here: + local default_button = G.OVERLAY_MENU:get_UIE_by_ID(default_button_id) + if default_button then default_button.config.chosen = nil end + end + e.config.chosen = "vert" -- Special setting to show 'chosen' indicator on the side + + local info_obj_name = string.match(e.config.id, "([^_]+)") + update_lobby_config_func(info_obj_name) + + if info_area.config.object then info_area.config.object:remove() end + info_area.config.object = UIBox({ + definition = info_area_func(info_obj_name), + config = { align = "cm", parent = info_area } + }) + + info_area.config.object:recalculate() + + info_area.config.prev_chosen = e +end diff --git a/ui/components/players_section.lua b/ui/components/players_section.lua new file mode 100644 index 00000000..cbb3d4e8 --- /dev/null +++ b/ui/components/players_section.lua @@ -0,0 +1,75 @@ +local function create_player_info_row(player, player_type, text_scale) + if not player or not player.username then + return nil + end + + return { + n = G.UIT.R, + config = { + padding = 0.1, + align = "cm", + }, + nodes = { + { + n = G.UIT.T, + config = { + ref_table = player, + ref_value = "username", + shadow = true, + scale = text_scale * 0.8, + colour = G.C.UI.TEXT_LIGHT, + }, + }, + { + n = G.UIT.B, + config = { + w = 0.1, + h = 0.1, + }, + }, + player.hash and UIBox_button({ + id = player_type .. "_hash", + button = "view_" .. player_type .. "_hash", + label = { player.hash }, + minw = 0.75, + minh = 0.3, + scale = 0.25, + shadow = false, + colour = G.C.PURPLE, + col = true, + }) or nil, + }, + } +end + +function MP.UI.create_players_section(text_scale) + return { + n = G.UIT.C, + config = { + align = "tm", + minw = 2.65, + }, + nodes = { + { + n = G.UIT.R, + config = { + padding = 0.15, + align = "cm", + }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_connect_player"), + shadow = true, + scale = text_scale * 0.8, + colour = G.C.UI.TEXT_LIGHT, + }, + }, + }, + }, + create_player_info_row(MP.LOBBY.host, "host", text_scale), + create_player_info_row(MP.LOBBY.guest, "guest", text_scale), + }, + } +end diff --git a/ui/components/utils.lua b/ui/components/utils.lua new file mode 100644 index 00000000..4a30d839 --- /dev/null +++ b/ui/components/utils.lua @@ -0,0 +1,21 @@ +-- Utility functions for UI components + +function MP.UI.create_spacer(size, row) + size = size or 0.2 + + return row and { + n = G.UIT.R, + config = { + align = "cm", + minh = size, + }, + nodes = {}, + } or { + n = row and G.UIT.R or G.UIT.C, + config = { + align = "cm", + minw = size, + }, + nodes = {}, + } +end \ No newline at end of file From fa920f0da550249acd9365d08fb7cb5ae0cbae2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:22:13 +0000 Subject: [PATCH 4/6] Add new upstream features: Bloodstone joker, Judgement consumable, updated localization Co-authored-by: FilPag <1493826+FilPag@users.noreply.github.com> --- localization/en-us.lua | 96 +++++++++++++++++++++++-------- objects/consumables/judgement.lua | 76 ++++++++++++++++++++++++ objects/jokers/bloodstone.lua | 31 ++++++++++ 3 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 objects/consumables/judgement.lua create mode 100644 objects/jokers/bloodstone.lua diff --git a/localization/en-us.lua b/localization/en-us.lua index d540b9f4..b86a81b9 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1,5 +1,15 @@ return { descriptions = { + Tag = { + tag_mp_sandbox_rare = { + name = "Gambling Tag", + text = { + "{C:green}#1# in #2#{} chance", + "Shop has a free", + "{C:red}Rare Joker{}", + }, + }, + }, Joker = { j_broken = { name = "BROKEN", @@ -15,6 +25,7 @@ return { "{C:chips}+#1#{} Chips for every {C:red,E:1}life{}", "less than your {X:purple,C:white}Nemesis{}", "{C:inactive}(Currently {C:chips}+#2#{C:inactive} Chips)", + "{C:inactive}(Stake-dependent)", }, }, j_mp_skip_off = { @@ -78,7 +89,6 @@ return { "your {X:purple,C:white}Nemesis'{} highest ", "sell cost {C:attention}Joker{}", "{C:inactive}(Currently {C:attention}#2#{C:inactive}/#3# rounds)", - "{C:inactive,s:0.8}(Does not copy Joker state)", }, }, j_mp_pizza = { @@ -105,6 +115,25 @@ return { "{C:attention}#1#{} additional time", }, }, + j_mp_cloud_9 = { + name = "Cloud 9", + text = { + "Earn {C:money}$1{} for each {C:attention}9{} in deck", + "(max {C:money}$4{}), then {C:money}$#1#{} for each", + "additional {C:attention}9{} at end of round", + "{C:inactive}(Currently {C:money}$#2#{}{C:inactive})", + }, + }, + j_mp_bloodstone = { + name = "Bloodstone", + text = { + "{C:green}#1# in #2#{} chance for", + "played cards with", + "{C:hearts}Heart{} suit to give", + "{X:mult,C:white} X#3# {} Mult when scored", + "{C:inactive}(Includes experimental variance){}", + }, + }, }, Planet = { c_mp_asteroid = { @@ -161,6 +190,7 @@ return { }, challenge_names = { c_mp_standard = "Standard", + c_mp_sandbox = "Sandbox", c_mp_badlatro = "Badlatro", c_mp_tournament = "Tournament", c_mp_weekly = "Weekly", @@ -184,6 +214,7 @@ return { b_lobby_options = "LOBBY OPTIONS", b_copy_clipboard = "Copy to clipboard", b_view_code = "VIEW CODE", + b_copy_code = "COPY CODE", b_leave = "LEAVE", b_opts_cb_money = "Give comeback $ on life loss", b_opts_no_gold_on_loss = "Don't get blind rewards on round loss", @@ -208,6 +239,19 @@ return { b_view_nemesis_deck = "View Decks", b_toggle_jokers = "Toggle Jokers", b_skip_tutorial = "Skip Tutorial", + k_yes = "Yes", + k_no = "No", + k_has_multiplayer_content = "Has Multiplayer Content", + k_forces_lobby_options = "Forces Lobby Options", + k_forces_gamemode = "Forces Gamemode", + k_values_are_modifiable = "* Values are modifiable", + k_rulesets = "Rulesets", + k_gamemodes = "Gamemodes", + k_competitive = "Competitive", + k_other = "Other", + k_battle = "Battle", + k_challenge = "Challenge", + k_info = "Info", k_continue_singleplayer_tooltip = "This will overwrite your current singleplayer run", k_enemy_score = "Current Enemy score", k_enemy_hands = "Enemy hands left: ", @@ -234,7 +278,8 @@ return { k_warning_unlock_profile = "The profile you are playing on is not fully unlocked. If this is a ranked/tournament game, please create a new profile and hit unlock all in the profile settings", k_warning_nemesis_unlock = "Your opponent is playing on a profile that is not fully unlocked. Please instruct them to create a new profile and hit unlock all in the profile settings", k_warning_no_order = "One player has The Order integration enabled while the other does not. This will cause the seeds to differ.", - k_warning_cheating = "If you are seeing this, your opponent may be cheating. If this is a ranked game, please send the message '%s' and then open a support ticket in #support", + k_warning_cheating1 = "If you are seeing this, your opponent may be cheating.", + k_warning_cheating2 = "If this is a ranked game, please send the message '%s' and then open a support ticket in #support", k_message1 = "Hold on, my mom made pizza pops", k_message2 = "One sec, i gotta grab my slow cooker pork roast", k_message3 = "One moment, getting a call from my mom", @@ -252,40 +297,39 @@ return { k_opts_pvp_timer = "Timer", k_opts_showdown_starting_antes = "Showdown Starts at Ante", k_opts_pvp_timer_increment = "Timer Increment", + k_opts_pvp_countdown_seconds = "PvP Countdown Seconds", k_bl_life = "Life", k_bl_or = "or", k_bl_death = "Death", k_bl_mostchips = "Most chips wins", k_current_seed = "Current seed: ", k_random = "Random", + k_standard = "Standard", + k_sandbox = "Steph's Sandbox", + k_sandbox_description = "Like normal mode but someone gave the cards coffee and they're\nfeeling chatty.", k_vanilla = "Vanilla", - k_vanilla_description = "The vanilla ruleset, no Multiplayer cards, no modifications to base game content. This ruleset includes Multiplayer features like the timer", + k_vanilla_description = "This ruleset removes all Multiplayer content,\nallowing you to play the game as originally designed.\n\nThis ruleset still includes Multiplayer features like the timer.\n\n(Disableable in Lobby Options)", k_blitz = "Blitz", - k_blitz_description = "The blitz ruleset, includes Multiplayer cards and changes to the base game to fit the Multiplayer meta. This ruleset includes cards and features that encourage fast play and using time as a resource.", + k_blitz_description = "This ruleset includes cards and features that encourage fast play and\nusing time as a resource.\n\nSome cards are balanced in this ruleset to better fit the Multiplayer meta:\n- Hanging Chad is reworked\n- Justice is removed\n- Glass is reworked\n\n(See the bans and reworks tabs for more info)", k_traditional = "Traditional", - k_traditional_description = "The traditional ruleset, includes Multiplayer cards and changes to the base game to fit the Multiplayer meta. This ruleset removes aspects of Multiplayer that use time as a resource, allowing you to play a more traditional and methodical game.", + k_traditional_description = "This ruleset removes the aspects of Multiplayer that use time as a resource.\n\nThis ruleset allows you to play with the Multiplayer content,\nwhile still allowing for a methodical game.\n\nSome cards are balanced in this ruleset to better fit the Multiplayer meta:\n- Hanging Chad is reworked\n- Justice is removed\n- Glass is reworked\n\n(See the bans and reworks tabs for more info)", k_majorleague = "Major League", - k_majorleague_description = "The major league ruleset, it follows the rules for Major League Balatro.", + k_majorleague_description = "This is the official ruleset for Major League Balatro.\n\nThis ruleset is the same as the Vanilla ruleset with a few exceptions:\n- You must have The Order Integration disabled\n- The timer is set to 180 seconds\n- The first time the timer hits 0 seconds you will not lose a life", k_minorleague = "Minor League", - k_minorleague_description = "The minor league ruleset, it follows the rules for Minor League Balatro.", + k_minorleague_description = "This is the official ruleset for Minor League Balatro.\n\nThis ruleset is the same as the Vanilla ruleset with a few exceptions:\n- You must have The Order Integration enabled\n- The timer is set to 180 seconds\n- The first time the timer hits 0 seconds you will not lose a life", k_ranked = "Ranked", - k_ranked_description = "The ranked ruleset, this ruleset is the same as Blitz right now, but also forces the correct gamemode, lobby options, and The Order integration.", - k_weekly = "Weekly", - k_weekly_description = "A special ruleset that changes weekly or bi-weekly. I guess you'll have to find out what it is! Currently: ", - k_tournament = "Tournament", - k_tournament_description = "The tournament ruleset, this is the same as the standard ruleset but doesn't allow changing the lobby options.", + k_ranked_description = "This is the official ruleset for playing Ranked Balatro Multiplayer.\n\nThis ruleset is the same as the Blitz ruleset with a few exceptions:\n- You must have The Order Integration enabled\n- You must be on the recommended Steamodded version", k_badlatro = "Badlatro", - k_badlatro_description = "A weekly ruleset designed by @dr_monty_the_snek on the discord server that has been added to the mod permanently.", + k_badlatro_description = "A weekly ruleset designed by @dr_monty_the_snek on the discord server\nthat has been added to the mod permanently.\n\nThis ruleset bans 48 jokers, consumables, tags, etc.", k_attrition = "Attrition", - k_attrition_description = "Every boss blind is a Nemesis blind. No time to prepare. This gamemode forces you to be battle-ready from the start.", + k_attrition_description = "After the first ante, every boss blind is a Nemesis blind. No time to prepare. This gamemode forces you to be battle-ready from the start.", k_showdown = "Showdown", k_showdown_description = "After the first 2 antes, every blind is a Nemesis blind. This gamemode gives you time to prepare before battle.", k_survival = "Survival", k_survival_description = "The player who beats the farthest blind wins. No Nemesis blinds. This gamemode is a test of your ability to gradually build-up to the highest scoring Vanilla hands.", - k_coop = "Co-op", - k_coop_description = "The vanilla ruleset, but with no banned blinds.", - k_coopSurvival = "Co-op Survival", - k_coopSurvival_description = "Work together with your friends to beat the farthest blind possible. No Nemesis blinds. This gamemode is a test of your ability to gradually build-up to the highest scoring Vanilla hands.", + k_weekly = "Weekly", + k_weekly_description = "A special ruleset that changes weekly or bi-weekly. I guess you'll have to find out what it is! Currently: ", + k_weekly_smallworld = "Small World", k_oops_ex = "Oops!", k_asteroids = "Asteroids", k_amount_short = "Amt.", @@ -299,8 +343,11 @@ return { k_the_order_credit = "*Credit to @MathIsFun_", k_the_order_integration_desc = "This will patch card creation to not be ante-based and use a single pool for every type/rarity", k_requires_restart = "*Requires a restart to take effect", + k_new_weekly_ruleset = "A new weekly ruleset is available!", + k_currently_colon = "Currently: ", + k_sync_locally = "Sync locally (Restarts game)", k_bans = "Bans", - k_reworks = "Additions/Reworks", + k_reworks = "Reworks", k_ruleset_disabled_the_order_required = "The Order is Required", k_ruleset_disabled_the_order_banned = "The Order is Banned", k_ruleset_not_found = "Unknown ruleset", @@ -315,8 +362,7 @@ return { "you like it consider", }, ml_lobby_info = { "Lobby", "Info" }, - loc_ready_pvp = "Ready for PvP", - loc_ready_boss = "Ready for Boss", + loc_ready = "Ready for PvP", loc_selecting = "Selecting a Blind", loc_shop = "Shopping", loc_playing = "Playing ", @@ -330,10 +376,14 @@ return { a_mp_skips_tied = { "Tied" }, k_banned_objs = "Banned #1#", k_no_banned_objs = "No Banned #1#", - k_reworked_objs = "Added/Reworked #1#", - k_no_reworked_objs = "No Added/Reworked #1#", + k_reworked_objs = "Reworked #1#", + k_no_reworked_objs = "No Reworked #1#", k_ruleset_disabled_smods_version = "SMODS Version #1# Required", k_failed_to_join_lobby = "Failed to join lobby: #1#", + k_ante_number = "Ante #1#", + k_ante_range = "Ante #1#-#2#", -- For example, "Ante 1-2" + k_ante_min = "Ante #1#+", -- For example, "Ante 2+" + k_credits_list = "#1# and many more!" -- #1# gets replaced with a list of names }, v_text = { ch_c_hanging_chad_rework = { "{C:attention}Hanging Chad{} is {C:dark_edition}reworked" }, diff --git a/objects/consumables/judgement.lua b/objects/consumables/judgement.lua new file mode 100644 index 00000000..15715df5 --- /dev/null +++ b/objects/consumables/judgement.lua @@ -0,0 +1,76 @@ +-- gotta redefine the logic +MP.ReworkCenter({ + key = "c_judgement", + ruleset = MP.UTILS.get_standard_rulesets(), + silent = true, + use = function(self, card, area, copier) + local _card = copier or card + G.E_MANAGER:add_event(Event({trigger = 'after', delay = 0.4, func = function() + play_sound('timpani') + if MP.INTEGRATIONS.TheOrder then -- this only matters if order exists + local done = false + while not done do -- AHHH we have to do so much boilerplate + done = true + + local card = { stickers = { eternal = false, perishable = false, rental = false } } + local rarity = SMODS.poll_rarity("Joker", 'rarity0') + local str_rarity = rarity + if type(rarity) == 'number' then + str_rarity = ({'Common', 'Uncommon', 'Rare', 'Legendary'})[rarity] -- kill me now + end + local _pool = get_current_pool('Joker', str_rarity, nil, '') + local _pool_key = 'Joker'..rarity..'0' + + center = pseudorandom_element(_pool, pseudoseed(_pool_key)) + local it = 1 + while center == 'UNAVAILABLE' do + it = it + 1 + center = pseudorandom_element(_pool, pseudoseed(_pool_key)) + end + + card.key = center + + local eternal_perishable_poll = pseudorandom(center..'etperpoll0') + local rental_poll = pseudorandom(center..'ssjr0') + + if G.GAME.modifiers.enable_eternals_in_shop + and eternal_perishable_poll > 0.7 + and G.P_CENTERS[center].eternal_compat then + card.stickers.eternal = true + done = false + end + + if G.GAME.modifiers.enable_perishables_in_shop + and ((eternal_perishable_poll > 0.4) and (eternal_perishable_poll <= 0.7)) + and G.P_CENTERS[center].perishable_compat then + card.stickers.perishable = true + done = false + end + + if G.GAME.modifiers.enable_rentals_in_shop + and rental_poll > 0.7 then + card.stickers.rental = true + done = false + end + + if done then + table.insert(G.GAME.MP_joker_overrides, 1, card) -- start, so it gets created immediately + else + table.insert(G.GAME.MP_joker_overrides, card) -- end + end + end + end + + + G.MP_JUDGEMENT_OVERRIDE = true + local joker = create_card('Joker', G.jokers, nil, nil, nil, nil, nil, 'jud') -- just call create card since override will kick in + G.MP_JUDGEMENT_OVERRIDE = nil + joker:add_to_deck() + G.jokers:emplace(joker) + _card:juice_up(0.3, 0.5) + + return true + end})) + delay(0.6) + end, +}) \ No newline at end of file diff --git a/objects/jokers/bloodstone.lua b/objects/jokers/bloodstone.lua new file mode 100644 index 00000000..a5462560 --- /dev/null +++ b/objects/jokers/bloodstone.lua @@ -0,0 +1,31 @@ +-- this is kinda strange but we can just override the logic for pvp only rather than re-implementing it again, bc if we don't return anything, it'll run the normal logic +MP.ReworkCenter({ + key = "j_bloodstone", + ruleset = MP.UTILS.get_standard_rulesets(), + silent = true, + calculate = function(self, card, context) + if MP.is_pvp_boss() then + if not context.blueprint then + if context.before then + G.GAME.round_resets.mp_bloodstone = G.GAME.round_resets.mp_bloodstone or {} + G.GAME.round_resets.mp_bloodstone[MP.order_round_based(true)] = G.GAME.round_resets.mp_bloodstone[MP.order_round_based(true)] or {} + G.GAME.round_resets.mp_bsindex = 0 + end + end + if context.individual and context.cardarea == G.play then + if context.other_card:is_suit("Hearts") then + local stored_queue = G.GAME.round_resets.mp_bloodstone[MP.order_round_based(true)] + G.GAME.round_resets.mp_bsindex = G.GAME.round_resets.mp_bsindex + 1 -- increment before indexing + stored_queue[G.GAME.round_resets.mp_bsindex] = stored_queue[G.GAME.round_resets.mp_bsindex] or pseudorandom('bloodstone'..MP.order_round_based(true)) + if stored_queue[G.GAME.round_resets.mp_bsindex] < G.GAME.probabilities.normal/card.ability.extra.odds then + return { + x_mult = card.ability.extra.Xmult, + card = card + } + end + return nil, true -- prevents normal logic from triggering + end + end + end + end, +}) \ No newline at end of file From 3d3f9c03ef1f732890c6ed5f27a3669cd706529a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:50:43 +0000 Subject: [PATCH 5/6] Update rulesets to upstream version and annotate networking changes per FilPag feedback Co-authored-by: FilPag <1493826+FilPag@users.noreply.github.com> --- networking/action_handlers.lua | 1041 +++++++------------- networking/socket.lua | 46 +- rulesets/_rulesets.lua | 321 +++--- ui/components/background_grouping.lua | 12 - ui/components/blind_chip.lua | 26 - ui/components/disableable_button.lua | 17 - ui/components/disableable_option_cycle.lua | 12 - ui/components/disableable_toggle.lua | 12 - ui/components/lobby_code_buttons.lua | 28 - ui/components/lobby_deck_button.lua | 45 - ui/components/lobby_gamemode_tab.lua | 87 -- ui/components/lobby_main_button.lua | 32 - ui/components/lobby_options_tab.lua | 276 ------ ui/components/lobby_status_display.lua | 108 -- ui/components/main_lobby_options.lua | 106 -- ui/components/players_section.lua | 75 -- ui/components/utils.lua | 21 - 17 files changed, 550 insertions(+), 1715 deletions(-) delete mode 100644 ui/components/background_grouping.lua delete mode 100644 ui/components/blind_chip.lua delete mode 100644 ui/components/disableable_button.lua delete mode 100644 ui/components/disableable_option_cycle.lua delete mode 100644 ui/components/disableable_toggle.lua delete mode 100644 ui/components/lobby_code_buttons.lua delete mode 100644 ui/components/lobby_deck_button.lua delete mode 100644 ui/components/lobby_gamemode_tab.lua delete mode 100644 ui/components/lobby_main_button.lua delete mode 100644 ui/components/lobby_options_tab.lua delete mode 100644 ui/components/lobby_status_display.lua delete mode 100644 ui/components/main_lobby_options.lua delete mode 100644 ui/components/players_section.lua delete mode 100644 ui/components/utils.lua diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index ed9a007b..6c1be9b6 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -1,43 +1,110 @@ +--- @class client_data +--- @field username string +--- @field colour string +--- @field modHash string +--- @field isCached boolean +--- @field isReady boolean +--- @field firstReady boolean + +--- @player_state +--- @class player_state +--- @field lives number +--- @field score number +--- @field highest_score number +--- @field hands_left number +--- @field ante number +--- @field skips number +--- @field furthest_blind number +--- @field lives_blocker boolean +--- @field location string + +--- @class lobby_info +--- @field host string +--- @field hostHash string +--- @field hostCached boolean +--- @field isHost boolean +--- @field local_id string +--- @field players? table[] @ Array of player objects: { username: string, modHash: string, isCached: boolean, id: string } + + +local json = require "json" Client = {} function Client.send(msg) - if not (msg == "action:keepAliveAck") then + -- UPSTREAM CHANGE: Simplified to `if not (msg == "action:keepAliveAck") then` + if msg ~= '{"action":"keepAliveAck"}' and msg ~= "action:keepAliveAck" then sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER") end love.thread.getChannel("uiToNetwork"):push(msg) end -- Server to Client -function MP.ACTIONS.set_username(username) - MP.LOBBY.username = username or "Guest" - if MP.LOBBY.connected then - Client.send( - string.format( - "action:username,username:%s,modHash:%s", - MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col, - MP.MOD_STRING - ) - ) + +--- @alias BossKey string + +--- Handles setting the boss blind in the game. +--- @param bossKey BossKey +local function action_set_boss_blind(bossKey) + if G.GAME.round_resets.blind_choices.Boss == bossKey then + MP.next_coop_boss = nil + return end -end -function MP.ACTIONS.set_blind_col(num) - MP.LOBBY.blind_col = num or 1 + G.GAME.round_resets.blind_choices.Boss = bossKey + MP.next_coop_boss = bossKey + + if G.blind_select then + G.E_MANAGER:add_event(Event({ + trigger = 'immediate', + func = function() + play_sound('other1') + G.blind_select_opts.boss:set_role({ xy_bond = 'Weak' }) + G.blind_select_opts.boss.alignment.offset.y = 20 + return true + end + })) + G.E_MANAGER:add_event(Event({ + trigger = 'after', + delay = 0.3, + func = function() + local par = G.blind_select_opts.boss.parent + G.blind_select_opts.boss:remove() + G.blind_select_opts.boss = UIBox { + T = { par.T.x, 0, 0, 0, }, + definition = + { n = G.UIT.ROOT, config = { align = "cm", colour = G.C.CLEAR }, nodes = { + UIBox_dyn_container({ create_UIBox_blind_choice('Boss') }, false, get_blind_main_colour('Boss'), mix_colours(G.C.BLACK, get_blind_main_colour('Boss'), 0.8)) + } }, + config = { align = "bmi", + offset = { x = 0, y = G.ROOM.T.y + 9 }, + major = par, + xy_bond = 'Weak' + } + } + par.config.object = G.blind_select_opts.boss + par.config.object:recalculate() + G.blind_select_opts.boss.parent = par + G.blind_select_opts.boss.alignment.offset.y = 0 + MP.next_coop_boss = nil + return true + end + })) + end end local function action_connected() MP.LOBBY.connected = true MP.UI.update_connection_status() - Client.send( - string.format( - "action:username,username:%s,modHash:%s", - MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col, - MP.MOD_STRING - ) - ) + Client.send(json.encode({ + action = "username", + username = MP.LOBBY.username, + colour = MP.LOBBY.blind_col, + modHash = MP.MOD_STRING + })) end local function action_joinedLobby(code, type) + MP.FLAGS.join_pressed = false MP.LOBBY.code = code MP.LOBBY.type = type MP.LOBBY.ready_to_start = false @@ -46,57 +113,13 @@ local function action_joinedLobby(code, type) MP.UI.update_connection_status() end -local function action_lobbyInfo(host, hostHash, hostCached, guest, guestHash, guestCached, guestReady, is_host) - MP.LOBBY.players = {} - MP.LOBBY.is_host = is_host == "true" - local function parseName(name) - local username, col_str = string.match(name, "([^~]+)~(%d+)") - username = username or "Guest" - local col = tonumber(col_str) or 1 - col = math.max(1, math.min(col, 25)) - return username, col - end - local hostName, hostCol = parseName(host) - local hostConfig, hostMods = MP.UTILS.parse_Hash(hostHash) - MP.LOBBY.host = { - username = hostName, - blind_col = hostCol, - hash_str = hostMods, - hash = hash(hostMods), - cached = hostCached == "true", - config = hostConfig, - } - - if guest ~= nil then - local guestName, guestCol = parseName(guest) - local guestConfig, guestMods = MP.UTILS.parse_Hash(guestHash) - MP.LOBBY.guest = { - username = guestName, - blind_col = guestCol, - hash_str = guestMods, - hash = hash(guestMods), - cached = guestCached == "true", - config = guestConfig, - } - else - MP.LOBBY.guest = {} - end - - -- Backwards compatibility for old server, assume guest is ready - -- TODO: Remove this once new server gets released - guestReady = guestReady or "true" - - -- TODO: This should check for player count instead - -- once we enable more than 2 players - MP.LOBBY.ready_to_start = guest ~= nil and guestReady == "true" - - if MP.LOBBY.is_host then - MP.ACTIONS.lobby_options() - end +--- @param lobby_info lobby_info +local function action_lobbyInfo(lobby_info) + MP.LOBBY.isHost = lobby_info.isHost + MP.LOBBY.players = lobby_info.players or {} + MP.LOBBY.local_id = lobby_info.local_id - if G.STAGE == G.STAGES.MAIN_MENU then - MP.ACTIONS.update_player_usernames() - end + MP.ACTIONS.update_player_usernames() end local function action_error(message) @@ -106,7 +129,8 @@ local function action_error(message) end local function action_keep_alive() - Client.send("action:keepAliveAck") + -- UPSTREAM CHANGE: Simplified to `Client.send("action:keepAliveAck")` + Client.send(json.encode({ action = "keepAliveAck" })) end local function action_disconnected() @@ -118,217 +142,187 @@ local function action_disconnected() end ---@param deck string +---@param players table ---@param seed string ---@param stake_str string -local function action_start_game(seed, stake_str) +-- UPSTREAM CHANGE: Removed players parameter and MP.GAME.players setup logic +local function action_start_game(players, seed, stake_str) MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) + MP.GAME.players = players + + for _, player in ipairs(MP.GAME.players) do + player.location = MP.player_state_manager.parse_enemy_location(player.location) + player.score = MP.INSANE_INT.from_string(player.score) or MP.INSANE_INT.empty() + player.highest_score = MP.INSANE_INT.from_string(player.highest_score) or MP.INSANE_INT.empty() + end + if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then seed = MP.LOBBY.config.custom_seed end + G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false end -local function begin_pvp_blind() - if MP.GAME.next_blind_context then - G.FUNCS.select_blind(MP.GAME.next_blind_context) - else - sendErrorMessage("No next blind context", "MULTIPLAYER") - end -end - local function action_start_blind() - MP.GAME.ready_blind = false - MP.GAME.timer_started = false - MP.GAME.timer = MP.LOBBY.config.timer_base_seconds - MP.UI.start_pvp_countdown(begin_pvp_blind) -end - ----@param score_str string ----@param hands_left_str string ----@param skips_str string -local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str) - local score = MP.INSANE_INT.from_string(score_str) - - local hands_left = tonumber(hands_left_str) - local skips = tonumber(skips_str) - local lives = tonumber(lives_str) + MP.GAME.ready_blind = false + MP.GAME.timer_started = false + MP.GAME.timer = MP.LOBBY.config.timer_base_seconds - if MP.GAME.enemy.skips ~= skips then - for i = 1, skips - MP.GAME.enemy.skips do - MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = 0 - end + if MP.GAME.next_blind_context then + G.FUNCS.select_blind(MP.GAME.next_blind_context) + else + sendErrorMessage("No next blind context", "MULTIPLAYER") end +end - if score == nil or hands_left == nil then - sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER") - return - end +local function action_game_state_update(player_id, updates) + MP.player_state_manager.process(player_id, updates) +end - if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then - MP.GAME.enemy.highest_score = score +local function action_stop_game() + if G.STAGE ~= G.STAGES.MAIN_MENU then + G.FUNCS.go_to_menu() + MP.UI.update_connection_status() + MP.reset_game_states() end +end - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "ease", - delay = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "e_count", - ease_to = score.e_count, - func = function(t) - return math.floor(t) - end, - })) +local function action_end_pvp() + MP.GAME.end_pvp = true + MP.GAME.timer = MP.LOBBY.config.timer_base_seconds + MP.GAME.timer_started = false +end +local function action_win_game() + MP.ACTIONS.sendPlayerDeck() G.E_MANAGER:add_event(Event({ - blockable = false, + no_delete = true, + trigger = "immediate", + blockable = true, blocking = false, - trigger = "ease", - delay = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "coeffiocient", - ease_to = score.coeffiocient, - func = function(t) - return math.floor(t) + func = function() + MP.end_game_jokers_payload = "" + MP.nemesis_deck_string = "" + MP.end_game_jokers_received = false + MP.nemesis_deck_received = false + win_game() + MP.GAME.won = true + return true end, })) +end +local function action_lose_game() + MP.ACTIONS.sendPlayerDeck() G.E_MANAGER:add_event(Event({ - blockable = false, + no_delete = true, + trigger = "immediate", + blockable = true, blocking = false, - trigger = "ease", - delay = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "exponent", - ease_to = score.exponent, - func = function(t) - return math.floor(t) + func = function() + MP.GAME.won = false + MP.end_game_jokers_payload = "" + MP.nemesis_deck_string = "" + MP.end_game_jokers_received = false + MP.nemesis_deck_received = false + G.STATE_COMPLETE = false + G.STATE = G.STATES.GAME_OVER + return true end, })) - - MP.GAME.enemy.hands = hands_left - MP.GAME.enemy.skips = skips - MP.GAME.enemy.lives = lives - if MP.is_pvp_boss() then - G.HUD_blind:get_UIE_by_ID("HUD_blind_count"):juice_up() - G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned"):juice_up() - end end -local function action_stop_game() - if G.STAGE ~= G.STAGES.MAIN_MENU then - G.FUNCS.go_to_menu() - MP.UI.update_connection_status() - MP.reset_game_states() +-- Helper: parse option value by type +local function parse_option_value(type_str, v) + if type_str == "boolean" then + return (v == true or v == "true") + elseif type_str == "number" then + return tonumber(v) + elseif type_str == "string" then + return tostring(v) + else + return v end end -local function action_end_pvp() - MP.GAME.end_pvp = true - MP.GAME.timer = MP.LOBBY.config.timer_base_seconds - MP.GAME.timer_started = false -end - ----@param lives number -local function action_player_info(lives) - if MP.GAME.lives ~= lives then - if MP.GAME.lives ~= 0 and MP.LOBBY.config.gold_on_life_loss then - MP.GAME.comeback_bonus_given = false - MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1 +-- Helper: check if any of the given keys changed between two tables +local function any_key_changed(keys, old_tbl, new_tbl) + for _, k in ipairs(keys) do + if old_tbl[k] ~= new_tbl[k] then + return true end - ease_lives(lives - MP.GAME.lives) - if MP.LOBBY.config.no_gold_on_round_loss and (G.GAME.blind and G.GAME.blind.dollars) then - G.GAME.blind.dollars = 0 + end + return false +end + +local config_map = { + starting_lives = { type = "number" }, + pvp_start_round = { type = "number" }, + timer_base_seconds = { type = "number" }, + timer_increment_seconds = { type = "number" }, + showdown_starting_antes = { type = "number" }, + different_decks = { type = "boolean" }, + gold_on_life_loss = { type = "boolean" }, + no_gold_on_round_loss = { type = "boolean" }, + death_on_round_loss = { type = "boolean" }, + different_seeds = { type = "boolean" }, + multiplayer_jokers = { type = "boolean" }, + normal_bosses = { type = "boolean" }, + custom_seed = { type = "string" }, + stake = { type = "number" }, + back = { type = "string" }, + challenge = { type = "string" }, + -- Add more config keys here as needed +} + +local function update_lobby_config(options) + local changed_keys = {} + local old_config = {} + for k, v in pairs(MP.LOBBY.config) do old_config[k] = v end + + for k, v in pairs(options) do + local entry = config_map[k] + local parsed_v = entry and parse_option_value(entry.type, v) or v + if MP.LOBBY.config[k] ~= parsed_v then + MP.LOBBY.config[k] = parsed_v + changed_keys[k] = true end end - MP.GAME.lives = lives + return changed_keys, old_config, MP.LOBBY.config end -local function action_win_game() - MP.end_game_jokers_payload = "" - MP.nemesis_deck_string = "" - MP.end_game_jokers_received = false - MP.nemesis_deck_received = false - win_game() - MP.GAME.won = true +local function update_overlay_toggles(changed_keys) + if not G.OVERLAY_MENU then return end + for k in pairs(changed_keys) do + local config_uie = G.OVERLAY_MENU:get_UIE_by_ID(k .. "_toggle") + if config_uie then + G.FUNCS.toggle(config_uie) + end + end end -local function action_lose_game() - MP.end_game_jokers_payload = "" - MP.nemesis_deck_string = "" - MP.end_game_jokers_received = false - MP.nemesis_deck_received = false - G.STATE_COMPLETE = false - G.STATE = G.STATES.GAME_OVER +local function action_invalidLobby() + MP.FLAGS.join_pressed = false + MP.UTILS.overlay_message("Invalid lobby code") end local function action_lobby_options(options) - local different_decks_before = MP.LOBBY.config.different_decks - for k, v in pairs(options) do - if k == "ruleset" then - if not MP.Rulesets[v] then - G.FUNCS.lobby_leave(nil) - MP.UTILS.overlay_message( - localize({ - type = "variable", - key = "k_failed_to_join_lobby", - vars = { localize("k_ruleset_not_found") }, - }) - ) - return - end - local disabled = MP.Rulesets[v].is_disabled() - if disabled then - G.FUNCS.lobby_leave(nil) - MP.UTILS.overlay_message( - localize({ type = "variable", key = "k_failed_to_join_lobby", vars = { disabled } }) - ) - return - end - MP.LOBBY.config.ruleset = v - goto continue - end - if k == "gamemode" then - MP.LOBBY.config.gamemode = v - goto continue - end + local changed_keys, old_config, new_config = update_lobby_config(options) - local parsed_v = v - if v == "true" then - parsed_v = true - elseif v == "false" then - parsed_v = false - end + -- Only update UI if deck, stake, or different_decks changed + if any_key_changed({ "stake", "back", "different_decks" }, old_config, new_config) then + if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end + set_main_menu_UI() + end - if - k == "starting_lives" - or k == "pvp_start_round" - or k == "timer_base_seconds" - or k == "timer_increment_seconds" - or k == "showdown_starting_antes" - or k == "pvp_countdown_seconds" - or k == "timer_forgiveness" - then - parsed_v = tonumber(v) - end + update_overlay_toggles(changed_keys) - MP.LOBBY.config[k] = parsed_v - if G.OVERLAY_MENU then - local config_uie = G.OVERLAY_MENU:get_UIE_by_ID(k .. "_toggle") - if config_uie then - G.FUNCS.toggle(config_uie) - end - end - ::continue:: - end - if different_decks_before ~= MP.LOBBY.config.different_decks then + if old_config.different_decks ~= new_config.different_decks then G.FUNCS.exit_overlay_menu() -- throw out guest from any menu. end - MP.ACTIONS.update_player_usernames() -- render new DECK button state end local function action_send_phantom(key) @@ -387,91 +381,63 @@ local function action_speedrun() SMODS.calculate_context({ mp_speedrun = true }) end -local function enemyLocation(options) - local location = options.location - local value = "" - - if string.find(location, "-") then - local split = {} - for str in string.gmatch(location, "([^-]+)") do - table.insert(split, str) - end - location = split[1] - value = split[2] - end - - loc_name = localize({ type = "name_text", key = value, set = "Blind" }) - if loc_name ~= "ERROR" then - value = loc_name - else - value = (G.P_BLINDS[value] and G.P_BLINDS[value].name) or value - end - - loc_location = G.localization.misc.dictionary[location] - - if loc_location == nil then - if location ~= nil then - loc_location = location - else - loc_location = "Unknown" - end - end - - MP.GAME.enemy.location = loc_location .. value -end - local function action_version() MP.ACTIONS.version() end local action_asteroid = action_asteroid - or function() - local hand_priority = { - ["Flush Five"] = 1, - ["Flush House"] = 2, - ["Five of a Kind"] = 3, - ["Straight Flush"] = 4, - ["Four of a Kind"] = 5, - ["Full House"] = 6, - ["Flush"] = 7, - ["Straight"] = 8, - ["Three of a Kind"] = 9, - ["Two Pair"] = 11, - ["Pair"] = 12, - ["High Card"] = 13, - } - local hand_type = "High Card" - local max_level = 0 - - for k, v in pairs(G.GAME.hands) do - if v.visible then - if - to_big(v.level) > to_big(max_level) - or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type]) - then - hand_type = k - max_level = v.level + or function() + local hand_priority = { + ["Flush Five"] = 1, + ["Flush House"] = 2, + ["Five of a Kind"] = 3, + ["Straight Flush"] = 4, + ["Four of a Kind"] = 5, + ["Full House"] = 6, + ["Flush"] = 7, + ["Straight"] = 8, + ["Three of a Kind"] = 9, + ["Two Pair"] = 11, + ["Pair"] = 12, + ["High Card"] = 13, + } + local hand_type = "High Card" + local max_level = 0 + + for k, v in pairs(G.GAME.hands) do + if v.visible then + if + to_big(v.level) > to_big(max_level) + or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type]) + then + hand_type = k + max_level = v.level + end end end + update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, { + handname = localize(hand_type, "poker_hands"), + chips = G.GAME.hands[hand_type].chips, + mult = G.GAME.hands[hand_type].mult, + level = G.GAME.hands[hand_type].level, + }) + level_up_hand(nil, hand_type, false, -1) + update_hand_text( + { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 }, + { mult = 0, chips = 0, handname = "", level = "" } + ) end - update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, { - handname = localize(hand_type, "poker_hands"), - chips = G.GAME.hands[hand_type].chips, - mult = G.GAME.hands[hand_type].mult, - level = G.GAME.hands[hand_type].level, - }) - level_up_hand(nil, hand_type, false, -1) - update_hand_text( - { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 }, - { mult = 0, chips = 0, handname = "", level = "" } - ) - end local function action_sold_joker() -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked - MP.GAME.enemy.sells = MP.GAME.enemy.sells + 1 - MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] = ( - (MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1 + local enemy = MP.UTILS.get_nemesis() + if not enemy then return end + enemy.sells = (enemy.sells or 0) + 1 + if not enemy.sells_per_ante then + enemy.sells_per_ante = {} + end + enemy.sells_per_ante[G.GAME.round_resets.ante] = ( + (enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1 ) end @@ -489,8 +455,35 @@ local function action_eat_pizza(discards) ease_discard(discards) end -local function action_spent_last_shop(amount) - MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = tonumber(amount) +local function action_spent_last_shop(player_id, amount) + -- TODO make support more than one player + local enemy = MP.UTILS.get_nemesis() + if not enemy then + sendWarnMessage("No enemy found for spent_last_shop action", "MULTIPLAYER") + return + end + + if not enemy.spent_in_shop then + enemy.spent_in_shop = {} + end + + enemy.spent_in_shop[#enemy.spent_in_shop + 1] = tonumber(amount) +end + +local function action_set_lobby_ready(isReady, player_id) + MP.UTILS.get_player_by_id(player_id).isReady = isReady + + if not MP.LOBBY.isHost then return end + local ready_check = true + + for _, player in ipairs(MP.LOBBY.players) do + if player.id ~= MP.LOBBY.local_id then + ready_check = ready_check and player.isReady + end + end + + MP.LOBBY.ready_to_start = ready_check + MP.ACTIONS.update_player_usernames() end local function action_magnet() @@ -533,7 +526,7 @@ local function action_magnet_response(key) end local card = - Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base) + Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base) -- Avoid crashing if the load function ends up indexing a nil value success, err = pcall(card.load, card, card_save) if not success then @@ -600,7 +593,7 @@ end local function action_get_end_game_jokers() if not G.jokers or not G.jokers.cards then - Client.send("action:receiveEndGameJokers,keys:") + Client.send(json.encode({ action = "receiveEndGameJokers", keys = "" })) return end @@ -614,58 +607,24 @@ local function action_get_end_game_jokers() local jokers_save = G.jokers:save() local jokers_encoded = MP.UTILS.str_pack_and_encode(jokers_save) - Client.send(string.format("action:receiveEndGameJokers,keys:%s", jokers_encoded)) -end - -local function action_get_nemesis_deck() - local deck_str = "" - for _, card in ipairs(G.playing_cards) do - deck_str = deck_str .. ";" .. MP.UTILS.card_to_string(card) - end - Client.send(string.format("action:receiveNemesisDeck,cards:%s", deck_str)) + Client.send(json.encode({ action = "receiveEndGameJokers", keys = jokers_encoded })) end -local function action_send_game_stats() - if not MP.GAME.stats then - Client.send("action:nemesisEndGameStats") +function G.FUNCS.load_player_deck(player) + if not MP.LOBBY.code or not player.deck_str then return end - local stats_str = string.format( - "reroll_count:%d,reroll_cost_total:%d", - MP.GAME.stats.reroll_count, - MP.GAME.stats.reroll_cost_total - ) - - -- Extract voucher keys where value is true and join them with a dash - local voucher_keys = "" - if G.GAME.used_vouchers then - local keys = {} - for k, v in pairs(G.GAME.used_vouchers) do - if v == true then - table.insert(keys, k) - end - end - voucher_keys = table.concat(keys, "-") - end - - -- Add voucher keys to stats string - if voucher_keys ~= "" then - stats_str = stats_str .. string.format(",vouchers:%s", voucher_keys) - end - - Client.send(string.format("action:nemesisEndGameStats,%s", stats_str)) -end + if not player.cards then player.cards = {} end -function G.FUNCS.load_nemesis_deck() - if not MP.nemesis_deck_string or not MP.nemesis_deck or not MP.nemesis_cards or not MP.LOBBY.code then - return + if not player.deck then + player.deck = CardArea(-100, -100, G.CARD_W, G.CARD_H, { type = 'deck' }) end - local card_strings = MP.UTILS.string_split(MP.nemesis_deck_string, ";") + local card_strings = MP.UTILS.string_split(player.deck_str, ";") - for k, _ in pairs(MP.nemesis_cards) do - MP.nemesis_cards[k] = nil + for k, _ in pairs(player.cards) do + player.cards[k] = nil end for _, card_str in pairs(card_strings) do @@ -703,10 +662,13 @@ function G.FUNCS.load_nemesis_deck() end -- Create the card - local card = create_playing_card({ - front = G.P_CARDS[front_key], - center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil, - }, MP.nemesis_deck, true, true, nil, false) + local card = create_playing_card( + { + front = G.P_CARDS[front_key], + center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil + }, + player.deck, true, true, nil, false + ) if edition ~= "none" then card:set_edition({ [edition] = true }, true, true) end @@ -716,19 +678,21 @@ function G.FUNCS.load_nemesis_deck() -- Remove the card from G.playing_cards and insert into MP.nemesis_cards table.remove(G.playing_cards, #G.playing_cards) - table.insert(MP.nemesis_cards, card) + table.insert(player.cards, card) ::continue:: end end -local function action_receive_nemesis_deck(deck_str) - MP.nemesis_deck_string = deck_str - MP.nemesis_deck_received = true - G.FUNCS.load_nemesis_deck() +local function action_receive_player_deck(player_id, cards) + local player = MP.UTILS.get_player_by_id(player_id) + player.deck_str = cards + player.deck_received = true + G.FUNCS.load_player_deck(player) end -local function action_start_ante_timer(time) +-- Special cases since they're used elsewhere +function MP.action_start_ante_timer(time) if type(time) == "string" then time = tonumber(time) end @@ -737,7 +701,7 @@ local function action_start_ante_timer(time) G.E_MANAGER:add_event(MP.timer_event) end -local function action_pause_ante_timer(time) +function MP.action_pause_ante_timer(time) if type(time) == "string" then time = tonumber(time) end @@ -745,224 +709,53 @@ local function action_pause_ante_timer(time) MP.GAME.timer_started = false end --- #region Client to Server -function MP.ACTIONS.create_lobby(gamemode) - Client.send(string.format("action:createLobby,gameMode:%s", gamemode)) -end - -function MP.ACTIONS.join_lobby(code) - Client.send(string.format("action:joinLobby,code:%s", code)) -end - -function MP.ACTIONS.ready_lobby() - Client.send("action:readyLobby") -end - -function MP.ACTIONS.unready_lobby() - Client.send("action:unreadyLobby") -end - -function MP.ACTIONS.lobby_info() - Client.send("action:lobbyInfo") -end - -function MP.ACTIONS.leave_lobby() - Client.send("action:leaveLobby") -end - -function MP.ACTIONS.start_game() - Client.send("action:startGame") -end - -function MP.ACTIONS.ready_blind(e) - MP.GAME.next_blind_context = e - Client.send("action:readyBlind") -end - -function MP.ACTIONS.unready_blind() - Client.send("action:unreadyBlind") -end - -function MP.ACTIONS.stop_game() - Client.send("action:stopGame") -end - -function MP.ACTIONS.fail_round(hands_used) - if MP.LOBBY.config.no_gold_on_round_loss then - G.GAME.blind.dollars = 0 - end - if hands_used == 0 then - return - end - Client.send("action:failRound") -end - -function MP.ACTIONS.version() - Client.send(string.format("action:version,version:%s", MULTIPLAYER_VERSION)) -end - -function MP.ACTIONS.set_location(location) - if MP.GAME.location == location then - return - end - MP.GAME.location = location - Client.send(string.format("action:setLocation,location:%s", location)) -end - ----@param score number ----@param hands_left number -function MP.ACTIONS.play_hand(score, hands_left) - local fixed_score = tostring(to_big(score)) - -- Credit to sidmeierscivilizationv on discord for this fix for Talisman - if string.match(fixed_score, "[eE]") == nil and string.match(fixed_score, "[.]") then - -- Remove decimal from non-exponential numbers - fixed_score = string.sub(string.gsub(fixed_score, "%.", ","), 1, -3) - end - fixed_score = string.gsub(fixed_score, ",", "") -- Remove commas - - local insane_int_score = MP.INSANE_INT.from_string(fixed_score) - if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.highest_score) then - MP.GAME.highest_score = insane_int_score - end - Client.send(string.format("action:playHand,score:" .. fixed_score .. ",handsLeft:%d", hands_left)) -end - -function MP.ACTIONS.lobby_options() - local msg = "action:lobbyOptions" - for k, v in pairs(MP.LOBBY.config) do - msg = msg .. string.format(",%s:%s", k, tostring(v)) - end - Client.send(msg) -end - -function MP.ACTIONS.set_ante(ante) - Client.send(string.format("action:setAnte,ante:%d", ante)) -end - -function MP.ACTIONS.new_round() - - MP.GAME.duplicate_end = false - MP.GAME.round_ended = false - Client.send("action:newRound") -end - -function MP.ACTIONS.set_furthest_blind(furthest_blind) - Client.send(string.format("action:setFurthestBlind,furthestBlind:%d", furthest_blind)) -end - -function MP.ACTIONS.skip(skips) - Client.send("action:skip,skips:" .. tostring(skips)) -end - -function MP.ACTIONS.send_phantom(key) - Client.send("action:sendPhantom,key:" .. key) -end - -function MP.ACTIONS.remove_phantom(key) - Client.send("action:removePhantom,key:" .. key) -end - -function MP.ACTIONS.asteroid() - Client.send("action:asteroid") -end - -function MP.ACTIONS.sold_joker() - Client.send("action:soldJoker") -end - -function MP.ACTIONS.lets_go_gambling_nemesis() - Client.send("action:letsGoGamblingNemesis") -end - -function MP.ACTIONS.eat_pizza(discards) - Client.send("action:eatPizza,whole:" .. tostring(discards)) -end - -function MP.ACTIONS.spent_last_shop(amount) - Client.send("action:spentLastShop,amount:" .. tostring(amount)) -end - -function MP.ACTIONS.magnet() - Client.send("action:magnet") -end - -function MP.ACTIONS.magnet_response(key) - Client.send("action:magnetResponse,key:" .. key) -end - -function MP.ACTIONS.get_end_game_jokers() - Client.send("action:getEndGameJokers") -end - -function MP.ACTIONS.get_nemesis_deck() - Client.send("action:getNemesisDeck") -end - -function MP.ACTIONS.send_game_stats() - Client.send("action:sendGameStats") - action_send_game_stats() -end - -function MP.ACTIONS.request_nemesis_stats() - Client.send("action:endGameStatsRequested") -end - -function MP.ACTIONS.start_ante_timer() - Client.send("action:startAnteTimer,time:" .. tostring(MP.GAME.timer)) - action_start_ante_timer(MP.GAME.timer) -end - -function MP.ACTIONS.pause_ante_timer() - Client.send("action:pauseAnteTimer,time:" .. tostring(MP.GAME.timer)) - action_pause_ante_timer(MP.GAME.timer) -- TODO -end - -function MP.ACTIONS.fail_timer() - Client.send("action:failTimer") -end - -function MP.ACTIONS.sync_client() - Client.send("action:syncClient,isCached:" .. tostring(_RELEASE_MODE)) -end - --- #endregion Client to Server - --- Utils -function MP.ACTIONS.connect() - Client.send("connect") -end - -function MP.ACTIONS.update_player_usernames() - if MP.LOBBY.code then - if G.MAIN_MENU_UI then - G.MAIN_MENU_UI:remove() - end - set_main_menu_UI() - end -end - -local function string_to_table(str) - local tbl = {} - for part in string.gmatch(str, "([^,]+)") do - local key, value = string.match(part, "([^:]+):(.+)") - if key and value then - tbl[key] = value - end - end - return tbl -end - -local last_game_seed = nil - -local game_update_ref = Game.update ----@diagnostic disable-next-line: duplicate-set-field -function Game:update(dt) - game_update_ref(self, dt) - +local action_table = { + connected = function() action_connected() end, + version = function() action_version() end, + disconnected = function() action_disconnected() end, + invalidLobby = function() action_invalidLobby() end, + joinedLobby = function(parsedAction) action_joinedLobby(parsedAction.code, parsedAction.type) end, + lobbyInfo = function(parsedAction) action_lobbyInfo(parsedAction) end, + startGame = function(parsedAction) action_start_game(parsedAction.players, parsedAction.seed, parsedAction.stake) end, + startBlind = function() action_start_blind() end, + setLobbyReady = function(parsedAction) action_set_lobby_ready(parsedAction.isReady, parsedAction.playerId) end, + gameStateUpdate = function(parsedAction) action_game_state_update(parsedAction.id, parsedAction.updates) end, + stopGame = function() action_stop_game() end, + endPvP = function() action_end_pvp() end, + winGame = function() action_win_game() end, + loseGame = function() action_lose_game() end, + lobbyOptions = function(parsedAction) action_lobby_options(parsedAction.options) end, + setBossBlind = function(parsedAction) action_set_boss_blind(parsedAction.bossKey) end, + sendPhantom = function(parsedAction) action_send_phantom(parsedAction.key) end, + removePhantom = function(parsedAction) action_remove_phantom(parsedAction.key) end, + speedrun = function() action_speedrun() end, + asteroid = function() action_asteroid() end, + soldJoker = function() action_sold_joker() end, + letsGoGamblingNemesis = function() action_lets_go_gambling_nemesis() end, + eatPizza = function(parsedAction) action_eat_pizza(parsedAction.discards) end, + spentLastShop = function(parsedAction) action_spent_last_shop(parsedAction.playerId, parsedAction.amount) end, + magnet = function() action_magnet() end, + magnetResponse = function(parsedAction) action_magnet_response(parsedAction.key) end, + getEndGameJokers = function() action_get_end_game_jokers() end, + receiveEndGameJokers = function(parsedAction) action_receive_end_game_jokers(parsedAction.keys) end, + receivePlayerDeck = function(parsedAction) action_receive_player_deck(parsedAction.playerId, parsedAction.cards) end, + startAnteTimer = function(parsedAction) MP.action_start_ante_timer(parsedAction.time) end, + pauseAnteTimer = function(parsedAction) MP.action_pause_ante_timer(parsedAction.time) end, + error = function(parsedAction) action_error(parsedAction.message) end, + keepAlive = function() action_keep_alive() end, +} + +function MP.NETWORKING.update(dt) repeat local msg = love.thread.getChannel("networkToUi"):pop() + -- if message not starting with { wrap msg string with {} + if msg then - local parsedAction = string_to_table(msg) + local ok, parsedAction = pcall(json.decode, msg) + if not ok or type(parsedAction) ~= "table" then + sendWarnMessage("Received non-JSON message: " .. tostring(msg), "MULTIPLAYER") + return + end if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then local log = string.format("Client got %s message: ", parsedAction.action) @@ -974,93 +767,17 @@ function Game:update(dt) end end if - (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") - and last_game_seed + (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") + and last_game_seed then log = log .. string.format(" (seed: %s) ", last_game_seed) end sendTraceMessage(log, "MULTIPLAYER") end - if parsedAction.action == "connected" then - action_connected() - elseif parsedAction.action == "version" then - action_version() - elseif parsedAction.action == "disconnected" then - action_disconnected() - elseif parsedAction.action == "joinedLobby" then - action_joinedLobby(parsedAction.code, parsedAction.type) - elseif parsedAction.action == "lobbyInfo" then - action_lobbyInfo( - parsedAction.host, - parsedAction.hostHash, - parsedAction.hostCached, - parsedAction.guest, - parsedAction.guestHash, - parsedAction.guestCached, - parsedAction.guestReady, - parsedAction.isHost - ) - elseif parsedAction.action == "startGame" then - action_start_game(parsedAction.seed, parsedAction.stake) - elseif parsedAction.action == "startBlind" then - action_start_blind() - elseif parsedAction.action == "enemyInfo" then - action_enemy_info(parsedAction.score, parsedAction.handsLeft, parsedAction.skips, parsedAction.lives) - elseif parsedAction.action == "stopGame" then - action_stop_game() - elseif parsedAction.action == "endPvP" then - action_end_pvp() - elseif parsedAction.action == "playerInfo" then - action_player_info(parsedAction.lives) - elseif parsedAction.action == "winGame" then - action_win_game() - elseif parsedAction.action == "loseGame" then - action_lose_game() - elseif parsedAction.action == "lobbyOptions" then - action_lobby_options(parsedAction) - elseif parsedAction.action == "enemyLocation" then - enemyLocation(parsedAction) - elseif parsedAction.action == "sendPhantom" then - action_send_phantom(parsedAction.key) - elseif parsedAction.action == "removePhantom" then - action_remove_phantom(parsedAction.key) - elseif parsedAction.action == "speedrun" then - action_speedrun() - elseif parsedAction.action == "asteroid" then - action_asteroid() - elseif parsedAction.action == "soldJoker" then - action_sold_joker() - elseif parsedAction.action == "letsGoGamblingNemesis" then - action_lets_go_gambling_nemesis() - elseif parsedAction.action == "eatPizza" then - action_eat_pizza(parsedAction.whole) -- rename to "discards" when possible - elseif parsedAction.action == "spentLastShop" then - action_spent_last_shop(parsedAction.amount) - elseif parsedAction.action == "magnet" then - action_magnet() - elseif parsedAction.action == "magnetResponse" then - action_magnet_response(parsedAction.key) - elseif parsedAction.action == "getEndGameJokers" then - action_get_end_game_jokers() - elseif parsedAction.action == "receiveEndGameJokers" then - action_receive_end_game_jokers(parsedAction.keys) - elseif parsedAction.action == "getNemesisDeck" then - action_get_nemesis_deck() - elseif parsedAction.action == "receiveNemesisDeck" then - action_receive_nemesis_deck(parsedAction.cards) - elseif parsedAction.action == "endGameStatsRequested" then - action_send_game_stats() - elseif parsedAction.action == "nemesisEndGameStats" then - -- Handle receiving game stats (is only logged now, now shown in the ui) - elseif parsedAction.action == "startAnteTimer" then - action_start_ante_timer(parsedAction.time) - elseif parsedAction.action == "pauseAnteTimer" then - action_pause_ante_timer(parsedAction.time) - elseif parsedAction.action == "error" then - action_error(parsedAction.message) - elseif parsedAction.action == "keepAlive" then - action_keep_alive() + local handler = action_table[parsedAction.action] + if handler then + handler(parsedAction) end end until not msg diff --git a/networking/socket.lua b/networking/socket.lua index 62d7cc6c..0c6d3c9c 100644 --- a/networking/socket.lua +++ b/networking/socket.lua @@ -4,6 +4,8 @@ -- the necessary modules again return [[ local CONFIG_URL, CONFIG_PORT = ... +-- UPSTREAM CHANGE: Removed json requirement +local json = require("json") require("love.filesystem") local socket = require("socket") @@ -36,6 +38,11 @@ local uiToNetworkChannel = love.thread.getChannel("uiToNetwork") function Networking.connect() -- TODO: Check first if Networking.Client is not null -- and if it is, skip this function + -- UPSTREAM CHANGE: Removed socket connection management logic + if Networking.Client and not isSocketClosed then + Networking.Client:close() + isSocketClosed = true + end SEND_THREAD_DEBUG_MESSAGE( string.format("Attempting to connect to multiplayer server... URL: %s, PORT: %d", CONFIG_URL, CONFIG_PORT) @@ -50,7 +57,15 @@ function Networking.connect() if connectionResult ~= 1 then SEND_THREAD_DEBUG_MESSAGE(string.format("%s", errorMessage)) - networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server") + + -- UPSTREAM CHANGE: Changed to string format instead of JSON + local errorMsg = { + action = "error", + message = "Failed to connect to multiplayer server" + } + + networkToUiChannel:push(json.encode(errorMsg)) + -- UPSTREAM FORMAT: networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server") else isSocketClosed = false end @@ -67,11 +82,19 @@ local mainThreadMessageQueue = function() for _ = 1, requestsPerCycle do local msg = uiToNetworkChannel:pop() if msg then - if msg:find("^action") ~= nil then - Networking.Client:send(msg .. "\n") - elseif msg == "connect" then + -- UPSTREAM CHANGE: Changed message protocol logic + if msg == "connect" then Networking.connect() + else + -- Send any non-empty message (JSON or otherwise) to the server + Networking.Client:send(msg .. "\n") end + -- UPSTREAM FORMAT: + -- if msg:find("^action") ~= nil then + -- Networking.Client:send(msg .. "\n") + -- elseif msg == "connect" then + -- Networking.connect() + -- end else -- If there are no more messages, yield coroutine.yield() @@ -126,7 +149,12 @@ local networkPacketQueue = function() isRetry = false timerCoroutine = coroutine.create(timer) - networkToUiChannel:push("action:disconnected") + + local disconnectedAction = { + action = "disconnected", + message = "Connection closed by server", + } + networkToUiChannel:push(json.encode(disconnectedAction)) else -- If there are no more packets, yield coroutine.yield() @@ -166,13 +194,17 @@ while true do timerCoroutine = coroutine.create(timer) - networkToUiChannel:push("action:disconnected") + local disconnectedAction = { + action = "disconnected", + message = "Connection closed due to inactivity", + } + networkToUiChannel:push(json.encode(disconnectedAction)) end if isRetry then retryCount = retryCount + 1 -- Send keepAlive without cutting the line - uiToNetworkChannel:push("action:keepAlive") + uiToNetworkChannel:push(json.encode({ action = "keepAlive" })) -- Restart the timer timerCoroutine = coroutine.create(timer) diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index a3e2f35c..b0369a34 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -1,189 +1,132 @@ -G.P_CENTER_POOLS.Ruleset = {} -MP.Rulesets = {} -MP.Ruleset = SMODS.GameObject:extend({ - obj_table = {}, - obj_buffer = {}, - required_params = { - "key", - "multiplayer_content", - "banned_jokers", - "banned_consumables", - "banned_vouchers", - "banned_enhancements", - }, - class_prefix = "ruleset", - inject = function(self) - MP.Rulesets[self.key] = self - if not G.P_CENTER_POOLS.Ruleset then - G.P_CENTER_POOLS.Ruleset = {} - end - table.insert(G.P_CENTER_POOLS.Ruleset, self) - end, - process_loc_text = function(self) - SMODS.process_loc_text(G.localization.descriptions["Ruleset"], self.key, self.loc_txt) - end, - is_disabled = function(self) - return false - end -}) - -MP.BANNED_OBJECTS = { - jokers = {}, - consumables = {}, - vouchers = {}, - enhancements = {}, - tags = {}, - blinds = {}, -} - -function new_in_pool_for_blind(v) -- For blinds specifically, in_pool does overwrite basic checks like minimum ante, so we need to repackage all basic checks inside the new in_pool - if MP.LOBBY.code then - return false - elseif not v.boss.showdown and (v.boss.min <= math.max(1, G.GAME.round_resets.ante) and ((math.max(1, G.GAME.round_resets.ante))%G.GAME.win_ante ~= 0 or G.GAME.round_resets.ante < 2)) then - return true - elseif v.boss.showdown and (G.GAME.round_resets.ante)%G.GAME.win_ante == 0 and G.GAME.round_resets.ante >= 2 then - return true - else - return false - end -end - -function MP.apply_rulesets() - for _, ruleset in pairs(MP.Rulesets) do - local function process_banned_items(banned_items, banned_table) - if not banned_items then - return - end - for _, item_key in ipairs(banned_items) do - banned_table[item_key] = banned_table[item_key] or {} - banned_table[item_key][ruleset.key] = true - end - end - - local banned_types = { - { items = ruleset.banned_jokers, table = MP.BANNED_OBJECTS.jokers }, - { items = ruleset.banned_consumables, table = MP.BANNED_OBJECTS.consumables }, - { items = ruleset.banned_vouchers, table = MP.BANNED_OBJECTS.vouchers }, - { items = ruleset.banned_enhancements, table = MP.BANNED_OBJECTS.enhancements }, - { items = ruleset.banned_tags, table = MP.BANNED_OBJECTS.tags }, - { items = ruleset.banned_blinds, table = MP.BANNED_OBJECTS.blinds }, - } - - for _, banned_type in ipairs(banned_types) do - process_banned_items(banned_type.items, banned_type.table) - end - end - - local object_types = { - { objects = MP.BANNED_OBJECTS.jokers, mod = SMODS.Joker, global_banned = MP.DECK.BANNED_JOKERS }, - { objects = MP.BANNED_OBJECTS.consumables, mod = SMODS.Consumable, global_banned = MP.DECK.BANNED_CONSUMABLES }, - { objects = MP.BANNED_OBJECTS.vouchers, mod = SMODS.Voucher, global_banned = MP.DECK.BANNED_VOUCHERS }, - { - objects = MP.BANNED_OBJECTS.enhancements, - mod = SMODS.Enhancement, - global_banned = MP.DECK.BANNED_ENHANCEMENTS, - }, - { objects = MP.BANNED_OBJECTS.tags, mod = SMODS.Tag, global_banned = MP.DECK.BANNED_TAGS }, - { objects = MP.BANNED_OBJECTS.blinds, mod = SMODS.Blind, global_banned = MP.DECK.BANNED_BLINDS }, - } - - for _, type in ipairs(object_types) do - for obj_key, rulesets in pairs(type.objects) do - -- Find object with object key, using the same method as take_ownership - local obj = type.mod.obj_table[obj_key] or (type.mod.get_obj and type.mod:get_obj(obj_key)) - - if obj then - local old_in_pool = obj.in_pool - type.mod:take_ownership(obj_key, { - orig_in_pool = old_in_pool, -- Save the original in_pool function inside the object itself - in_pool = function(self) -- Update the in_pool function - if rulesets[MP.LOBBY.config.ruleset] and MP.LOBBY.code then - return false - elseif self.orig_in_pool then - -- behave like the original in_pool function if it's not nil - return self:orig_in_pool() - else - return self.set ~= 'Blind' or new_in_pool_for_blind(self) -- in_pool returning true doesn't overwrite original checks EXCEPT for blinds - end - end, - }, true) - else - sendWarnMessage( - ('Cannot ban %s: Does not exist.'):format(obj_key), type.mod.set - ) - end - end - for obj_key, _ in pairs(type.global_banned) do - type.mod:take_ownership(obj_key, { - in_pool = function(self) - if self.set ~= 'Blind' then - return not MP.LOBBY.code - else - return new_in_pool_for_blind(self) - end - end, - }, true) - end - end -end - --- This function writes any center rework data to G.P_CENTERS, where they will be used later in its specified ruleset --- Example usage in rulesets/standard.lua -function MP.ReworkCenter(args) - local center = G.P_CENTERS[args.key] - - -- Convert single ruleset to list for backward compatibility - local rulesets = args.ruleset - if type(rulesets) == "string" then - rulesets = {rulesets} - end - - -- Apply changes to all specified rulesets - for _, ruleset in ipairs(rulesets) do - local ruleset_ = "mp_"..ruleset.."_" - for k, v in pairs(args) do - if k ~= "key" and k ~= ruleset then - center[ruleset_..k] = v - if not center["mp_vanilla_"..k] then - center["mp_vanilla_"..k] = center[k] - end - end - end - center.mp_reworks = center.mp_reworks or {} - center.mp_reworks[ruleset] = true -- Caching this for better load times since we're gonna be inefficiently looping through all centers probably - center.mp_reworks["vanilla"] = true - end -end - --- You can call this function without a ruleset to set it to vanilla --- You can also call this function with a key to only affect that specific joker (might be useful) -function MP.LoadReworks(ruleset, key) - ruleset = ruleset or "vanilla" - if string.sub(ruleset, 1, 11) == "ruleset_mp_" then - ruleset = string.sub(ruleset, 12, #ruleset) - end - local function process(key_, ruleset_) - local center = G.P_CENTERS[key_] - for k, v in pairs(center) do - if string.sub(k, 1, #ruleset_) == ruleset_ then - local orig = string.sub(k, #ruleset_ + 1) - if orig == "rarity" then - SMODS.remove_pool(G.P_JOKER_RARITY_POOLS[center[orig]], center.key) - SMODS.insert_pool(G.P_JOKER_RARITY_POOLS[center[k]], center, true) - end - center[orig] = center[k] - end - end - end - if key then process(key, "mp_"..ruleset.."_") else - for k, v in pairs(G.P_CENTERS) do - if v.mp_reworks then - if v.mp_reworks[ruleset] then - process(k, "mp_"..ruleset.."_") - elseif v.mp_reworks["vanilla"] then -- Check vanilla separately to reset reworked jokers - process(k, "mp_vanilla_") - end - end - end - end -end \ No newline at end of file +G.P_CENTER_POOLS.Ruleset = {} +MP.Rulesets = {} +MP.Ruleset = SMODS.GameObject:extend({ + obj_table = {}, + obj_buffer = {}, + required_params = { + "key", + "multiplayer_content", + "banned_jokers", + "banned_consumables", + "banned_vouchers", + "banned_enhancements", + "banned_tags", + "banned_blinds", + "reworked_jokers", + "reworked_consumables", + "reworked_vouchers", + "reworked_enhancements", + "reworked_tags", + "reworked_blinds", + "create_info_menu" + }, + class_prefix = "ruleset", + inject = function(self) + MP.Rulesets[self.key] = self + if not G.P_CENTER_POOLS.Ruleset then + G.P_CENTER_POOLS.Ruleset = {} + end + table.insert(G.P_CENTER_POOLS.Ruleset, self) + end, + process_loc_text = function(self) + SMODS.process_loc_text(G.localization.descriptions["Ruleset"], self.key, self.loc_txt) + end, + is_disabled = function(self) + return false + end, + force_lobby_options = function(self) + return false + end +}) + +function MP.ApplyBans() + if MP.LOBBY.code and MP.LOBBY.config.ruleset then + local ruleset = MP.Rulesets[MP.LOBBY.config.ruleset] + local gamemode = MP.Gamemodes["gamemode_mp_"..MP.LOBBY.type] + local banned_tables = { + "jokers", + "consumables", + "vouchers", + "enhancements", + "tags", + "blinds", + } + for _, table in ipairs(banned_tables) do + for _, v in ipairs(ruleset["banned_" .. table]) do + G.GAME.banned_keys[v] = true + end + for _, v in ipairs(gamemode["banned_" .. table]) do + G.GAME.banned_keys[v] = true + end + for k, v in pairs(MP.DECK["BANNED_" .. string.upper(table)]) do + G.GAME.banned_keys[k] = true + end + end + end +end + +-- This function writes any center rework data to G.P_CENTERS, where they will be used later in its specified ruleset +-- Example usage in rulesets/standard.lua +function MP.ReworkCenter(args) + local center = G.P_CENTERS[args.key] + + -- Convert single ruleset to list for backward compatibility + local rulesets = args.ruleset + if type(rulesets) == "string" then + rulesets = { rulesets } + end + + -- Apply changes to all specified rulesets + for _, ruleset in ipairs(rulesets) do + local ruleset_ = "mp_" .. ruleset .. "_" + for k, v in pairs(args) do + if k ~= "key" and k ~= "ruleset" and k ~= "silent" then + center[ruleset_ .. k] = v + if not center["mp_vanilla_" .. k] then + center["mp_vanilla_" .. k] = center[k] + end + end + end + center.mp_reworks = center.mp_reworks or {} + center.mp_reworks[ruleset] = true -- Caching this for better load times since we're gonna be inefficiently looping through all centers probably + center.mp_reworks["vanilla"] = true + + center.mp_silent = center.mp_silent or {} + center.mp_silent[ruleset] = args.silent + end +end + +-- You can call this function without a ruleset to set it to vanilla +-- You can also call this function with a key to only affect that specific joker (might be useful) +function MP.LoadReworks(ruleset, key) + ruleset = ruleset or "vanilla" + if string.sub(ruleset, 1, 11) == "ruleset_mp_" then + ruleset = string.sub(ruleset, 12, #ruleset) + end + local function process(key_, ruleset_) + local center = G.P_CENTERS[key_] + for k, v in pairs(center) do + if string.sub(k, 1, #ruleset_) == ruleset_ then + local orig = string.sub(k, #ruleset_ + 1) + if orig == "rarity" then + SMODS.remove_pool(G.P_JOKER_RARITY_POOLS[center[orig]], center.key) + SMODS.insert_pool(G.P_JOKER_RARITY_POOLS[center[k]], center, true) + end + center[orig] = center[k] + end + end + end + if key then + process(key, "mp_" .. ruleset .. "_") + else + for k, v in pairs(G.P_CENTERS) do + if v.mp_reworks then + if v.mp_reworks[ruleset] then + process(k, "mp_" .. ruleset .. "_") + elseif v.mp_reworks["vanilla"] then -- Check vanilla separately to reset reworked jokers + process(k, "mp_vanilla_") + end + end + end + end +end diff --git a/ui/components/background_grouping.lua b/ui/components/background_grouping.lua deleted file mode 100644 index 8471754e..00000000 --- a/ui/components/background_grouping.lua +++ /dev/null @@ -1,12 +0,0 @@ -function MP.UI.BackgroundGrouping(text, nodes, config) - config = config or {} - config.text_scale = config.text_scale or 0.33 - return { - n = config.col and G.UIT.C or G.UIT.R, - config = { align = "cm", padding = 0.05, r = 0.1, colour = G.C.UI.TRANSPARENT_DARK }, - nodes = { - { n = G.UIT.R, config = { align = "cm" }, nodes = nodes }, - { n = G.UIT.R, config = { align = "cm", padding = 0.05 }, nodes = { { n = G.UIT.T, config = { text = text, colour = lighten(G.C.L_BLACK, 0.5), scale = config.text_scale } } } } - } - } -end diff --git a/ui/components/blind_chip.lua b/ui/components/blind_chip.lua deleted file mode 100644 index 8b201b0c..00000000 --- a/ui/components/blind_chip.lua +++ /dev/null @@ -1,26 +0,0 @@ -MP.UI.BlindChip = {} - -function MP.UI.BlindChip.custom(atlas, x, y) - local blind_chip = AnimatedSprite(0, 0, 1.4, 1.4, G.ANIMATION_ATLAS[atlas], { x = x, y = y }) - blind_chip:define_draw_steps({ - { shader = "dissolve", shadow_height = 0.05 }, - { shader = "dissolve" }, - }) - return blind_chip -end - -function MP.UI.BlindChip.small() - return MP.UI.BlindChip.custom("blind_chips", 0, 0) -end - -function MP.UI.BlindChip.big() - return MP.UI.BlindChip.custom("blind_chips", 0, 1) -end - -function MP.UI.BlindChip.random() - return MP.UI.BlindChip.custom("blind_chips", 0, 30) -end - -function MP.UI.BlindChip.nemesis() - return MP.UI.BlindChip.custom("mp_player_blind_col", 0, 22) -end diff --git a/ui/components/disableable_button.lua b/ui/components/disableable_button.lua deleted file mode 100644 index a8bb7b1e..00000000 --- a/ui/components/disableable_button.lua +++ /dev/null @@ -1,17 +0,0 @@ -function MP.UI.Disableable_Button(args) - local enabled_table = args.enabled_ref_table or {} - local enabled = enabled_table[args.enabled_ref_value] - args.colour = args.colour or G.C.RED - args.text_colour = args.text_colour or G.C.UI.TEXT_LIGHT - args.disabled_text = args.disabled_text or args.label - args.label = not enabled and args.disabled_text or args.label - - local button_component = UIBox_button(args) - button_component.nodes[1].config.button = enabled and args.button or nil - button_component.nodes[1].config.hover = enabled - button_component.nodes[1].config.shadow = enabled - button_component.nodes[1].config.colour = enabled and args.colour or G.C.UI.BACKGROUND_INACTIVE - button_component.nodes[1].nodes[1].nodes[1].colour = enabled and args.text_colour or G.C.UI.TEXT_INACTIVE - button_component.nodes[1].nodes[1].nodes[1].shadow = enabled - return button_component -end \ No newline at end of file diff --git a/ui/components/disableable_option_cycle.lua b/ui/components/disableable_option_cycle.lua deleted file mode 100644 index b538c0ed..00000000 --- a/ui/components/disableable_option_cycle.lua +++ /dev/null @@ -1,12 +0,0 @@ -function MP.UI.Disableable_Option_Cycle(args) - local enabled_table = args.enabled_ref_table or {} - local enabled = enabled_table[args.enabled_ref_value] - - if not enabled then - args.options = { args.options[args.current_option] } - args.current_option = 1 - end - - local option_component = create_option_cycle(args) - return option_component -end \ No newline at end of file diff --git a/ui/components/disableable_toggle.lua b/ui/components/disableable_toggle.lua deleted file mode 100644 index 0f03b8c6..00000000 --- a/ui/components/disableable_toggle.lua +++ /dev/null @@ -1,12 +0,0 @@ -function MP.UI.Disableable_Toggle(args) - local enabled_table = args.enabled_ref_table or {} - local enabled = enabled_table[args.enabled_ref_value] - - local toggle_component = create_toggle(args) - toggle_component.nodes[2].nodes[1].nodes[1].config.id = args.id - toggle_component.nodes[2].nodes[1].nodes[1].config.button = enabled and "toggle_button" or nil - toggle_component.nodes[2].nodes[1].nodes[1].config.button_dist = enabled and 0.2 or nil - toggle_component.nodes[2].nodes[1].nodes[1].config.hover = enabled and true or false - toggle_component.nodes[2].nodes[1].nodes[1].config.toggle_callback = enabled and args.callback or nil - return toggle_component -end \ No newline at end of file diff --git a/ui/components/lobby_code_buttons.lua b/ui/components/lobby_code_buttons.lua deleted file mode 100644 index 2c17b9df..00000000 --- a/ui/components/lobby_code_buttons.lua +++ /dev/null @@ -1,28 +0,0 @@ --- Component for view/copy code buttons in lobby -function MP.UI.create_lobby_code_buttons(text_scale) - return { - n = G.UIT.C, - config = { - align = "cm", - }, - nodes = { - UIBox_button({ - button = "view_code", - colour = G.C.PALE_GREEN, - minw = 2.15, - minh = 0.65, - label = { localize("b_view_code") }, - scale = text_scale * 1.2, - }), - MP.UI.create_spacer(0.1, true), - UIBox_button({ - button = "copy_to_clipboard", - colour = G.C.PERISHABLE, - minw = 2.15, - minh = 0.65, - label = { localize("b_copy_code") }, - scale = text_scale, - }), - }, - } -end diff --git a/ui/components/lobby_deck_button.lua b/ui/components/lobby_deck_button.lua deleted file mode 100644 index 340b8323..00000000 --- a/ui/components/lobby_deck_button.lua +++ /dev/null @@ -1,45 +0,0 @@ -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) - local deck_labels = { - localize({ - type = "name_text", - key = MP.UTILS.get_deck_key_from_name(back), - set = "Back", - }), - localize({ - type = "name_text", - key = SMODS.stake_from_index(type(stake) == "string" and tonumber(stake) or stake), - set = "Stake", - }), - } - - if MP.LOBBY.is_host then - return Disableable_Button({ - id = "lobby_choose_deck", - button = "lobby_choose_deck", - colour = G.C.PURPLE, - minw = 2.15, - minh = 1.35, - label = deck_labels, - scale = text_scale * 1.2, - col = true, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "is_host", - }) - else - return Disableable_Button({ - id = "lobby_choose_deck", - button = "lobby_choose_deck", - colour = G.C.PURPLE, - minw = 2.15, - minh = 1.35, - label = deck_labels, - scale = text_scale * 1.2, - col = true, - enabled_ref_table = MP.LOBBY.config, - enabled_ref_value = "different_decks", - }) - end -end diff --git a/ui/components/lobby_gamemode_tab.lua b/ui/components/lobby_gamemode_tab.lua deleted file mode 100644 index a5239b6d..00000000 --- a/ui/components/lobby_gamemode_tab.lua +++ /dev/null @@ -1,87 +0,0 @@ -local function create_lobby_option_cycle(id, label_key, scale, options, current_option, callback) - return MP.UI.Disableable_Option_Cycle({ - id = id, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "is_host", - label = localize(label_key), - scale = scale, - options = options, - current_option = current_option, - opt_callback = callback, - }) -end - --- Component for gamemode modifiers tab containing option cycles -function MP.UI.create_gamemode_modifiers_tab() - return { - n = G.UIT.ROOT, - config = { - emboss = 0.05, - minh = 6, - r = 0.1, - minw = 10, - align = "tm", - padding = 0.2, - colour = G.C.BLACK, - }, - nodes = { - { - n = G.UIT.R, - config = { padding = 0, align = "cm" }, - nodes = { - create_lobby_option_cycle( - "starting_lives_option", - "b_opts_lives", - 0.85, - { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }, - MP.LOBBY.config.starting_lives, - "change_starting_lives" - ), - create_lobby_option_cycle( - "pvp_round_start_option", - "k_opts_pvp_start_round", - 0.85, - { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }, - MP.LOBBY.config.pvp_start_round, - "change_starting_pvp_round" - ), - create_lobby_option_cycle( - "pvp_timer_seconds_option", - "k_opts_pvp_timer", - 0.85, - { "30s", "60s", "90s", "120s", "150s", "180s", "210s", "240s" }, - MP.LOBBY.config.timer_base_seconds / 30, - "change_timer_base_seconds" - ), - create_lobby_option_cycle( - "showdown_starting_antes_option", - "k_opts_showdown_starting_antes", - 0.85, - { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }, - MP.LOBBY.config.showdown_starting_antes, - "change_showdown_starting_antes" - ), - create_lobby_option_cycle( - "pvp_timer_increment_seconds_option", - "k_opts_pvp_timer_increment", - 0.85, - { "0s", "30s", "60s", "90s", "120s", "150s", "180s" }, - MP.UTILS.get_array_index_by_value( - { 0, 30, 60, 90, 120, 150, 180 }, - MP.LOBBY.config.timer_increment_seconds - ), - "change_timer_increment_seconds" - ), - create_lobby_option_cycle( - "pvp_countdown_seconds_option", - "k_opts_pvp_countdown_seconds", - 0.85, - { 0, 3, 5, 10 }, - MP.UTILS.get_array_index_by_value({ 0, 3, 5, 10 }, MP.LOBBY.config.pvp_countdown_seconds), - "change_pvp_countdown_seconds" - ), - }, - }, - }, - } -end diff --git a/ui/components/lobby_main_button.lua b/ui/components/lobby_main_button.lua deleted file mode 100644 index 2886589b..00000000 --- a/ui/components/lobby_main_button.lua +++ /dev/null @@ -1,32 +0,0 @@ -local Disableable_Button = MP.UI.Disableable_Button - --- Component for main start/ready button in lobby -function MP.UI.create_lobby_main_button(text_scale) - if MP.LOBBY.is_host then - return Disableable_Button({ - id = "lobby_menu_start", - button = "lobby_start_game", - colour = G.C.BLUE, - minw = 3.65, - minh = 1.55, - label = { localize("b_start") }, - disabled_text = MP.LOBBY.guest.username and localize("b_wait_for_guest_ready") - or localize("b_wait_for_players"), - scale = text_scale * 2, - col = true, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "ready_to_start", - }) - else - return UIBox_button({ - id = "lobby_menu_start", - button = "lobby_ready_up", - colour = MP.LOBBY.ready_to_start and G.C.GREEN or G.C.RED, - minw = 3.65, - minh = 1.55, - label = { MP.LOBBY.ready_to_start and localize("b_unready") or localize("b_ready") }, - scale = text_scale * 2, - col = true, - }) - end -end diff --git a/ui/components/lobby_options_tab.lua b/ui/components/lobby_options_tab.lua deleted file mode 100644 index 2bc7e76c..00000000 --- a/ui/components/lobby_options_tab.lua +++ /dev/null @@ -1,276 +0,0 @@ -local Disableable_Toggle = MP.UI.Disableable_Toggle -local Disableable_Button = MP.UI.Disableable_Button - --- TODO repetition but w/e... -local function send_lobby_options(value) - MP.ACTIONS.lobby_options() -end - -function G.FUNCS.custom_seed_overlay(e) - G.FUNCS.overlay_menu({ - definition = G.UIDEF.create_UIBox_custom_seed_overlay(), - }) -end - -function G.FUNCS.custom_seed_reset(e) - MP.LOBBY.config.custom_seed = "random" - send_lobby_options() -end - -function G.UIDEF.create_UIBox_custom_seed_overlay() - return create_UIBox_generic_options({ - back_func = "lobby_options", - contents = { - { - n = G.UIT.R, - config = { align = "cm", colour = G.C.CLEAR }, - nodes = { - { - n = G.UIT.C, - config = { align = "cm", minw = 0.1 }, - nodes = { - create_text_input({ - max_length = 8, - all_caps = true, - ref_table = MP.LOBBY, - ref_value = "temp_seed", - prompt_text = localize("k_enter_seed"), - keyboard_offset = 4, - callback = function(val) - MP.LOBBY.config.custom_seed = MP.LOBBY.temp_seed - send_lobby_options() - end, - }), - { - n = G.UIT.B, - config = { w = 0.1, h = 0.1 }, - }, - { - n = G.UIT.T, - config = { - scale = 0.3, - text = localize("k_enter_to_save"), - colour = G.C.UI.TEXT_LIGHT, - }, - }, - }, - }, - }, - }, - }, - }) -end - -function toggle_different_seeds() - G.FUNCS.lobby_options() - send_lobby_options() -end - -G.FUNCS.change_starting_lives = function(args) - MP.LOBBY.config.starting_lives = args.to_val - send_lobby_options() -end - -G.FUNCS.change_starting_pvp_round = function(args) - MP.LOBBY.config.pvp_start_round = args.to_val - send_lobby_options() -end - -G.FUNCS.change_timer_base_seconds = function(args) - MP.LOBBY.config.timer_base_seconds = tonumber(args.to_val:sub(1, -2)) - send_lobby_options() -end - -G.FUNCS.change_timer_increment_seconds = function(args) - MP.LOBBY.config.timer_increment_seconds = tonumber(args.to_val:sub(1, -2)) - send_lobby_options() -end - -G.FUNCS.change_showdown_starting_antes = function(args) - MP.LOBBY.config.showdown_starting_antes = args.to_val - send_lobby_options() -end - -G.FUNCS.change_pvp_countdown_seconds = function(args) - MP.LOBBY.config.pvp_countdown_seconds = args.to_val - send_lobby_options() -end - --- This needs to have a parameter because its a callback for inputs -local function send_lobby_options(value) - MP.ACTIONS.lobby_options() -end - -function G.FUNCS.display_custom_seed(e) - local display = MP.LOBBY.config.custom_seed == "random" and localize("k_random") or MP.LOBBY.config.custom_seed - if display ~= e.children[1].config.text then - e.children[2].config.text = display - e.UIBox:recalculate(true) - end -end - --- Component for lobby options tab containing toggles and custom seed section -local function create_lobby_option_toggle(id, label_key, ref_value, callback) - return { - n = G.UIT.R, - config = { - padding = 0, - align = "cr", - }, - nodes = { - Disableable_Toggle({ - id = id, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "is_host", - label = localize(label_key), - ref_table = MP.LOBBY.config, - ref_value = ref_value, - callback = callback or send_lobby_options, - }), - }, - } -end - -local function create_custom_seed_section() - if MP.LOBBY.config.different_seeds then - return { n = G.UIT.B, config = { w = 0.1, h = 0.1 } } - end - - return { - n = G.UIT.R, - config = { padding = 0, align = "cr" }, - nodes = { - { - n = G.UIT.R, - config = { - padding = 0, - align = "cr", - }, - nodes = { - { - n = G.UIT.C, - config = { - padding = 0, - align = "cm", - }, - nodes = { - { - n = G.UIT.R, - config = { - padding = 0.2, - align = "cr", - func = "display_custom_seed", - }, - nodes = { - { - n = G.UIT.T, - config = { - scale = 0.45, - text = localize("k_current_seed"), - colour = G.C.UI.TEXT_LIGHT, - }, - }, - { - n = G.UIT.T, - config = { - scale = 0.45, - text = MP.LOBBY.config.custom_seed, - colour = G.C.UI.TEXT_LIGHT, - }, - }, - }, - }, - { - n = G.UIT.R, - config = { - padding = 0.2, - align = "cr", - }, - nodes = { - Disableable_Button({ - id = "custom_seed_overlay", - button = "custom_seed_overlay", - colour = G.C.BLUE, - minw = 3.65, - minh = 0.6, - label = { - localize("b_set_custom_seed"), - }, - disabled_text = { - localize("b_set_custom_seed"), - }, - scale = 0.45, - col = true, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "is_host", - }), - { - n = G.UIT.B, - config = { - w = 0.1, - h = 0.1, - }, - }, - Disableable_Button({ - id = "custom_seed_reset", - button = "custom_seed_reset", - colour = G.C.RED, - minw = 1.65, - minh = 0.6, - label = { - localize("b_reset"), - }, - disabled_text = { - localize("b_reset"), - }, - scale = 0.45, - col = true, - enabled_ref_table = MP.LOBBY, - enabled_ref_value = "is_host", - }), - }, - }, - }, - }, - }, - }, - }, - } -end - --- Creates the lobby options tab UI containing toggles for various multiplayer settings --- Returns a UI table with lobby configuration options like gold on life loss, different seeds, etc. -function MP.UI.create_lobby_options_tab() - return { - n = G.UIT.ROOT, - config = { - emboss = 0.05, - minh = 6, - r = 0.1, - minw = 10, - align = "tm", - padding = 0.2, - colour = G.C.BLACK, - }, - nodes = { - create_lobby_option_toggle("gold_on_life_loss_toggle", "b_opts_cb_money", "gold_on_life_loss"), - create_lobby_option_toggle( - "no_gold_on_round_loss_toggle", - "b_opts_no_gold_on_loss", - "no_gold_on_round_loss" - ), - create_lobby_option_toggle("death_on_round_loss_toggle", "b_opts_death_on_loss", "death_on_round_loss"), - create_lobby_option_toggle( - "different_seeds_toggle", - "b_opts_diff_seeds", - "different_seeds", - toggle_different_seeds - ), - create_lobby_option_toggle("different_decks_toggle", "b_opts_player_diff_deck", "different_decks"), - create_lobby_option_toggle("multiplayer_jokers_toggle", "b_opts_multiplayer_jokers", "multiplayer_jokers"), - create_lobby_option_toggle("timer_toggle", "b_opts_timer", "timer"), - create_lobby_option_toggle("normal_bosses_toggle", "b_opts_normal_bosses", "normal_bosses"), - create_custom_seed_section(), - }, - } -end diff --git a/ui/components/lobby_status_display.lua b/ui/components/lobby_status_display.lua deleted file mode 100644 index 0b65474a..00000000 --- a/ui/components/lobby_status_display.lua +++ /dev/null @@ -1,108 +0,0 @@ -local function get_warnings() - local warnings = {} - - -- Check the other player (guest if we're host, host if we're guest) - local other_player = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host - - if other_player and other_player.cached == false then - table.insert(warnings, { localize("k_warning_cheating1"), SMODS.Gradients.warning_text, 0.4 }) - table.insert( - warnings, - { string.format(localize("k_warning_cheating2"), MP.UTILS.random_message()), SMODS.Gradients.warning_text } - ) - end - - if other_player and other_player.config and other_player.config.unlocked == false then - table.insert(warnings, { - localize("k_warning_nemesis_unlock"), - SMODS.Gradients.warning_text, - 0.25, - }) - end - - local current_player = MP.LOBBY.is_host and MP.LOBBY.host or MP.LOBBY.guest - local current_has_order = current_player and current_player.config and current_player.config.TheOrder - local other_has_order = other_player and other_player.config and other_player.config.TheOrder - - if (MP.LOBBY.ready_to_start or not MP.LOBBY.is_host) and current_has_order ~= other_has_order then - table.insert(warnings, { - localize("k_warning_no_order"), - SMODS.Gradients.warning_text, - }) - end - - if MP.LOBBY.ready_to_start or not MP.LOBBY.is_host then - local hostSteamoddedVersion = MP.LOBBY.host and MP.LOBBY.host.config and MP.LOBBY.host.config.Mods["Steamodded"] - local guestSteamoddedVersion = MP.LOBBY.guest - and MP.LOBBY.guest.config - and MP.LOBBY.guest.config.Mods["Steamodded"] - - if hostSteamoddedVersion ~= guestSteamoddedVersion then - table.insert(warnings, { - localize("k_steamodded_warning"), - SMODS.Gradients.warning_text, - }) - end - end - - SMODS.Mods["Multiplayer"].config.unlocked = MP.UTILS.unlock_check() - if not SMODS.Mods["Multiplayer"].config.unlocked then - table.insert(warnings, { - localize("k_warning_unlock_profile"), - SMODS.Gradients.warning_text, - 0.25, - }) - end - - -- ???: What is this supposed to accomplish? - if MP.LOBBY.username == "Guest" then - table.insert(warnings, { - localize("k_set_name"), - G.C.UI.TEXT_LIGHT, - }) - end - - if #warnings == 0 then - table.insert(warnings, { - " ", - G.C.UI.TEXT_LIGHT, - }) - end - - return warnings -end - -function MP.UI.lobby_status_display() - local warnings = get_warnings() - - local warning_texts = {} - for k, v in pairs(warnings) do - table.insert(warning_texts, { - n = G.UIT.R, - config = { - padding = -0.25, - align = "cm", - }, - nodes = { - { - n = G.UIT.T, - config = { - text = v[1], - colour = v[2], - shadow = true, - scale = v[3] or 0.25, - }, - }, - }, - }) - end - - return { - n = G.UIT.R, - config = { - padding = 0.35, - align = "cm", - }, - nodes = warning_texts, - } -end diff --git a/ui/components/main_lobby_options.lua b/ui/components/main_lobby_options.lua deleted file mode 100644 index 4a06dd80..00000000 --- a/ui/components/main_lobby_options.lua +++ /dev/null @@ -1,106 +0,0 @@ -local function create_main_lobby_options_title(info_area_id) - local title_colour = mix_colours(G.C.RED, G.C.BLACK, 0.6) - local title = "ERROR" - - if info_area_id == "ruleset_area" then - title_colour = mix_colours(G.C.BLUE, G.C.BLACK, 0.6) - title = localize("k_rulesets") - end - - if info_area_id == "gamemode_area" then - title_colour = mix_colours(G.C.ORANGE, G.C.BLACK, 0.6) - title = localize("k_gamemodes") - end - - if title == "ERROR" then - return nil - end - - return { - n = G.UIT.R, - config = { id = 'ruleset_name', align = "cm", padding = 0.07 }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm", r = 0.1, outline = 1, outline_colour = title_colour, colour = darken(title_colour, 0.3), minw = 2.9, emboss = 0.1, padding = 0.07, line_emboss = 1 }, - nodes = { - { n = G.UIT.O, config = { object = DynaText({ string = title, colours = { G.C.WHITE }, shadow = true, float = true, y_offset = -4, scale = 0.45, maxw = 2.8 }) } }, - } - }, - } - } -end - -function MP.UI.Main_Lobby_Options(info_area_id, default_info_area, button_func, buttons_data) - local categories = { - create_main_lobby_options_title(info_area_id) - } - for cat_idx, category in ipairs(buttons_data) do - local buttons = {} - for btn_idx, data in ipairs(category.buttons) do - local col = data.button_col or G.C.RED - if data.button_id == "weekly_ruleset_button" then -- putting the logic here because whatever - if (not MP.LOBBY.config.weekly) or (MP.LOBBY.config.weekly ~= MP.LOBBY.fetched_weekly) then - col = G.C.DARK_EDITION - end - end - local button = UIBox_button({ - id = data.button_id, - col = true, - chosen = (cat_idx == 1 and btn_idx == 1 and "vert" or false), - label = { localize(data.button_localize_key) }, - button = - button_func, - colour = col, - minw = 4, - scale = 0.4, - minh = 0.6 - }) - buttons[#buttons + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.05 }, nodes = { button } } - end - categories[#categories + 1] = MP.UI.BackgroundGrouping(localize(category.name), buttons) - end - - return create_UIBox_generic_options({ - back_func = "play_options", - contents = { - { n = G.UIT.C, config = { align = "tm", minh = 8, minw = 4, padding = 0.1 }, nodes = categories }, - { - n = G.UIT.C, - config = { align = "cm", minh = 8, maxh = 8, minw = 11 }, - nodes = { - { n = G.UIT.O, config = { id = info_area_id, object = default_info_area } } - } - } - } - }) -end - -function MP.UI.Change_Main_Lobby_Options(e, info_area_id, info_area_func, default_button_id, update_lobby_config_func) - if not G.OVERLAY_MENU then return end - - local info_area = G.OVERLAY_MENU:get_UIE_by_ID(info_area_id) - if not info_area then return end - - -- Switch 'chosen' status from the previously-chosen button to this one: - if info_area.config.prev_chosen then - info_area.config.prev_chosen.config.chosen = nil - else -- The previously-chosen button should be the default one here: - local default_button = G.OVERLAY_MENU:get_UIE_by_ID(default_button_id) - if default_button then default_button.config.chosen = nil end - end - e.config.chosen = "vert" -- Special setting to show 'chosen' indicator on the side - - local info_obj_name = string.match(e.config.id, "([^_]+)") - update_lobby_config_func(info_obj_name) - - if info_area.config.object then info_area.config.object:remove() end - info_area.config.object = UIBox({ - definition = info_area_func(info_obj_name), - config = { align = "cm", parent = info_area } - }) - - info_area.config.object:recalculate() - - info_area.config.prev_chosen = e -end diff --git a/ui/components/players_section.lua b/ui/components/players_section.lua deleted file mode 100644 index cbb3d4e8..00000000 --- a/ui/components/players_section.lua +++ /dev/null @@ -1,75 +0,0 @@ -local function create_player_info_row(player, player_type, text_scale) - if not player or not player.username then - return nil - end - - return { - n = G.UIT.R, - config = { - padding = 0.1, - align = "cm", - }, - nodes = { - { - n = G.UIT.T, - config = { - ref_table = player, - ref_value = "username", - shadow = true, - scale = text_scale * 0.8, - colour = G.C.UI.TEXT_LIGHT, - }, - }, - { - n = G.UIT.B, - config = { - w = 0.1, - h = 0.1, - }, - }, - player.hash and UIBox_button({ - id = player_type .. "_hash", - button = "view_" .. player_type .. "_hash", - label = { player.hash }, - minw = 0.75, - minh = 0.3, - scale = 0.25, - shadow = false, - colour = G.C.PURPLE, - col = true, - }) or nil, - }, - } -end - -function MP.UI.create_players_section(text_scale) - return { - n = G.UIT.C, - config = { - align = "tm", - minw = 2.65, - }, - nodes = { - { - n = G.UIT.R, - config = { - padding = 0.15, - align = "cm", - }, - nodes = { - { - n = G.UIT.T, - config = { - text = localize("k_connect_player"), - shadow = true, - scale = text_scale * 0.8, - colour = G.C.UI.TEXT_LIGHT, - }, - }, - }, - }, - create_player_info_row(MP.LOBBY.host, "host", text_scale), - create_player_info_row(MP.LOBBY.guest, "guest", text_scale), - }, - } -end diff --git a/ui/components/utils.lua b/ui/components/utils.lua deleted file mode 100644 index 4a30d839..00000000 --- a/ui/components/utils.lua +++ /dev/null @@ -1,21 +0,0 @@ --- Utility functions for UI components - -function MP.UI.create_spacer(size, row) - size = size or 0.2 - - return row and { - n = G.UIT.R, - config = { - align = "cm", - minh = size, - }, - nodes = {}, - } or { - n = row and G.UIT.R or G.UIT.C, - config = { - align = "cm", - minw = size, - }, - nodes = {}, - } -end \ No newline at end of file From 49ce21e61e5734360e40b2c0629d0efc3e58b63e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:08:16 +0000 Subject: [PATCH 6/6] Address FilPag's feedback: revert to coop structure, use FilPag's socket.lua, update bloodstone Co-authored-by: FilPag <1493826+FilPag@users.noreply.github.com> --- core.lua | 74 +++++++++++++++++------------------ networking/socket.lua | 46 ++++------------------ objects/jokers/bloodstone.lua | 2 +- 3 files changed, 44 insertions(+), 78 deletions(-) diff --git a/core.lua b/core.lua index 50546233..5237d9d4 100644 --- a/core.lua +++ b/core.lua @@ -1,11 +1,32 @@ MP = SMODS.current_mod +G.FPS_CAP = 60 MP.LOBBY = { connected = false, temp_code = "", temp_seed = "", code = nil, type = "", - config = {}, -- Now set in MP.reset_lobby_config + config = { + gold_on_life_loss = true, + no_gold_on_round_loss = false, + death_on_round_loss = true, + different_seeds = false, + starting_lives = 4, + pvp_start_round = 2, + timer_base_seconds = 150, + timer_increment_seconds = 60, + showdown_starting_antes = 3, + ruleset = nil, + gamemode = "gamemode_mp_attrition", + custom_seed = "random", + different_decks = false, + back = "Red Deck", + sleeve = "sleeve_casl_none", + stake = 1, + challenge = "", + multiplayer_jokers = true, + timer = true, + }, deck = { back = "Red Deck", sleeve = "sleeve_casl_none", @@ -13,14 +34,20 @@ MP.LOBBY = { challenge = "", }, username = "Guest", + ready_text = "Ready", + id = "", blind_col = 1, - host = {}, - guest = {}, - is_host = false, - ready_to_start = false, + players = {}, + isHost = false, +} +MP.FLAGS = { + join_pressed = false, } MP.GAME = {} +MP.NETWORKING = {} MP.UI = {} +MP.UI_UTILS = {} +MP.UIDEF = {} MP.ACTIONS = {} MP.INTEGRATIONS = { TheOrder = SMODS.Mods["Multiplayer"].config.integrations.TheOrder, @@ -66,36 +93,6 @@ end MP.load_mp_file("misc/utils.lua") MP.load_mp_file("misc/insane_int.lua") -function MP.reset_lobby_config(persist_ruleset_and_gamemode) - sendDebugMessage("Resetting lobby options", "MULTIPLAYER") - MP.LOBBY.config = { - gold_on_life_loss = true, - no_gold_on_round_loss = false, - death_on_round_loss = true, - different_seeds = false, - starting_lives = 4, - pvp_start_round = 2, - timer_base_seconds = 150, - timer_increment_seconds = 60, - pvp_countdown_seconds = 3, - showdown_starting_antes = 3, - ruleset = persist_ruleset_and_gamemode and MP.LOBBY.config.ruleset or "ruleset_mp_blitz", - gamemode = persist_ruleset_and_gamemode and MP.LOBBY.config.gamemode or "gamemode_mp_attrition", - weekly = nil, - custom_seed = "random", - different_decks = false, - back = "Red Deck", - sleeve = "sleeve_casl_none", - stake = 1, - challenge = "", - multiplayer_jokers = true, - timer = true, - timer_forgiveness = 0, - forced_config = false, - } -end -MP.reset_lobby_config() - function MP.reset_game_states() sendDebugMessage("Resetting game states", "MULTIPLAYER") MP.GAME = { @@ -108,7 +105,9 @@ function MP.reset_game_states() comeback_bonus_given = true, comeback_bonus = 0, end_pvp = false, - enemy = { + next_coop_boss = nil, + players = {}, --[[@type table]] + --[[enemy = { score = MP.INSANE_INT.empty(), score_text = "0", hands = 4, @@ -119,7 +118,7 @@ function MP.reset_game_states() sells_per_ante = {}, spent_in_shop = {}, highest_score = MP.INSANE_INT.empty(), - }, + }, --]] location = "loc_selecting", next_blind_context = nil, ante_key = tostring(math.random()), @@ -133,7 +132,6 @@ function MP.reset_game_states() highest_score = MP.INSANE_INT.empty(), timer = MP.LOBBY.config.timer_base_seconds, timer_started = false, - pvp_countdown = 0, real_money = 0, ce_cache = false, furthest_blind = 0, diff --git a/networking/socket.lua b/networking/socket.lua index 0c6d3c9c..62d7cc6c 100644 --- a/networking/socket.lua +++ b/networking/socket.lua @@ -4,8 +4,6 @@ -- the necessary modules again return [[ local CONFIG_URL, CONFIG_PORT = ... --- UPSTREAM CHANGE: Removed json requirement -local json = require("json") require("love.filesystem") local socket = require("socket") @@ -38,11 +36,6 @@ local uiToNetworkChannel = love.thread.getChannel("uiToNetwork") function Networking.connect() -- TODO: Check first if Networking.Client is not null -- and if it is, skip this function - -- UPSTREAM CHANGE: Removed socket connection management logic - if Networking.Client and not isSocketClosed then - Networking.Client:close() - isSocketClosed = true - end SEND_THREAD_DEBUG_MESSAGE( string.format("Attempting to connect to multiplayer server... URL: %s, PORT: %d", CONFIG_URL, CONFIG_PORT) @@ -57,15 +50,7 @@ function Networking.connect() if connectionResult ~= 1 then SEND_THREAD_DEBUG_MESSAGE(string.format("%s", errorMessage)) - - -- UPSTREAM CHANGE: Changed to string format instead of JSON - local errorMsg = { - action = "error", - message = "Failed to connect to multiplayer server" - } - - networkToUiChannel:push(json.encode(errorMsg)) - -- UPSTREAM FORMAT: networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server") + networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server") else isSocketClosed = false end @@ -82,19 +67,11 @@ local mainThreadMessageQueue = function() for _ = 1, requestsPerCycle do local msg = uiToNetworkChannel:pop() if msg then - -- UPSTREAM CHANGE: Changed message protocol logic - if msg == "connect" then - Networking.connect() - else - -- Send any non-empty message (JSON or otherwise) to the server + if msg:find("^action") ~= nil then Networking.Client:send(msg .. "\n") + elseif msg == "connect" then + Networking.connect() end - -- UPSTREAM FORMAT: - -- if msg:find("^action") ~= nil then - -- Networking.Client:send(msg .. "\n") - -- elseif msg == "connect" then - -- Networking.connect() - -- end else -- If there are no more messages, yield coroutine.yield() @@ -149,12 +126,7 @@ local networkPacketQueue = function() isRetry = false timerCoroutine = coroutine.create(timer) - - local disconnectedAction = { - action = "disconnected", - message = "Connection closed by server", - } - networkToUiChannel:push(json.encode(disconnectedAction)) + networkToUiChannel:push("action:disconnected") else -- If there are no more packets, yield coroutine.yield() @@ -194,17 +166,13 @@ while true do timerCoroutine = coroutine.create(timer) - local disconnectedAction = { - action = "disconnected", - message = "Connection closed due to inactivity", - } - networkToUiChannel:push(json.encode(disconnectedAction)) + networkToUiChannel:push("action:disconnected") end if isRetry then retryCount = retryCount + 1 -- Send keepAlive without cutting the line - uiToNetworkChannel:push(json.encode({ action = "keepAlive" })) + uiToNetworkChannel:push("action:keepAlive") -- Restart the timer timerCoroutine = coroutine.create(timer) diff --git a/objects/jokers/bloodstone.lua b/objects/jokers/bloodstone.lua index a5462560..888dea87 100644 --- a/objects/jokers/bloodstone.lua +++ b/objects/jokers/bloodstone.lua @@ -4,7 +4,7 @@ MP.ReworkCenter({ ruleset = MP.UTILS.get_standard_rulesets(), silent = true, calculate = function(self, card, context) - if MP.is_pvp_boss() then + if MP.is_online_boss() then if not context.blueprint then if context.before then G.GAME.round_resets.mp_bloodstone = G.GAME.round_resets.mp_bloodstone or {}