diff --git a/Multiplayer.json b/Multiplayer.json index 94e83eaf5..86272223b 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,10 +12,10 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.5.1", + "version": "0.5.2", "dependencies": [ - "Steamodded (>=1.0.0~BETA-1221a)", - "Lovely (>=0.8)", + "Steamodded (>=1.0.0~BETA-1620a)", + "Lovely (>=0.9)", "Balatro (>=1.0.1o)" ], "conflicts": [ diff --git a/compatibility/TheOrder.lua b/compatibility/TheOrder.lua index 512b52819..332ac4e21 100644 --- a/compatibility/TheOrder.lua +++ b/compatibility/TheOrder.lua @@ -30,9 +30,58 @@ end local original_reset_idol_card = reset_idol_card function reset_idol_card() if MP.should_use_the_order() then + G.GAME.current_round.idol_card.rank = "Ace" G.GAME.current_round.idol_card.suit = "Spades" + -- ---------------------------------------------------------------- + -- Helper: enhancement / seal / edition weights + -- NOTE: field names below assume base-Balatro conventions: + -- card.ability.effect -> "Wild Card" / "Glass Card" / "Lucky Card" / ... + -- card.seal -> "Red" / "Blue" / "Gold" / "Purple" / nil + -- card.edition -> { foil=true } / { holo=true } / { polychrome=true } / nil + -- Adjust these three helpers if your mod stores these differently. + -- ---------------------------------------------------------------- + local function is_wild(card) + return card.ability and card.ability.effect == "Wild Card" + end + + local function edition_weight(card) + local e = card.edition + if not e then return 0.0 end + if e.polychrome then return 1.05 end + if e.glass then return 0.95 end -- some mods track glass as an edition, not enhancement + if e.holo then return 0.50 end + if e.foil then return 0.15 end + return 0.0 + end + + local function enhancement_weight(card) + local eff = card.ability and card.ability.effect + if eff == "Glass Card" then return 0.95 end + if eff == "Lucky Card" then return 0.45 end + if eff == "Steel Card" then return 0.15 end + if eff == "Wild Card" then return 0.15 end + if eff == "Bonus Card" then return 0.10 end + if eff == "Mult Card" then return 0.10 end + if eff == "Gold Card" then return 0.05 end + return 0.0 + end + + local function seal_weight(card) + local s = card.seal + if s == "Red" then return 1.2 end + if s == "Purple" then return 0.15 end + if s == "Gold" then return 0.30 end + if s == "Blue" then return 0.05 end + return 0.0 + end + + -- ---------------------------------------------------------------- + -- Step 1: Build count_map keyed by (value, suit), tracking every + -- physical card so we can later sum seal/edition/enhancement + -- weights and detect wild cards. + -- ---------------------------------------------------------------- local count_map = {} local valid_idol_cards = {} @@ -40,44 +89,234 @@ function reset_idol_card() if v.ability.effect ~= "Stone Card" then local key = v.base.value .. "_" .. v.base.suit if not count_map[key] then - count_map[key] = { count = 0, card = v } + count_map[key] = { + count = 0, + card = v, + value = v.base.value, + suit = v.base.suit, + cards = {}, + wild_count = 0, + } table.insert(valid_idol_cards, count_map[key]) end - count_map[key].count = count_map[key].count + 1 + local entry = count_map[key] + entry.count = entry.count + 1 + table.insert(entry.cards, v) + if is_wild(v) then + entry.wild_count = entry.wild_count + 1 + end end end - --failsafe in case all are stone or no cards in deck. Defaults to Ace of Spades + if #valid_idol_cards == 0 then return end - local value_order = {} - for i, rank in ipairs(SMODS.Rank.obj_buffer) do - value_order[rank] = i + -- ---------------------------------------------------------------- + -- Step 2: Rank / suit ordering from SMODS (positional index) + -- ---------------------------------------------------------------- + local rank_index = {} + for i, rank_key in ipairs(SMODS.Rank.obj_buffer) do + rank_index[rank_key] = i end - local suit_order = {} - for i, suit in ipairs(SMODS.Suit.obj_buffer) do - suit_order[suit] = i + local suit_index = {} + for i, suit_key in ipairs(SMODS.Suit.obj_buffer) do + suit_index[suit_key] = i end - table.sort(valid_idol_cards, function(a, b) - -- Sort by count descending first - if a.count ~= b.count then return a.count > b.count end + -- ---------------------------------------------------------------- + -- Step 3: Aggregate per-rank totals (physical only) + wild-by-rank + -- ---------------------------------------------------------------- + local rank_totals = {} -- rank_key -> total physical count across all suits + local wild_by_rank = {} -- rank_key -> total wild-enhanced count across all suits + local distinct_ranks_set = {} + + for _, entry in ipairs(valid_idol_cards) do + local r = entry.value + rank_totals[r] = (rank_totals[r] or 0) + entry.count + wild_by_rank[r] = (wild_by_rank[r] or 0) + entry.wild_count + distinct_ranks_set[r] = true + end + + local distinct_ranks = 0 + for _ in pairs(distinct_ranks_set) do + distinct_ranks = distinct_ranks + 1 + end + + local total_cards = 0 + for _, entry in ipairs(valid_idol_cards) do + total_cards = total_cards + entry.count + end + + local raw_mean_by_number = total_cards / distinct_ranks + + -- ---------------------------------------------------------------- + -- Step 4: Face / low pools + baselines (unchanged, mean-based, + -- rank-restricted: only face ranks / nominal 2-5 ranks qualify) + -- ---------------------------------------------------------------- + local face_pool = 0 + local low_pool = 0 + local face_ranks_present = 0 + local low_ranks_present = 0 + + for rank_key, total in pairs(rank_totals) do + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj then + if rank_obj.face then + face_pool = face_pool + total + face_ranks_present = face_ranks_present + 1 + elseif rank_obj.nominal and rank_obj.nominal >= 2 and rank_obj.nominal <= 5 then + low_pool = low_pool + total + low_ranks_present = low_ranks_present + 1 + end + end + end + + local function round_to_nearest_05(x) + return math.floor(x * 20 + 0.5) / 20 + end - local a_suit = a.card.base.suit - local b_suit = b.card.base.suit - if suit_order[a_suit] ~= suit_order[b_suit] then return suit_order[a_suit] < suit_order[b_suit] end + local face_baseline = round_to_nearest_05(raw_mean_by_number * face_ranks_present) + local low_baseline = round_to_nearest_05(raw_mean_by_number * low_ranks_present) - local a_value = a.card.base.value - local b_value = b.card.base.value - return value_order[a_value] < value_order[b_value] + local W_GEN = 0.05 + local GEN_FLOOR = 0.00 + + local function face_score_for_rank(rank_key) + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj and rank_obj.face then + return math.max(GEN_FLOOR, W_GEN * 1.1 * math.max(0.0, face_pool - face_baseline)) + end + return 0.0 + end + + local function low_score_for_rank(rank_key) + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj and rank_obj.nominal and rank_obj.nominal >= 2 and rank_obj.nominal <= 5 then + return math.max(GEN_FLOOR, W_GEN * math.max(0.0, low_pool - low_baseline)) + end + return 0.0 + end + + -- ---------------------------------------------------------------- + -- Step 5: Previous rank (positional wrap: index 1 -> last index) + -- ---------------------------------------------------------------- + local function previous_rank_key(rank_key) + local idx = rank_index[rank_key] + if not idx then return nil end + if idx == 1 then + return SMODS.Rank.obj_buffer[#SMODS.Rank.obj_buffer] + else + return SMODS.Rank.obj_buffer[idx - 1] + end + end + + -- ---------------------------------------------------------------- + -- Step 6: Tier weights / achievability weights + -- ---------------------------------------------------------------- + local TARGET_COPIES = 5 -- how many exact copies we're trying to reach + + local W_EDITION_A = 1.3 -- Tier A: extra effect of enhanced cards (quality-dominant tier) + local W_EDITION_B = 0.7 -- Tier B: cares less about this bonus and more about quantity + local W_COUNT_A = 0.5 -- Tier A: reward per existing copy (quality-dominant tier) + local W_MAIN = 2.0 -- Tier B: reward per existing copy (progress toward 5) + local W_OFF = 1.0 -- Tier B: suit-changer potential, capped by need + local W_STR = 1.0 -- Tier B: Strength potential, capped by need + + -- ---------------------------------------------------------------- + -- Step 7: Compute total score for each distinct (rank, suit) entry + -- ---------------------------------------------------------------- + for _, entry in ipairs(valid_idol_cards) do + local rank_key = entry.value + local suit_key = entry.suit + local own_count = entry.count + + -- Effective count: physical copies + wild cards of same rank, other suits + local wild_elsewhere = (wild_by_rank[rank_key] or 0) - entry.wild_count + local effective_count = own_count + wild_elsewhere + + -- Quality terms (apply in both tiers) + local face_score = face_score_for_rank(rank_key) + local low_score = low_score_for_rank(rank_key) + local seal_score = 0.0 + local edition_score = 0.0 + for _, card in ipairs(entry.cards) do + seal_score = seal_score + seal_weight(card) + edition_score = edition_score + edition_weight(card) + enhancement_weight(card) + end + + local main_hit, off_hit, strength_adj = 0.0, 0.0, 0.0 + local tier + + if effective_count >= TARGET_COPIES then + -- ------------------------------------------------------ + -- TIER A: already at/above 5 — no operations required. + -- Score by raw count + quality only; achievability terms + -- (main/off/strength) don't apply, there's no gap to close. + -- ------------------------------------------------------ + tier = 1 + entry.total_score = (W_COUNT_A * effective_count) + + face_score + low_score + ((seal_score + edition_score) * W_EDITION_A) + else + -- ------------------------------------------------------ + -- TIER B: below 5 — operations are required. Achievability + -- terms are capped both by their mechanical limit AND by + -- the actual gap remaining (closing more than the gap + -- doesn't make the card any easier to complete). + -- ------------------------------------------------------ + tier = 0 + local needed = TARGET_COPIES - effective_count + + -- Main Hit: direct reward for existing progress + main_hit = W_MAIN * effective_count + + -- Off Hit: suit-changer potential (non-wild, off-suit, same rank) + -- capped at 3 (suit-changer limit) and by remaining need + local convertible_pool = (rank_totals[rank_key] or 0) - own_count - wild_elsewhere + off_hit = W_OFF * math.min(3, math.max(0.0, convertible_pool), needed) + + -- Strength Adjacent: same-suit rank-1 neighbor (+ off-suit wild + -- at rank-1), capped at 2 (Strength limit) and by remaining need + local prev_rank = previous_rank_key(rank_key) + local neighbor_count = 0 + if prev_rank then + local neighbor_key = prev_rank .. "_" .. suit_key + local neighbor_entry = count_map[neighbor_key] + local physical_same_suit = neighbor_entry and neighbor_entry.count or 0 + local neighbor_wild_same_suit = neighbor_entry and neighbor_entry.wild_count or 0 + local prev_wild_total = wild_by_rank[prev_rank] or 0 + local prev_wild_elsewhere = prev_wild_total - neighbor_wild_same_suit + neighbor_count = physical_same_suit + prev_wild_elsewhere + end + strength_adj = W_STR * math.min(2, neighbor_count, needed) + + entry.total_score = main_hit + off_hit + strength_adj + + face_score + low_score + ((seal_score + edition_score) * W_EDITION_B) + end + + entry.tier = tier + + end + + -- ---------------------------------------------------------------- + -- Step 8: Sort — tier first (A above B), then score, then + -- deterministic tiebreakers. Selection odds in Step 9 remain + -- untouched: pure count / total_weight. + -- ---------------------------------------------------------------- + table.sort(valid_idol_cards, function(a, b) + if a.tier ~= b.tier then return a.tier > b.tier end + if a.total_score ~= b.total_score then return a.total_score > b.total_score end + if (rank_index[a.value] or 0) ~= (rank_index[b.value] or 0) then return (rank_index[a.value] or 0) > (rank_index[b.value] or 0) end + if suit_index[a.suit] ~= suit_index[b.suit] then return suit_index[a.suit] > suit_index[b.suit] end + return (rank_index[a.value] or 0) < (rank_index[b.value] or 0) end) - -- Weighted random selection based on count local total_weight = 0 for _, entry in ipairs(valid_idol_cards) do total_weight = total_weight + entry.count end + if total_weight <= 0 then return end + local raw_random = pseudorandom("idol" .. G.GAME.round_resets.ante) local threshold = 0 @@ -86,19 +325,14 @@ function reset_idol_card() if raw_random < threshold then local idol_card = entry.card sendDebugMessage( - "(Idol) Selected card " - .. idol_card.base.value - .. " of " - .. idol_card.base.suit - .. " with weight " - .. entry.count - .. " of total " - .. total_weight, - "MULTIPLAYER" + string.format( + "Selected %s of %s, with a count of %d", + idol_card.base.value, idol_card.base.suit, entry.count + ), "IdolAlgo" ) G.GAME.current_round.idol_card.rank = idol_card.base.value G.GAME.current_round.idol_card.suit = idol_card.base.suit - G.GAME.current_round.idol_card.id = idol_card.base.id + G.GAME.current_round.idol_card.id = idol_card.base.id break end end @@ -108,6 +342,7 @@ function reset_idol_card() return original_reset_idol_card() end + local original_reset_mail_rank = reset_mail_rank function reset_mail_rank() diff --git a/core.lua b/core.lua index e35ec61cd..96b3dafb6 100644 --- a/core.lua +++ b/core.lua @@ -245,8 +245,11 @@ function MP.reset_game_states() highest_score = MP.INSANE_INT.empty(), timer = MP.UTILS.timer_base(), timer_started = false, + nemesis_timer_started = false, + nemesis_timer_was_started = false, timer_consumed = false, pvp_reached = false, + pvp_reached_first = false, pvp_countdown = 0, real_money = 0, ce_cache = false, @@ -263,6 +266,8 @@ function MP.reset_game_states() reroll_cost_total = 0, -- Add more stats here in the future }, + pvp_timer_order = nil, + pvp_timer_activated = false, } end MP.reset_game_states() diff --git a/layers/pvp_timer.lua b/layers/pvp_timer.lua index b0aa2282d..7476203c4 100644 --- a/layers/pvp_timer.lua +++ b/layers/pvp_timer.lua @@ -1,4 +1,4 @@ MP.Layer("pvp_timer", { - pvp_timer_base_seconds = 90, - pvp_timer_hand_played_increment_seconds = 15, + pvp_timer_base_seconds = 60, + pvp_timer_hand_played_increment_seconds = 10, }) diff --git a/localization/en-us.lua b/localization/en-us.lua index adc86f1ed..79934cdcc 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1385,6 +1385,7 @@ return { k_amount_short = "Amt.", k_filed_ex = "Filed!", k_timer = "Timer", + k_nemesis_timer = "Nemesis", k_mods_list = "Mods List", k_enemy_jokers = "Enemy Jokers", k_your_jokers = "Your Jokers", @@ -1417,6 +1418,11 @@ return { k_ghost = "Ghost", k_hide_mp_content = "Hide Multiplayer content*", k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets", + k_automatic_pvp_timer = "Automatic PvP timer", + k_automatic_pvp_timer_description = { + "Reactivate PvP timer when became available", + "(Must be activated manually first)" + }, k_timer_sfx = "Timer Sound Effects", ml_mp_kofi_message = { "This mod and game server is", @@ -1446,6 +1452,8 @@ return { loc_selecting = "Selecting", loc_shop = "Shopping before", loc_playing = "Playing", + b_get_log_file = "Get Logs", + b_open_log_parser = "Log Parser", }, v_dictionary = { a_mp_art = { diff --git a/lovely/pause.toml b/lovely/pause.toml index 1cd1704f2..28102c7ef 100644 --- a/lovely/pause.toml +++ b/lovely/pause.toml @@ -12,6 +12,16 @@ payload = ''' local unstuck_button = nil local return_to_lobby = nil local leave_lobby = nil + +if MP.LOBBY.code and G.STATE == G.STATES.MENU then + your_collection = UIBox_button({ + label = { localize("b_collection") }, + button = "your_collection", + minw = 5, + id = "your_collection", + }) + mods = UIBox_button{ id = "mods_button", label = {localize('b_mods')}, button = "mods_button", minw = 5} +end ''' match_indent = true times = 1 diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index ea454a99c..bf04a4a4b 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -26,9 +26,16 @@ local json = require("json") Client = {} +local no_log_actions = { + dataSync = true, + keepAlive = true, + keepAliveAck = true, +} + function Client.send(msg) + local should_send_log = not (msg and msg.action and no_log_actions[msg.action]) msg = json.encode(msg) - if msg ~= '{"action":"keepAliveAck"}' then + if should_send_log then sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER") end love.thread.getChannel("uiToNetwork"):push(msg) @@ -312,6 +319,11 @@ end local function begin_pvp_blind() if MP.GAME.next_blind_context then G.FUNCS.select_blind(MP.GAME.next_blind_context) + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false + MP.GAME.timer_consumed = false + MP.GAME.timer = MP.UTILS.pvp_timer_base() else sendErrorMessage("No next blind context", "MULTIPLAYER") end @@ -328,17 +340,14 @@ local function action_start_blind(p) MP.GAME.enemy.info_received = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false - MP.GAME.timer_started = false - MP.GAME.nemesis_timer_started = false - MP.GAME.timer_consumed = false - MP.GAME.timer = MP.UTILS.pvp_timer_base() + MP.GAME.pvp_timer_order = nil + MP.GAME.pvp_timer_activated = false MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player MP.UI.start_pvp_countdown(begin_pvp_blind) end local function action_enemy_info(p) - local score = MP.INSANE_INT.from_string(p.score) - + local score local hands_left = tonumber(p.handsLeft) local skips = tonumber(p.skips) local lives = tonumber(p.lives) @@ -361,64 +370,78 @@ local function action_enemy_info(p) end end - if score == nil or hands_left == nil then - sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER") - return - end - - if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end - - -- PvP timer: stop timer according to score - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.INSANE_INT.greater_than(MP.GAME.score, score) then - MP.GAME.nemesis_timer_started = false - elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then + if not p.noScore then + score = MP.INSANE_INT.from_string(p.score) + end + + if score then + if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 0.75, + timer = "REAL", + ref_table = MP.GAME.enemy.score, + ref_value = "e_count", + ease_to = score.e_count, + func = function(t) + return math.floor(t) + end, + })) + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 0.75, + timer = "REAL", + ref_table = MP.GAME.enemy.score, + ref_value = "coeffiocient", -- why is this misspelled + ease_to = score.coeffiocient, + func = function(t) + local mult = 1 + if score.exponent > 0 then mult = 100 end + return math.floor(t * mult) / mult + end, + })) + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 0.75, + timer = "REAL", + ref_table = MP.GAME.enemy.score, + ref_value = "exponent", + ease_to = score.exponent, + func = function(t) + return math.floor(t) + end, + })) + + MP.GAME.enemy.real_score = score + MP.GAME.enemy.info_received = true + end + + -- PvP timer: server determines who and when can activate pvp timer + if p.pvpTimerOrder ~= nil and MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + MP.GAME.pvp_timer_order = p.pvpTimerOrder + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then MP.GAME.nemesis_timer_started = false + if + not MP.GAME.timer_started + and MP.UI.can_timer_opponent() + and MP.GAME.pvp_timer_activated + and SMODS.Mods["Multiplayer"].config.automatic_pvp_timer + then + MP.ACTIONS.start_ante_timer() + end else - MP.GAME.timer_started = false - 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, - })) - - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "ease", - delay = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "coeffiocient", -- why is this misspelled - ease_to = score.coeffiocient, - func = function(t) - local mult = 1 - if score.exponent > 0 then mult = 100 end - return math.floor(t * mult) / mult - end, - })) - - G.E_MANAGER:add_event(Event({ - blockable = false, - 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) - end, - })) + MP.GAME.timer_started = false + end + end if MP.GAME.enemy.lives > lives then play_sound("holo1", 0.865, 0.9) @@ -429,12 +452,9 @@ local function action_enemy_info(p) play_sound("gong", 0.765, 0.4) end - MP.GAME.enemy.real_score = score - MP.GAME.enemy.hands = hands_left - MP.GAME.enemy.skips = skips - MP.GAME.enemy.lives = lives - -- We've now heard from the opponent this blind: unmask their hands count. - MP.GAME.enemy.info_received = true + MP.GAME.enemy.hands = hands_left or 0 + MP.GAME.enemy.skips = skips or 0 + MP.GAME.enemy.lives = lives or 0 if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end end @@ -462,10 +482,29 @@ local function action_end_pvp(p) MP.GAME.timer_consumed = false MP.GAME.timer_started = false MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false MP.GAME.pvp_reached_first = false + MP.GAME.pvp_timer_activated = false MP.GAME.score = nil + + -- Sanity check + G.E_MANAGER:add_event(Event({ + func = function() + G.E_MANAGER:add_event(Event({ + func = function() + MP.GAME.timer = MP.UTILS.timer_base() + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false + MP.GAME.pvp_timer_activated = false + return true + end, + })) + return true + end, + })) end local function action_player_info(p) @@ -790,6 +829,12 @@ local function action_magnet_response(p) sendTraceMessage(string.format("Received magnet joker: %s", MP.UTILS.joker_to_string(card)), "MULTIPLAYER") end +local function action_data_sync(data) + if data.timer then + MP.GAME.enemy.last_timer = data.timer or 0 + end +end + function G.FUNCS.load_end_game_jokers() local card_area_save, success, err @@ -999,6 +1044,7 @@ local function action_start_ante_timer(p) end if from_nemesis then MP.GAME.nemesis_timer_started = true + MP.GAME.nemesis_timer_was_started = true else MP.GAME.timer_started = true end @@ -1158,17 +1204,6 @@ function MP.ACTIONS.play_hand(score, hands_left) MP.GAME.highest_score = insane_int_score end - -- Stop PvP timers according to score - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.enemy.score) then - MP.GAME.nemesis_timer_started = false - elseif MP.INSANE_INT.equal(insane_int_score, MP.GAME.enemy.score) and MP.GAME.pvp_reached_first then - MP.GAME.nemesis_timer_started = false - else - MP.GAME.timer_started = false - end - end - Client.send({ action = "playHand", score = fixed_score, @@ -1393,6 +1428,14 @@ function MP.ACTIONS.update_player_usernames() end end +function MP.ACTIONS.data_sync() + local timer = (MP.is_layer_active("speedlatro_timer") and MP.speedlatro_timer and MP.speedlatro_timer.real) or MP.GAME.timer + Client.send({ + action = "dataSync", + timer = timer + }) +end + local function string_to_table(str) local tbl = {} for part in string.gmatch(str, "([^,]+)") do @@ -1451,6 +1494,7 @@ local HANDLERS = { moddedAction = action_modded_action, error = action_error, keepAlive = action_keep_alive, + dataSync = action_data_sync, } function MP.register_action(name, cb) @@ -1489,7 +1533,7 @@ function Game:update(dt) local ok, parsedAction = pcall(json.decode, msg) if ok then - if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then + if not no_log_actions[parsedAction.action] then local log = string.format("Client got %s message: ", parsedAction.action) for k, v in pairs(parsedAction) do if parsedAction.action == "startGame" and k == "seed" then diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index d3adc4af9..e0710a8a3 100644 --- a/ui/game/blind_hud.lua +++ b/ui/game/blind_hud.lua @@ -198,17 +198,7 @@ function Blind:disable() end G.FUNCS.multiplayer_blind_chip_UI_scale = function(e) - -- Mask the opponent's hands as "?" until the first enemyInfo arrives this - -- blind (same gating as the hidden score), otherwise mirror the real count. - if - MP.LOBBY.config.hide_score_until_played - and MP.is_pvp_boss() - and not MP.GAME.enemy.info_received - then - MP.GAME.enemy.hands_text = "?" - else - MP.GAME.enemy.hands_text = tostring(MP.GAME.enemy.hands) - end + MP.GAME.enemy.hands_text = tostring(MP.GAME.enemy.hands) -- Hide the opponent's score until we have played a hand this PvP blind, so -- a player can't watch the enemy score before committing their own hand. diff --git a/ui/game/functions.lua b/ui/game/functions.lua index cd018ac69..eb9cad98a 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -89,6 +89,7 @@ G.FUNCS.skip_blind = function(e) and not MP.GAME.timer_started and not MP.GAME.nemesis_timer_started and not MP.GAME.timer_consumed + and not MP.GAME.nemesis_timer_was_started and not MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 then @@ -412,7 +413,50 @@ function MP.UI.show_asteroid_hand_level_up() end end end - SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 }) + if to_big(max_level) >= to_big(1) then + SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 }) + end +end + +function G.FUNCS.mp_open_log_parser(e) + love.system.openURL("https://balatromp.com/log-parser") +end + +function G.FUNCS.mp_get_lovely_log_file(e) + local file_path = require("lovely").log_path + local os_name = love.system.getOS() + + local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" + end + + local function fileUri(path) + path = path:gsub("\\", "/") + return "file://" .. path:gsub("([^A-Za-z0-9%-%._~/%:/])", function(c) + return string.format("%%%02X", c:byte()) + end) + end + + local ok + if os_name == "Windows" then + ok = os.execute('explorer.exe /select,"' .. file_path:gsub("/", "\\") .. '"') + elseif os_name == "OS X" then + os.execute("open -R " .. shellQuote(file_path)) + elseif os_name == "Linux" then + local cmd = + "dbus-send --session " .. + "--dest=org.freedesktop.FileManager1 " .. + "--type=method_call " .. + "/org/freedesktop/FileManager1 " .. + "org.freedesktop.FileManager1.ShowItems " .. + "array:string:" .. shellQuote(fileUri(file_path)) .. " string:''" + + ok = os.execute(cmd) + end + if not ok then + local parent_dir = file_path:match("^(.*)[/\\][^/\\]*$") or "." + love.system.openURL(fileUri(parent_dir)) + end end --[[ diff --git a/ui/game/game_end.lua b/ui/game/game_end.lua index 746c8d458..c55b86685 100644 --- a/ui/game/game_end.lua +++ b/ui/game/game_end.lua @@ -386,7 +386,34 @@ function MP.UI.create_UIBox_mp_game_end(has_won) { n = G.UIT.R, config = { align = "cm", minh = 0.45, minw = 0.1 }, - nodes = {}, + nodes = { + UIBox_button({ + button = "mp_open_log_parser", + label = { localize("b_open_log_parser") }, + minw = 1.75, + maxw = 1.75, + minh = 0.5, + maxh = 0.5, + col = true, + colour = G.C.CHIPS, + padding = 0.1, + scale = 0.4 + }), + { n = G.UIT.C, config = { minw = 0.1 } }, + UIBox_button({ + id = "from_game_won", + button = "mp_get_lovely_log_file", + label = { localize("b_get_log_file") }, + minw = 1.75, + maxw = 1.75, + minh = 0.5, + maxh = 0.5, + col = true, + colour = G.C.CHIPS, + padding = 0.1, + scale = 0.4 + }), + }, }, UIBox_button({ id = "from_game_won", @@ -394,7 +421,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) label = { localize("b_return_lobby") }, minw = 4, maxw = 4, - minh = 0.8, + minh = 0.75, focus_args = { nav = "wide", snap_to = true }, }), UIBox_button({ @@ -402,7 +429,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) label = { localize("b_leave_lobby") }, minw = 4, maxw = 4, - minh = 0.8, + minh = 0.75, focus_args = { nav = "wide" }, }), }, diff --git a/ui/game/round.lua b/ui/game/round.lua index e9fc24969..673b02795 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -36,7 +36,15 @@ end local ease_round_ref = ease_round function ease_round(mod) if MP.is_mp_or_ghost() and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then - G.GAME.round = G.GAME.round + mod + G.E_MANAGER:add_event(Event({ + trigger = 'immediate', + func = function() + G.GAME.round = G.GAME.round + mod + play_sound('timpani', 0.8) + play_sound('generic1') + return true + end + })) return end ease_round_ref(mod) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 39aac5fb5..c68cf56f4 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -1,16 +1,16 @@ -- ease_round override moved to game/round.lua -function MP.UI.cam_timer_opponent() +function MP.UI.can_timer_opponent() if not MP.LOBBY.config.timer then return false end - if MP.GAME.pvp_countdown_in_progress then return false end - if MP.GAME.timer <= 0 then return false end - if MP.GAME.nemesis_timer_started then return false end + if MP.GAME.pvp_countdown_in_progress then return false end + if MP.GAME.timer <= 0 then return false end + if MP.GAME.nemesis_timer_started then return false end + if MP.GAME.enemy.location_type == "loc_ready" then return false end + if G.STATE == G.STATES.GAME_OVER or MP.GAME.won then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.GAME.end_pvp then return false end + if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end - if MP.LOBBY.config.hide_score_until_played and not MP.GAME.enemy.info_received then return false end - if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.real_score) then return true end - if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.real_score) then return MP.GAME.pvp_reached_first end + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then return true end return false end return MP.GAME.ready_blind @@ -20,13 +20,13 @@ function G.FUNCS.mp_timer_button(e) -- Under pressure_timer the local timer auto-ticks regardless of timer_started, -- but the button still needs to fire — pressing it broadcasts startAnteTimer, -- which is what flips the opponent's nemesis_timer_started and triggers 2x. - if MP.UI.cam_timer_opponent() then - if MP.GAME.timer <= 0 then - return - elseif not MP.GAME.timer_started then + if MP.UI.can_timer_opponent() then + if not MP.GAME.timer_started then MP.ACTIONS.start_ante_timer() + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then MP.GAME.pvp_timer_activated = true end else MP.ACTIONS.pause_ante_timer() + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then MP.GAME.pvp_timer_activated = false end end end end @@ -43,6 +43,8 @@ function MP.UI.timer_hud() colour = G.C.DYN_UI.BOSS_MAIN, emboss = 0.05, r = 0.1, + hover = true, + collideable = true, }, nodes = { { @@ -71,6 +73,8 @@ function MP.UI.timer_hud() id = "row_round_text", func = "set_timer_box", button = "mp_timer_button", + hover = true, + collideable = true, }, nodes = { { @@ -107,6 +111,86 @@ function MP.UI.timer_hud() } end end +function MP.UI.nemesis_timer_hud() + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm", + padding = 0.05, + minw = 1.45, + minh = 1, + colour = G.C.DYN_UI.BOSS_MAIN, + emboss = 0.05, + r = 0.1, + }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", maxw = 1.35 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_nemesis_timer"), + minh = 0.33, + scale = 0.34, + colour = G.C.UI.TEXT_LIGHT, + shadow = true, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { + align = "cm", + r = 0.1, + minw = 1.2, + colour = G.C.DYN_UI.BOSS_DARK, + id = "row_round_text", + }, + nodes = { + { + n = G.UIT.O, + config = { + object = DynaText({ + string = { + { + ref_table = setmetatable({}, { + __index = function() + if not MP.GAME.enemy or not MP.GAME.enemy.last_timer then + return "0.0" + end + -- All numbers bigger then 10 - display as integer + -- Also accounting for rounding to prevent 10.0 to be displayed + if MP.GAME.enemy.last_timer > 9.95 then + return string.format("%d", MP.GAME.enemy.last_timer) + end + -- Less than 10 - display decimal part + return string.format("%.1f", MP.GAME.enemy.last_timer) + end, + }), + ref_value = "timer", + }, + }, -- sorry + colours = { G.C.IMPORTANT }, + shadow = true, + scale = 0.8, + }), + id = "timer_UI_count", + }, + }, + }, + }, + }, + }, + }, + } +end function MP.UI.start_pvp_countdown(callback) local seconds = countdown_seconds @@ -114,7 +198,7 @@ function MP.UI.start_pvp_countdown(callback) if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.pvp_countdown_seconds then seconds = MP.LOBBY.config.pvp_countdown_seconds end - MP.GAME.pvp_countdown_in_progress = true + MP.GAME.pvp_countdown_in_progress = true MP.GAME.pvp_countdown = seconds G.CONTROLLER.locks.enter_pvp = true @@ -122,19 +206,19 @@ function MP.UI.start_pvp_countdown(callback) local function show_next() if MP.GAME.pvp_countdown <= 0 then if callback then callback() end - G.E_MANAGER:add_event(Event({ - blocking = false, - func = function() - G.E_MANAGER:add_event(Event({ - blocking = false, - func = function() - MP.GAME.pvp_countdown_in_progress = nil - return true - end, - })) - return true - end, - })) + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + MP.GAME.pvp_countdown_in_progress = nil + return true + end, + })) + return true + end, + })) G.E_MANAGER:add_event(Event({ no_delete = true, trigger = "after", @@ -229,7 +313,7 @@ SMODS.Gradient({ if #self.colours < 2 or not MP.speedlatro_timer then return end local speedup = MP.current_ruleset().timer_speedup_multiplier or 1 - local timer_value = MP.GAME.ready_blind and 0 or -(MP.speedlatro_timer.real or 0) + local timer_value = MP.GAME.ready_blind and 0 or -(MP.speedlatro_timer.real or 0) local timer = (timer_value / speedup) % self.cycle local start_index = math.ceil(timer * #self.colours / self.cycle) if start_index == 0 then start_index = 1 end @@ -248,6 +332,23 @@ SMODS.Gradient({ }) function G.FUNCS.set_timer_box(e) + if e.states and e.parent and e.parent.states then + local is_hovered = e.states.hover.is or e.parent.states.hover.is + if is_hovered and not e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info = UIBox({ + definition = MP.UI.nemesis_timer_hud(), + config = { + parent = e.parent, + major = e.parent, + align = "tm", + offset = { x = 0, y = -0.1 }, + }, + }) + elseif not is_hovered and e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info:remove() + e.parent.children.mp_timer_info = nil + end + end if MP.LOBBY.config.timer then if MP.GAME.timer_started or MP.GAME.nemesis_timer_started then e.config.colour = G.C.DYN_UI.BOSS_DARK @@ -257,17 +358,17 @@ function G.FUNCS.set_timer_box(e) } return end - if not MP.GAME.timer_started and MP.UI.cam_timer_opponent() then + if not MP.GAME.timer_started and MP.UI.can_timer_opponent() then e.config.colour = G.C.IMPORTANT e.children[1].config.object.colours = { G.C.UI.TEXT_LIGHT } return end e.config.colour = G.C.DYN_UI.BOSS_DARK - e.children[1].config.object.colours = - { - (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) - and G.C.IMPORTANT or G.C.UI.TEXT_DARK - } + e.children[1].config.object.colours = { + (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) + and G.C.IMPORTANT + or G.C.UI.TEXT_DARK, + } end end @@ -304,10 +405,11 @@ function Game:update(dt) math.min(animation_budget_capacity, MP.TIMER_ANIMATION_BUDGET + timer_dt * animation_budget_restore_rate) -- Bail fast: not an MP PvP-timer context - if G.STATE == G.STATES.GAME_OVER then return end + if G.STATE == G.STATES.GAME_OVER or MP.GAME.won then return end if not MP.LOBBY.code then return end if not MP.LOBBY.config.timer then return end if MP.GAME.timer_consumed then return end + if MP.GAME.pvp_countdown_in_progress then return end if not MP.GAME.timer or MP.GAME.timer <= 0 then return end if MP.is_layer_active("speedlatro_timer") then return end @@ -324,25 +426,26 @@ function Game:update(dt) local should_check_animations = false if is_pvp_timer then - -- PvP timer: tick when opponent timering, stop when animations, state checks, pvp blind only + -- PvP timer: tick when opponent timering, stop when animations, state checks, pvp blind only if not MP.GAME.nemesis_timer_started then return end - if G.GAME.current_round.hands_left <= 0 then return end + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then return end + if G.GAME.current_round.hands_left <= 0 then return end if G.STATE == G.STATES.NEW_ROUND or G.STATE == G.STATES.ROUND_EVAL then return end should_check_animations = true elseif is_pressure_timer then - -- Pressure timer: tick from the start of a game, stop when reached pvp (unless timered) or animations, not in pvp blind + -- Pressure timer: tick from the start of a game, stop when reached pvp (unless timered) or animations, not in pvp blind if MP.GAME.pvp_reached and not MP.GAME.nemesis_timer_started then return end if MP.GAME.ready_blind or is_pvp_boss then return end should_check_animations = true elseif is_no_animation_timer then - -- No-animation timer: tick when opponen timering, stop when animations, not in pvp + -- No-animation timer: tick when opponen timering, stop when animations, not in pvp if not MP.GAME.nemesis_timer_started then return end if MP.GAME.ready_blind or is_pvp_boss then return end should_check_animations = true else - -- Old timer: tick when opponent timering, not in pvp - if is_pvp_boss then return end - if MP.GAME.pvp_reached and MP.GAME.ready_blind and not MP.GAME.timer_started then return end + -- Old timer: tick when opponent timering, not in pvp + if is_pvp_boss then return end + if MP.GAME.pvp_reached and MP.GAME.ready_blind and not MP.GAME.timer_started then return end if not (MP.GAME.timer_started or MP.GAME.nemesis_timer_started) then return end end @@ -369,20 +472,18 @@ function Game:update(dt) if MP.GAME.timer == 0 then MP.GAME.timer_consumed = true - -- PvP timer: end PvP immediately as a loss (only when timered by opponent) - if is_pvp_timer then - if MP.GAME.nemesis_timer_started then - MP.ACTIONS.fail_pvp_timer() - end - -- Old, No-animations: lose a live when timered by opponent - -- Pressure timers: lose a live regardless - elseif (is_pressure_timer or MP.GAME.nemesis_timer_started) then - if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then - MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 - else - MP.ACTIONS.fail_timer() - end - end + -- PvP timer: end PvP immediately as a loss (only when timered by opponent) + if is_pvp_timer then + if MP.GAME.nemesis_timer_started then MP.ACTIONS.fail_pvp_timer() end + -- Old, No-animations: lose a live when timered by opponent + -- Pressure timers: lose a live regardless + elseif is_pressure_timer or MP.GAME.nemesis_timer_started then + if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then + MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 + else + MP.ACTIONS.fail_timer() + end + end end end @@ -408,26 +509,28 @@ end local old_play = G.FUNCS.play_cards_from_highlighted function G.FUNCS.play_cards_from_highlighted(...) - -- Carbon: capture which hand slots are played BEFORE old_play consumes them. - local played = MP.UTILS.highlighted_hand_indices() - old_play(...) - if #played > 0 then - MP.RLOG.record("play", { played }, "action:play,cards:" .. table.concat(played, ".")) - end - if G.play and G.play.cards[1] then return end - if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then - if MP.is_pvp_boss() then - -- PvP timer: Increment timer when hand is played during pvp - if MP.is_layer_active("pvp_timer") then - local increment = MP.LOBBY.config.pvp_timer_hand_played_increment_seconds or MP.current_ruleset().pvp_timer_hand_played_increment_seconds or 0 - MP.UI.restore_timer(increment) - end - else - -- No-animation, Pressure timers: Increment timer when hand is played during regular blinds - if MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) then - local increment = MP.LOBBY.config.timer_hand_played_increment_seconds or MP.current_ruleset().timer_hand_played_increment_seconds or 0 - MP.UI.restore_timer(increment) - end - end - end -end \ No newline at end of file + -- Carbon: capture which hand slots are played BEFORE old_play consumes them. + local played = MP.UTILS.highlighted_hand_indices() + old_play(...) + if #played > 0 then MP.RLOG.record("play", { played }, "action:play,cards:" .. table.concat(played, ".")) end + if G.play and G.play.cards[1] then return end + if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then + if MP.is_pvp_boss() then + -- PvP timer: Increment timer when hand is played during pvp + if MP.is_layer_active("pvp_timer") then + local increment = MP.LOBBY.config.pvp_timer_hand_played_increment_seconds + or MP.current_ruleset().pvp_timer_hand_played_increment_seconds + or 0 + MP.UI.restore_timer(increment) + end + else + -- No-animation, Pressure timers: Increment timer when hand is played during regular blinds + if MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) then + local increment = MP.LOBBY.config.timer_hand_played_increment_seconds + or MP.current_ruleset().timer_hand_played_increment_seconds + or 0 + MP.UI.restore_timer(increment) + end + end + end +end diff --git a/ui/lobby/lobby.lua b/ui/lobby/lobby.lua index ab53011b2..094c8e1d5 100644 --- a/ui/lobby/lobby.lua +++ b/ui/lobby/lobby.lua @@ -548,6 +548,9 @@ function set_main_menu_UI() end end +local data_sync_interval = 5 +local data_sync_delay = data_sync_interval + local in_lobby = false local gameUpdateRef = Game.update ---@diagnostic disable-next-line: duplicate-set-field @@ -561,6 +564,14 @@ function Game:update(dt) MP.reset_game_states() end end + -- Data sync every 5 seconds + if MP.LOBBY.code and G.STAGE == G.STAGES.RUN then + data_sync_delay = data_sync_delay - dt + if data_sync_delay < 0 then + data_sync_delay = data_sync_interval + MP.ACTIONS.data_sync() + end + end gameUpdateRef(self, dt) end diff --git a/ui/smods_menu/config_tab.lua b/ui/smods_menu/config_tab.lua index 578cc0172..4562df0e1 100644 --- a/ui/smods_menu/config_tab.lua +++ b/ui/smods_menu/config_tab.lua @@ -9,6 +9,24 @@ function MP.UI.create_config_tab() colour = G.C.BLACK, }, nodes = { + { + n = G.UIT.R, + config = { + padding = 0, + align = "cm", + on_demand_tooltip = { + text = localize("k_automatic_pvp_timer_description"), + }, + }, + nodes = { + create_toggle({ + id = "multiplayer_automatic_pvp_timer", + label = localize("k_automatic_pvp_timer"), + ref_table = SMODS.Mods["Multiplayer"].config, + ref_value = "automatic_pvp_timer", + }), + }, + }, { n = G.UIT.R, config = { @@ -107,6 +125,36 @@ function MP.UI.create_config_tab() }, }, }, + { + n = G.UIT.R, + config = { + padding = 0.1, + align = "cm", + }, + nodes = { + UIBox_button({ + button = "mp_open_log_parser", + label = { localize("b_open_log_parser") }, + minw = 3, + maxw = 3, + minh = 0.8, + maxh = 0.8, + col = true, + colour = G.C.CHIPS, + }), + UIBox_button({ + id = "from_game_won", + button = "mp_get_lovely_log_file", + label = { localize("b_get_log_file") }, + minw = 3, + maxw = 3, + minh = 0.8, + maxh = 0.8, + col = true, + colour = G.C.CHIPS, + }), + }, + }, }, } return ret