diff --git a/.gitignore b/.gitignore index ae5a6e0e..688cd47a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .vs .DS_Store .env +replays/* +!replays/.gitkeep diff --git a/config.lua b/config.lua index 3a036cae..50922d1d 100644 --- a/config.lua +++ b/config.lua @@ -13,4 +13,5 @@ return { ["preview"] = {}, ["joker_stats"] = {}, ["match_history"] = {}, + ["ghost_replays"] = {}, } diff --git a/core.lua b/core.lua index 8391e4a9..665890e8 100644 --- a/core.lua +++ b/core.lua @@ -97,7 +97,12 @@ MP.SMODS_VERSION = "1.0.0~BETA-1503a" MP.REQUIRED_LOVELY_VERSION = "0.9" function MP.should_use_the_order() - return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code + if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code then + return true + elseif MP.is_practice_mode() then -- should actually check the ruleset but okay for now + return true + end + return false end function MP.is_major_league_ruleset() diff --git a/lib/log_parser.lua b/lib/log_parser.lua new file mode 100644 index 00000000..06be8bc2 --- /dev/null +++ b/lib/log_parser.lua @@ -0,0 +1,436 @@ +-- Log Parser: parse Lovely log files into ghost replay tables. +-- Lua port of tools/log_to_ghost_replay.py + +local M = {} + +------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------- + +local function parse_timestamp(line) + return line:match("(%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d)") +end + +local function parse_sent_json(line) + local raw = line:match("Client sent message: ({.*})%s*$") + if not raw then return nil end + local json = require("json") + local ok, obj = pcall(json.decode, raw) + if ok and type(obj) == "table" then return obj end + return nil +end + +local function parse_sent_kv(line) + local raw = line:match("Client sent message: (action:%w+.-)%s*$") + if not raw then return nil end + local pairs_t = {} + for part in raw:gmatch("[^,]+") do + local k, v = part:match("^%s*(.-):%s*(.-)%s*$") + if k then pairs_t[k] = v end + end + return pairs_t +end + +local function parse_got_kv(line) + local action, kv_str = line:match("Client got (%w+) message:%s*(.-)%s*$") + if not action then return nil end + local pairs_t = {} + for key, val in kv_str:gmatch("%((%w+):%s*([^)]*)%)") do + val = val:match("^%s*(.-)%s*$") + if val == "true" then + pairs_t[key] = true + elseif val == "false" then + pairs_t[key] = false + elseif val:match("^%-?%d+%.%d+$") then + pairs_t[key] = tonumber(val) + elseif val:match("^%-?%d+$") then + pairs_t[key] = tonumber(val) + else + pairs_t[key] = val + end + end + return action, pairs_t +end + +local function parse_joker_list_full(raw) + local jokers = {} + for entry in raw:gmatch("[^;]+") do + entry = entry:match("^%s*(.-)%s*$") + if entry ~= "" then + local parts = {} + for p in entry:gmatch("[^%-]+") do parts[#parts + 1] = p end + local joker = { key = parts[1] } + if parts[2] and parts[2] ~= "none" then joker.edition = parts[2] end + if parts[3] and parts[3] ~= "none" then joker.sticker1 = parts[3] end + if parts[4] and parts[4] ~= "none" then joker.sticker2 = parts[4] end + jokers[#jokers + 1] = joker + end + end + return jokers +end + +------------------------------------------------------------------------------- +-- Game record (fresh state) +------------------------------------------------------------------------------- + +local function new_game() + return { + seed = nil, + ruleset = nil, + gamemode = nil, + deck = nil, + stake = nil, + player_name = nil, + nemesis_name = nil, + starting_lives = 4, + is_host = nil, + lobby_code = nil, + ante_snapshots = {}, + winner = nil, + final_ante = 1, + current_ante = 0, + player_lives = 4, + enemy_lives = 4, + -- PvP round tracking (transient) + pvp_player_score = "0", + pvp_enemy_score = "0", + pvp_hands = {}, + in_pvp = false, + -- End-game data + player_jokers = {}, + nemesis_jokers = {}, + player_stats = {}, + nemesis_stats = {}, + -- Per-ante shop spending + shop_spending = {}, + -- Non-PvP round failures + failed_rounds = {}, + -- Timing + game_start_ts = nil, + game_end_ts = nil, + -- Card activity + cards_bought = {}, + cards_sold = {}, + cards_used = {}, + } +end + +------------------------------------------------------------------------------- +-- Duration helper +------------------------------------------------------------------------------- + +local function ts_to_epoch(ts_str) + local y, mo, d, h, mi, s = ts_str:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)") + if not y then return nil end + return os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d), + hour = tonumber(h), min = tonumber(mi), sec = tonumber(s) }) +end + +local function format_duration(start_ts, end_ts) + local t0 = ts_to_epoch(start_ts) + local t1 = ts_to_epoch(end_ts) + if not t0 or not t1 then return nil end + local secs = t1 - t0 + if secs < 0 then return nil end + local mins = math.floor(secs / 60) + local s = secs % 60 + return string.format("%dm%02ds", mins, s) +end + +------------------------------------------------------------------------------- +-- Core parser +------------------------------------------------------------------------------- + +function M.process_log(content) + local games = {} + local game = new_game() + local last_lobby_options = nil + local in_game = false + + for line in content:gmatch("[^\r\n]+") do + if line:find("MULTIPLAYER", 1, true) then + local ts = parse_timestamp(line) + + -- Direct log messages (not Client sent/got) + if line:find("Sending end game jokers:", 1, true) then + local raw = line:match("Sending end game jokers:%s*(.-)%s*$") + if raw then game.player_jokers = parse_joker_list_full(raw) end + goto continue + end + + if line:find("Received end game jokers:", 1, true) then + local raw = line:match("Received end game jokers:%s*(.-)%s*$") + if raw then game.nemesis_jokers = parse_joker_list_full(raw) end + goto continue + end + + -- Sent messages (JSON) + local sent = parse_sent_json(line) + if sent then + local action = sent.action + + if action == "username" then + game.player_name = sent.username + + elseif action == "lobbyOptions" then + last_lobby_options = sent + + elseif action == "setAnte" then + local ante = sent.ante or 0 + game.current_ante = ante + if ante > game.final_ante then game.final_ante = ante end + + elseif action == "playHand" then + local score = tostring(sent.score or "0") + local hands_left = sent.handsLeft or 0 + if game.in_pvp then + game.pvp_player_score = score + game.pvp_hands[#game.pvp_hands + 1] = { + score = score, + hands_left = hands_left, + side = "player", + } + end + + elseif action == "setLocation" then + local loc = sent.location or "" + if loc:find("bl_mp_nemesis", 1, true) then + game.in_pvp = true + end + + elseif action == "failRound" then + game.failed_rounds[#game.failed_rounds + 1] = game.current_ante + + elseif action == "spentLastShop" then + local amount = sent.amount or 0 + game.shop_spending[game.current_ante] = + (game.shop_spending[game.current_ante] or 0) + amount + + elseif action == "nemesisEndGameStats" then + local stats = {} + for k, v in pairs(sent) do + if k ~= "action" then stats[k] = v end + end + game.player_stats = stats + + elseif action == "startGame" then + if ts then game.game_start_ts = game.game_start_ts or ts end + end + + goto continue + end + + -- Sent messages (key:value format — card activity) + local sent_kv = parse_sent_kv(line) + if sent_kv then + local action = sent_kv.action + if action == "boughtCardFromShop" then + game.cards_bought[#game.cards_bought + 1] = { + card = sent_kv.card or "", + cost = tonumber(sent_kv.cost) or 0, + ante = game.current_ante, + } + elseif action == "soldCard" then + game.cards_sold[#game.cards_sold + 1] = { + card = sent_kv.card or "", + ante = game.current_ante, + } + elseif action == "usedCard" then + game.cards_used[#game.cards_used + 1] = { + card = sent_kv.card or "", + ante = game.current_ante, + } + end + goto continue + end + + -- Received messages (key-value) + local action, kv = parse_got_kv(line) + if not action then goto continue end + + if action == "joinedLobby" then + if kv.code then game.lobby_code = tostring(kv.code) end + + elseif action == "lobbyInfo" then + if kv.isHost ~= nil then game.is_host = kv.isHost end + if game.is_host == true and kv.guest then + game.nemesis_name = tostring(kv.guest) + elseif game.is_host == false and kv.host then + game.nemesis_name = tostring(kv.host) + end + + elseif action == "startGame" then + in_game = true + if ts then game.game_start_ts = game.game_start_ts or ts end + if last_lobby_options then + game.ruleset = last_lobby_options.ruleset + game.gamemode = last_lobby_options.gamemode + game.deck = last_lobby_options.back or "Red Deck" + game.stake = last_lobby_options.stake or 1 + game.starting_lives = last_lobby_options.starting_lives or 4 + game.player_lives = game.starting_lives + game.enemy_lives = game.starting_lives + end + + elseif action == "playerInfo" then + if kv.lives then game.player_lives = kv.lives end + + elseif action == "enemyInfo" then + if kv.lives then game.enemy_lives = kv.lives end + if kv.score then + local score_str = tostring(kv.score) + if game.in_pvp then + game.pvp_enemy_score = score_str + game.pvp_hands[#game.pvp_hands + 1] = { + score = score_str, + hands_left = kv.handsLeft or 0, + side = "enemy", + } + end + end + + elseif action == "enemyLocation" then + local loc = kv.location or "" + if tostring(loc):find("bl_mp_nemesis", 1, true) then + game.in_pvp = true + end + + elseif action == "endPvP" then + local lost = kv.lost + local result = lost and "loss" or "win" + + -- Clean up hand progression + local cleaned = {} + local seen_final = {} + for _, h in ipairs(game.pvp_hands) do + if h.score == "0" and h.hands_left >= 4 then + -- skip initial zero-score entries + elseif #cleaned > 0 + and cleaned[#cleaned].score == h.score + and cleaned[#cleaned].side == h.side then + -- deduplicate consecutive same-score same-side + elseif seen_final[h.side] and h.score == seen_final[h.side] then + -- skip re-broadcast of final score + else + if h.hands_left == 0 then + seen_final[h.side] = h.score + end + cleaned[#cleaned + 1] = h + end + end + + game.ante_snapshots[game.current_ante] = { + ante = game.current_ante, + player_score = game.pvp_player_score, + enemy_score = game.pvp_enemy_score, + player_lives = game.player_lives, + enemy_lives = game.enemy_lives, + result = result, + hands = cleaned, + } + + -- Reset PvP tracking + game.in_pvp = false + game.pvp_player_score = "0" + game.pvp_enemy_score = "0" + game.pvp_hands = {} + + elseif action == "winGame" then + game.winner = "player" + + elseif action == "loseGame" then + game.winner = "nemesis" + + elseif action == "nemesisEndGameStats" then + local stats = {} + for k, v in pairs(kv) do + if k ~= "action" then stats[k] = v end + end + game.nemesis_stats = stats + + elseif action == "stopGame" then + if kv.seed then game.seed = tostring(kv.seed) end + if ts then game.game_end_ts = ts end + if in_game then + games[#games + 1] = game + end + in_game = false + local prev_name = game.player_name + game = new_game() + game.player_name = prev_name + last_lobby_options = nil + end + + ::continue:: + end + end + + -- Capture mid-game record if log ends without stopGame + if in_game and game.winner then + games[#games + 1] = game + end + + return games +end + +------------------------------------------------------------------------------- +-- Convert a parsed game record to a replay table (same shape as JSON replays) +------------------------------------------------------------------------------- + +function M.to_replay(game) + local snapshots = {} + for ante, snap in pairs(game.ante_snapshots) do + local snap_t = { + player_score = snap.player_score, + enemy_score = snap.enemy_score, + player_lives = snap.player_lives, + enemy_lives = snap.enemy_lives, + result = snap.result, + } + if snap.hands and #snap.hands > 0 then + snap_t.hands = {} + for _, h in ipairs(snap.hands) do + snap_t.hands[#snap_t.hands + 1] = { + score = h.score, + hands_left = h.hands_left, + side = h.side, + } + end + end + snapshots[ante] = snap_t + end + + local replay = { + gamemode = game.gamemode or "gamemode_mp_attrition", + final_ante = game.final_ante, + ante_snapshots = snapshots, + winner = game.winner or "unknown", + timestamp = os.time(), + ruleset = game.ruleset or "ruleset_mp_blitz", + seed = game.seed or "UNKNOWN", + deck = game.deck or "Red Deck", + stake = game.stake or 1, + } + if game.player_name then replay.player_name = game.player_name end + if game.nemesis_name then replay.nemesis_name = game.nemesis_name end + if game.lobby_code then replay.lobby_code = game.lobby_code end + + if game.game_start_ts and game.game_end_ts then + local dur = format_duration(game.game_start_ts, game.game_end_ts) + if dur then replay.duration = dur end + end + + if #game.player_jokers > 0 then replay.player_jokers = game.player_jokers end + if #game.nemesis_jokers > 0 then replay.nemesis_jokers = game.nemesis_jokers end + if next(game.player_stats) then replay.player_stats = game.player_stats end + if next(game.nemesis_stats) then replay.nemesis_stats = game.nemesis_stats end + if next(game.shop_spending) then replay.shop_spending = game.shop_spending end + if #game.failed_rounds > 0 then replay.failed_rounds = game.failed_rounds end + if #game.cards_bought > 0 then replay.cards_bought = game.cards_bought end + if #game.cards_sold > 0 then replay.cards_sold = game.cards_sold end + if #game.cards_used > 0 then replay.cards_used = game.cards_used end + + return replay +end + +return M diff --git a/lib/match_history.lua b/lib/match_history.lua new file mode 100644 index 00000000..6239464f --- /dev/null +++ b/lib/match_history.lua @@ -0,0 +1,274 @@ +-- Ghost Replay data capture +-- +-- Records every match you play: seed, ruleset, gamemode, deck, stake, and +-- per-ante snapshots of both players' scores and lives. Persisted to config +-- so the data survives between sessions (last 20 matches kept). +-- +-- What this enables: +-- - Replay any seed you already played in practice mode, +-- with your opponent's scores as ghost data +-- - Practice mode can load a ghost replay and use the recorded enemy scores +-- as the PvP blind target, so you're "playing against" a past opponent +-- - Match history / post-game review: see how scores diverged ante by ante + +MP.MATCH_RECORD = { + seed = nil, + ruleset = nil, + gamemode = nil, + deck = nil, + player_name = nil, + nemesis_name = nil, + ante_snapshots = {}, + winner = nil, + final_ante = nil, +} + +function MP.MATCH_RECORD.reset() + MP.MATCH_RECORD.seed = nil + MP.MATCH_RECORD.ruleset = nil + MP.MATCH_RECORD.gamemode = nil + MP.MATCH_RECORD.deck = nil + MP.MATCH_RECORD.player_name = nil + MP.MATCH_RECORD.nemesis_name = nil + MP.MATCH_RECORD.ante_snapshots = {} + MP.MATCH_RECORD.winner = nil + MP.MATCH_RECORD.final_ante = nil +end + +function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck, stake, player_name, nemesis_name) + MP.MATCH_RECORD.reset() + MP.MATCH_RECORD.seed = seed + MP.MATCH_RECORD.ruleset = ruleset + MP.MATCH_RECORD.gamemode = gamemode + MP.MATCH_RECORD.deck = deck + MP.MATCH_RECORD.stake = stake + MP.MATCH_RECORD.player_name = player_name + MP.MATCH_RECORD.nemesis_name = nemesis_name +end + +function MP.MATCH_RECORD.snapshot_ante(ante, data) + MP.MATCH_RECORD.ante_snapshots[ante] = { + player_score = data.player_score, + enemy_score = data.enemy_score, + player_lives = data.player_lives, + enemy_lives = data.enemy_lives, + blind_key = data.blind_key, + result = data.result, + } +end + +function MP.MATCH_RECORD.finalize(won) + -- Don't save ghost practice games as new replays + if MP.is_practice_mode() then return end + + MP.MATCH_RECORD.winner = won and "player" or "nemesis" + MP.MATCH_RECORD.final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1 +end + +MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil } + +-- Per-ante playback state +MP.GHOST._hands = {} +MP.GHOST._hand_idx = 0 +MP.GHOST._advancing = false + +function MP.GHOST.load(replay) + MP.GHOST.active = true + MP.GHOST.replay = replay + MP.GHOST.flipped = false + MP.GHOST.gamemode = replay and replay.gamemode or nil + MP.GHOST._hands = {} + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false +end + +function MP.GHOST.clear() + MP.GHOST.active = false + MP.GHOST.replay = nil + MP.GHOST.flipped = false + MP.GHOST.gamemode = nil + MP.GHOST._hands = {} + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false +end + +function MP.GHOST.flip() + MP.GHOST.flipped = not MP.GHOST.flipped +end + +function MP.GHOST.get_enemy_hands(ante) + if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return {} end + local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)] + if not snapshot or not snapshot.hands then return {} end + local enemy_side = MP.GHOST.flipped and "player" or "enemy" + local out = {} + for _, h in ipairs(snapshot.hands) do + if h.side == enemy_side then + out[#out + 1] = h + end + end + return out +end + +-- Fallback for replays without hand-level data +function MP.GHOST.get_enemy_score(ante) + if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return nil end + local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)] + if not snapshot then return nil end + local key = MP.GHOST.flipped and "player_score" or "enemy_score" + return snapshot[key] +end + +function MP.GHOST.init_playback(ante) + local hands = MP.GHOST.get_enemy_hands(ante) + MP.GHOST._hands = hands + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false + if #hands > 0 then + MP.GHOST._hand_idx = 1 + local score = MP.INSANE_INT.from_string(hands[1].score) + MP.GAME.enemy.score = score + MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score) + MP.GAME.enemy.hands = hands[1].hands_left or 0 + return true + else + local score_str = MP.GHOST.get_enemy_score(ante) + if score_str then + MP.GAME.enemy.score = MP.INSANE_INT.from_string(score_str) + MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score) + end + return false + end +end + +function MP.GHOST.advance_hand() + if MP.GHOST._hand_idx >= #MP.GHOST._hands then return false end + MP.GHOST._hand_idx = MP.GHOST._hand_idx + 1 + local entry = MP.GHOST._hands[MP.GHOST._hand_idx] + local score = MP.INSANE_INT.from_string(entry.score) + + G.E_MANAGER:add_event(Event({ + blockable = false, blocking = false, + trigger = "ease", delay = 0.5, + 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.5, + ref_table = MP.GAME.enemy.score, + ref_value = "coeffiocient", + ease_to = score.coeffiocient, + func = function(t) return math.floor(t) end, + })) + G.E_MANAGER:add_event(Event({ + blockable = false, blocking = false, + trigger = "ease", delay = 0.5, + ref_table = MP.GAME.enemy.score, + ref_value = "exponent", + ease_to = score.exponent, + func = function(t) return math.floor(t) end, + })) + + MP.GAME.enemy.hands = entry.hands_left or 0 + if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end + return true +end + +function MP.GHOST.playback_exhausted() + return #MP.GHOST._hands == 0 or MP.GHOST._hand_idx >= #MP.GHOST._hands +end + +function MP.GHOST.has_hand_data() + return #MP.GHOST._hands > 0 +end + +-- Reads target from hands array directly, bypassing the eased score table. +function MP.GHOST.current_target_big() + if MP.GHOST._hand_idx < 1 or MP.GHOST._hand_idx > #MP.GHOST._hands then return to_big(0) end + local entry = MP.GHOST._hands[MP.GHOST._hand_idx] + local score = MP.INSANE_INT.from_string(entry.score) + return to_big(score.coeffiocient * (10 ^ score.exponent)) +end + +function MP.GHOST.get_nemesis_name() + if not MP.GHOST.replay then return nil end + if MP.GHOST.flipped then + return MP.GHOST.replay.player_name or localize("k_ghost") + else + return MP.GHOST.replay.nemesis_name or localize("k_ghost") + end +end + +function MP.GHOST.is_active() + return MP.GHOST.active and MP.GHOST.replay ~= nil +end + +-- Load ghost replays from JSON files in the replays/ folder. +-- Files are read once when the picker is opened; drop a .json file +-- generated by tools/log_to_ghost_replay.py into replays/ and it +-- shows up alongside config-stored replays. + +function MP.GHOST.load_folder_replays() + local json = require("json") + local log_parser = MP.load_mp_file("lib/log_parser.lua") + local replays_dir = MP.path .. "/replays" + local dir_info = NFS.getInfo(replays_dir) + if not dir_info or dir_info.type ~= "directory" then return {} end + + local items = NFS.getDirectoryItemsInfo(replays_dir) + local results = {} + + for _, item in ipairs(items) do + if item.type == "file" and item.name:match("%.json$") then + local filepath = replays_dir .. "/" .. item.name + local content = NFS.read(filepath) + if content then + local ok, replay = pcall(json.decode, content) + if ok and replay and replay.ante_snapshots then + -- Convert string ante keys to numbers for consistency + local fixed = {} + for k, v in pairs(replay.ante_snapshots) do + fixed[tonumber(k) or k] = v + end + replay.ante_snapshots = fixed + replay._source = "file" + replay._filename = item.name + table.insert(results, replay) + else + sendWarnMessage("Failed to parse replay: " .. item.name, "MULTIPLAYER") + end + end + elseif item.type == "file" and item.name:match("%.log$") then + local filepath = replays_dir .. "/" .. item.name + local content = NFS.read(filepath) + if content and log_parser then + local ok, game_records = pcall(log_parser.process_log, content) + if ok and game_records then + local total = #game_records + for idx, game in ipairs(game_records) do + local ok2, replay = pcall(log_parser.to_replay, game) + if ok2 and replay and replay.ante_snapshots and next(replay.ante_snapshots) then + replay._source = "file" + replay._filename = item.name + replay._game_index = idx + replay._game_count = total + table.insert(results, replay) + end + end + else + sendWarnMessage("Failed to parse log: " .. item.name, "MULTIPLAYER") + end + end + end + end + + -- Sort by timestamp descending (newest first) + table.sort(results, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + return results +end diff --git a/localization/en-us.lua b/localization/en-us.lua index 8df7f5fc..a7af9656 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -901,6 +901,9 @@ return { dictionary = { b_singleplayer = "Singleplayer", b_sp_with_ruleset = "Practice Mode", + b_practice = "Practice", + k_unlimited_slots = "Unlimited Slots", + k_edition_cycling = "Edition Cycling (Q)", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", @@ -1095,6 +1098,9 @@ return { "Enemy", "location", }, + k_ghost_replays = "Match Replays", + k_no_ghost_replays = "No replays yet", + k_ghost = "Ghost", k_hide_mp_content = "Hide Multiplayer content*", k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets", k_timer_sfx = "Timer Sound Effects", diff --git a/lovely/end_round.toml b/lovely/end_round.toml index ae13e938..d4625c08 100644 --- a/lovely/end_round.toml +++ b/lovely/end_round.toml @@ -28,7 +28,7 @@ target = "functions/state_events.lua" pattern = '''-- context.end_of_round calculations''' position = 'before' payload = ''' -if MP.LOBBY.code then +if MP.LOBBY.code or MP.GHOST.is_active() then game_over = false end ''' @@ -42,7 +42,7 @@ target = "functions/state_events.lua" pattern = '''if game_over then''' position = 'before' payload = ''' -if MP.LOBBY.code then +if MP.LOBBY.code or MP.GHOST.is_active() then game_won = nil G.GAME.won = nil end @@ -149,7 +149,7 @@ function ease_ante(mod) ''' position = 'after' payload = ''' -if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then +if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud then MP.suppress_next_event = true end ''' diff --git a/lovely/hud.toml b/lovely/hud.toml index 22fbe3f5..3c65f852 100644 --- a/lovely/hud.toml +++ b/lovely/hud.toml @@ -37,3 +37,20 @@ pattern = '''\{n=G\.UIT\.C, config=\{align = "cm", padding = 0\.05, minw = 1\.45 position = 'at' payload = '''MP.LOBBY.code and (not MP.LOBBY.config.disable_live_and_timer_hud) and MP.UI.timer_hud() or {n=G.UIT.C, config={align = "cm", padding = 0.05, minw = 1.45, minh = 1,''' times = 1 + +# Practice mode: add collection button to HUD (after lobby info button injection) +[[patches]] +[patches.pattern] +target = "functions/UI_definitions.lua" +pattern = '''{n=G.UIT.T, config={text = localize('b_options'), scale = scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}} + }}, + }}''' +position = 'after' +payload = ''', +(MP and MP.is_practice_mode and MP.is_practice_mode()) and {n=G.UIT.R, config={id = 'practice_collection_button', align = "cm", minh = 1.75, minw = 1.5, padding = 0.05, r = 0.1, hover = true, colour = G.C.SECONDARY_SET.Planet, button = "your_collection", shadow = true}, nodes={ + {n=G.UIT.R, config={align = "cm", padding = 0, maxw = 1.4}, nodes={ + {n=G.UIT.T, config={text = localize("k_collection"), scale = 0.9*scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}} + }} +}} or nil''' +match_indent = true +times = 1 diff --git a/lovely/practice.toml b/lovely/practice.toml new file mode 100644 index 00000000..06180042 --- /dev/null +++ b/lovely/practice.toml @@ -0,0 +1,87 @@ +[manifest] +version = "1.0.0" +dump_lua = true +priority = 2147483600 + +# Practice mode: give cards from collection via key "3" or left-click. +# Also: edition cycling. + +# Shared helper function for giving a hovered card to the player +[[patches]] +[patches.pattern] +target = "engine/controller.lua" +pattern = '''function Controller:key_press_update(key, dt)''' +position = 'before' +payload = ''' +function Controller:practice_give_card() + if not (MP and MP.is_practice_mode()) then return end + if MP.LOBBY.code then return end + if not (self.hovering.target and self.hovering.target:is(Card)) then return end + if not G.OVERLAY_MENU then return end + local _card = self.hovering.target + if _card.ability.set == 'Joker' and G.jokers then + if MP.SP.unlimited_slots or #G.jokers.cards < G.jokers.config.card_limit then + if MP.SP.unlimited_slots and #G.jokers.cards >= G.jokers.config.card_limit then + G.jokers.config.card_limit = G.jokers.config.card_limit + 1 + end + add_joker(_card.config.center.key) + _card:set_sprites(_card.config.center) + end + end + if _card.ability.consumeable and G.consumeables then + if MP.SP.unlimited_slots or #G.consumeables.cards < G.consumeables.config.card_limit then + if MP.SP.unlimited_slots and #G.consumeables.cards >= G.consumeables.config.card_limit then + G.consumeables.config.card_limit = G.consumeables.config.card_limit + 1 + end + add_joker(_card.config.center.key) + _card:set_sprites(_card.config.center) + end + end +end +''' +match_indent = true +times = 1 + +# Key "3" triggers give in practice mode +[[patches]] +[patches.pattern] +target = "engine/controller.lua" +pattern = '''if not _RELEASE_MODE then''' +position = 'before' +payload = ''' +if MP and MP.is_practice_mode() and not MP.LOBBY.code then + if key == "3" then + self:practice_give_card() + end + if key == "q" and MP.SP.edition_cycling then + if self.hovering.target and self.hovering.target:is(Card) then + local _card = self.hovering.target + if _card.ability.set == 'Joker' or _card.playing_card or _card.area then + local _edition = { + foil = not _card.edition, + holo = _card.edition and _card.edition.foil, + polychrome = _card.edition and _card.edition.holo, + negative = _card.edition and _card.edition.polychrome, + } + _card:set_edition(_edition, true, true) + end + end + end +end +''' +match_indent = true +times = 1 + +# Left-click on hovered card in collection triggers give in practice mode +[[patches]] +[patches.pattern] +target = "main.lua" +pattern = '''G.CONTROLLER:queue_L_cursor_press(x, y)''' +position = 'before' +payload = ''' + if MP and MP.is_practice_mode() then + G.CONTROLLER:practice_give_card() + end +''' +match_indent = true +times = 1 diff --git a/networking-old/action_handlers.lua b/networking-old/action_handlers.lua index 8961b54a..8f6f035f 100644 --- a/networking-old/action_handlers.lua +++ b/networking-old/action_handlers.lua @@ -114,6 +114,10 @@ end ---@param seed string ---@param stake_str string local function action_start_game(seed, stake_str) + -- Clear any stale practice/ghost state so it can't leak into real MP + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 3f52ad00..6625311f 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -228,6 +228,10 @@ end ---@param seed string ---@param stake_str string local function action_start_game(seed, stake_str) + -- Clear any stale practice/ghost state so it can't leak into real MP + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) @@ -236,6 +240,11 @@ local function action_start_game(seed, stake_str) end G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false + + local player_name = MP.UI.get_username() + local nemesis = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host + local nemesis_name = nemesis and nemesis.username or "Unknown" + MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back, stake_str, player_name, nemesis_name) end local function begin_pvp_blind() @@ -340,6 +349,21 @@ local function action_end_pvp() MP.GAME.timer = MP.LOBBY.config.timer_base_seconds MP.GAME.timer_started = false MP.GAME.ready_blind = false + + -- needs a cleaner place than the networking layer but whatever + local ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1 + MP.MATCH_RECORD.snapshot_ante(ante, { + player_score = MP.GAME.highest_score and tostring(MP.GAME.highest_score) or "0", + enemy_score = MP.GAME.enemy.score_text or "0", + player_lives = MP.GAME.lives, + enemy_lives = MP.GAME.enemy.lives, + blind_key = G.GAME.blind + and G.GAME.blind.config + and G.GAME.blind.config.blind + and G.GAME.blind.config.blind.key + or nil, + result = nil, + }) end ---@param lives number @@ -366,6 +390,7 @@ local function action_win_game() MP.nemesis_deck_received = false MP.GAME.won = true MP.STATS.record_match(true) + MP.MATCH_RECORD.finalize(true) win_game() end @@ -375,6 +400,7 @@ local function action_lose_game() MP.end_game_jokers_received = false MP.nemesis_deck_received = false MP.STATS.record_match(false) + MP.MATCH_RECORD.finalize(false) G.STATE_COMPLETE = false G.STATE = G.STATES.GAME_OVER end diff --git a/replays/.gitkeep b/replays/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 4763a9c3..13d84f4e 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -41,7 +41,7 @@ function MP.is_ruleset_active(ruleset_name) local key = "ruleset_mp_" .. ruleset_name if MP.LOBBY.code then return MP.LOBBY.config.ruleset == key - elseif MP.SP and MP.SP.ruleset then + elseif MP.is_practice_mode() then return MP.SP.ruleset == key end return false @@ -50,22 +50,30 @@ end function MP.get_active_ruleset() if MP.LOBBY.code then return MP.LOBBY.config.ruleset - elseif MP.SP and MP.SP.ruleset then + elseif MP.is_practice_mode() then return MP.SP.ruleset end return nil end -function MP.ApplyBans() - local ruleset_key = nil - local gamemode = nil - - if MP.LOBBY.code and MP.LOBBY.config.ruleset then - ruleset_key = MP.LOBBY.config.ruleset - gamemode = MP.Gamemodes["gamemode_mp_" .. MP.LOBBY.type] - elseif MP.SP and MP.SP.ruleset then - ruleset_key = MP.SP.ruleset +function MP.get_active_gamemode() + if MP.LOBBY.code then + return MP.LOBBY.config.gamemode + elseif MP.is_practice_mode() then + -- Ghost replay stores the gamemode directly + if MP.GHOST.is_active() and MP.GHOST.gamemode then + return MP.GHOST.gamemode + end + local ruleset_key = MP.SP and MP.SP.ruleset + if ruleset_key and MP.Rulesets[ruleset_key] then return MP.Rulesets[ruleset_key].forced_gamemode end end + return nil +end + +function MP.ApplyBans() + local ruleset_key = MP.get_active_ruleset() + local gamemode_key = MP.get_active_gamemode() + local gamemode = gamemode_key and MP.Gamemodes[gamemode_key] or nil if ruleset_key then local ruleset = MP.Rulesets[ruleset_key] diff --git a/tools/log_to_ghost_replay.html b/tools/log_to_ghost_replay.html new file mode 100644 index 00000000..2342778e --- /dev/null +++ b/tools/log_to_ghost_replay.html @@ -0,0 +1,284 @@ + + + + + +Ghost Replay Generator + + + + +

Ghost Replay Generator

+

Balatro Multiplayer — drop a Lovely log, get a ghost replay JSON

+ +
+

Drop your lovely-*.log here

+

or click to browse

+
+ + +
+ + + + diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py new file mode 100644 index 00000000..01305ce3 --- /dev/null +++ b/tools/log_to_ghost_replay.py @@ -0,0 +1,732 @@ +#!/usr/bin/env python3 +"""Parse a Lovely log file and generate ghost replay JSON(s). + +Handles multiple games within a single log file — each startGame/stopGame +cycle produces a separate replay. Output defaults to JSON written into +replays/; use --lua for Lua table output to stdout. + +Usage: + python3 tools/log_to_ghost_replay.py # writes to replays/ + python3 tools/log_to_ghost_replay.py --lua # output Lua table to stdout +""" + +import json +import os +import re +import sys +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass +class HandScore: + """A single hand played during a PvP round.""" + + score: str + hands_left: int + side: str # "player" or "enemy" + + +@dataclass +class AnteSnapshot: + ante: int + player_score: str = "0" + enemy_score: str = "0" + player_lives: int = 4 + enemy_lives: int = 4 + result: Optional[str] = None # "win" or "loss" + hands: list = field(default_factory=list) # list[HandScore] + + +@dataclass +class GameRecord: + seed: Optional[str] = None + ruleset: Optional[str] = None + gamemode: Optional[str] = None + deck: Optional[str] = None + stake: Optional[int] = None + player_name: Optional[str] = None + nemesis_name: Optional[str] = None + starting_lives: int = 4 + is_host: Optional[bool] = None + lobby_code: Optional[str] = None + ante_snapshots: dict = field(default_factory=dict) + winner: Optional[str] = None + final_ante: int = 1 + current_ante: int = 0 + player_lives: int = 4 + enemy_lives: int = 4 + + # PvP round tracking (transient) + pvp_player_score: str = "0" + pvp_enemy_score: str = "0" + pvp_hands: list = field(default_factory=list) + in_pvp: bool = False + + # End-game data + player_jokers: list = field(default_factory=list) + nemesis_jokers: list = field(default_factory=list) + player_stats: dict = field(default_factory=dict) + nemesis_stats: dict = field(default_factory=dict) + + # Per-ante shop spending + shop_spending: dict = field(default_factory=dict) + + # Non-PvP round failures + failed_rounds: list = field(default_factory=list) + + # Timing + game_start_ts: Optional[str] = None + game_end_ts: Optional[str] = None + + # Card activity log + cards_bought: list = field(default_factory=list) + cards_sold: list = field(default_factory=list) + cards_used: list = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +_TS_RE = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})") + + +def parse_timestamp(line: str) -> Optional[str]: + """Extract ISO-ish timestamp from a log line.""" + m = _TS_RE.search(line) + return m.group(1) if m else None + + +def parse_client_sent_json(line: str) -> Optional[dict]: + """Extract JSON from 'Client sent message: {...}' lines.""" + m = re.search(r"Client sent message: (\{.*\})\s*$", line) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + return None + return None + + +def parse_client_sent_kv(line: str) -> Optional[dict]: + """Extract key:value pairs from 'Client sent message: action:foo,key:val' lines.""" + m = re.search(r"Client sent message: (action:\w+.*?)\s*$", line) + if not m: + return None + raw = m.group(1) + pairs = {} + for part in raw.split(","): + if ":" in part: + k, v = part.split(":", 1) + pairs[k.strip()] = v.strip() + return pairs + + +def parse_client_got_kv(line: str) -> Optional[tuple]: + """Extract action and key-value pairs from 'Client got message: (k: v) ...' lines.""" + m = re.search(r"Client got (\w+) message:\s*(.*?)\s*$", line) + if not m: + return None + action = m.group(1) + kv_str = m.group(2) + pairs = {} + for km in re.finditer(r"\((\w+):\s*([^)]*)\)", kv_str): + key = km.group(1) + val = km.group(2).strip() + try: + if "." in val: + val = float(val) + else: + val = int(val) + except ValueError: + if val == "true": + val = True + elif val == "false": + val = False + pairs[key] = val + return action, pairs + + +def parse_joker_list(raw: str) -> list: + """Parse a ';'-separated joker string like ';j_foo-none-none-none;j_bar-...' + into a list of joker key strings.""" + jokers = [] + for entry in raw.split(";"): + entry = entry.strip() + if not entry: + continue + # Format: j_key-edition-sticker1-sticker2 + parts = entry.split("-", 1) + jokers.append(parts[0] if parts else entry) + return jokers + + +def parse_joker_list_full(raw: str) -> list: + """Parse joker string preserving edition/sticker info as dicts.""" + jokers = [] + for entry in raw.split(";"): + entry = entry.strip() + if not entry: + continue + parts = entry.split("-") + joker = {"key": parts[0]} + if len(parts) >= 2 and parts[1] != "none": + joker["edition"] = parts[1] + if len(parts) >= 3 and parts[2] != "none": + joker["sticker1"] = parts[2] + if len(parts) >= 4 and parts[3] != "none": + joker["sticker2"] = parts[3] + jokers.append(joker) + return jokers + + +# --------------------------------------------------------------------------- +# Multi-game log processing +# --------------------------------------------------------------------------- + + +def process_log(filepath: str) -> list: + """Process a log file and return a list of GameRecords (one per game).""" + games = [] + game = GameRecord() + last_lobby_options = None + in_game = False + + with open(filepath, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + + for line in lines: + if "MULTIPLAYER" not in line: + continue + + ts = parse_timestamp(line) + + # --- Direct log messages (not Client sent/got) --- + if "Sending end game jokers:" in line: + m = re.search(r"Sending end game jokers:\s*(.*?)\s*$", line) + if m: + game.player_jokers = parse_joker_list_full(m.group(1)) + continue + + if "Received end game jokers:" in line: + m = re.search(r"Received end game jokers:\s*(.*?)\s*$", line) + if m: + game.nemesis_jokers = parse_joker_list_full(m.group(1)) + continue + + # --- Sent messages (JSON) --- + sent = parse_client_sent_json(line) + if sent: + action = sent.get("action") + + if action == "username": + game.player_name = sent.get("username") + + elif action == "lobbyOptions": + last_lobby_options = sent + + elif action == "setAnte": + ante = sent.get("ante", 0) + game.current_ante = ante + if ante > game.final_ante: + game.final_ante = ante + + elif action == "playHand": + score = sent.get("score", "0") + hands_left = sent.get("handsLeft", 0) + if game.in_pvp: + game.pvp_player_score = str(score) + game.pvp_hands.append( + HandScore( + score=str(score), + hands_left=hands_left, + side="player", + ) + ) + + elif action == "setLocation": + loc = sent.get("location", "") + if "bl_mp_nemesis" in loc: + game.in_pvp = True + + elif action == "failRound": + game.failed_rounds.append(game.current_ante) + + elif action == "spentLastShop": + amount = sent.get("amount", 0) + game.shop_spending[game.current_ante] = ( + game.shop_spending.get(game.current_ante, 0) + amount + ) + + elif action == "nemesisEndGameStats": + # Player's own stats sent to the nemesis + game.player_stats = { + k: v for k, v in sent.items() if k != "action" + } + + elif action == "startGame": + # Record game start timestamp + if ts: + game.game_start_ts = ts + + continue + + # --- Sent messages (key:value format — card activity) --- + sent_kv = parse_client_sent_kv(line) + if sent_kv: + action = sent_kv.get("action") + if action == "boughtCardFromShop": + card = sent_kv.get("card", "") + cost = sent_kv.get("cost", "0") + game.cards_bought.append( + {"card": card, "cost": int(cost), "ante": game.current_ante} + ) + elif action == "soldCard": + card = sent_kv.get("card", "") + game.cards_sold.append( + {"card": card, "ante": game.current_ante} + ) + elif action == "usedCard": + card = sent_kv.get("card", "") + game.cards_used.append( + {"card": card, "ante": game.current_ante} + ) + continue + + # --- Received messages (key-value) --- + parsed = parse_client_got_kv(line) + if not parsed: + continue + action, kv = parsed + + if action == "joinedLobby": + if "code" in kv: + game.lobby_code = str(kv["code"]) + + elif action == "lobbyInfo": + if "isHost" in kv: + game.is_host = kv["isHost"] + if game.is_host is True and "guest" in kv: + game.nemesis_name = str(kv["guest"]) + elif game.is_host is False and "host" in kv: + game.nemesis_name = str(kv["host"]) + + elif action == "startGame": + in_game = True + if ts: + game.game_start_ts = game.game_start_ts or ts + # Apply last lobby options + if last_lobby_options: + game.ruleset = last_lobby_options.get("ruleset") + game.gamemode = last_lobby_options.get("gamemode") + game.deck = last_lobby_options.get("back", "Red Deck") + game.stake = last_lobby_options.get("stake", 1) + game.starting_lives = last_lobby_options.get( + "starting_lives", 4 + ) + game.player_lives = game.starting_lives + game.enemy_lives = game.starting_lives + + elif action == "playerInfo": + if "lives" in kv: + game.player_lives = kv["lives"] + + elif action == "enemyInfo": + if "lives" in kv: + game.enemy_lives = kv["lives"] + if "score" in kv: + score_str = str(kv["score"]) + if game.in_pvp: + game.pvp_enemy_score = score_str + # Track enemy hand progressions + hands_left = kv.get("handsLeft", 0) + game.pvp_hands.append( + HandScore( + score=score_str, + hands_left=hands_left, + side="enemy", + ) + ) + + elif action == "enemyLocation": + loc = kv.get("location", "") + if "bl_mp_nemesis" in str(loc): + game.in_pvp = True + + elif action == "endPvP": + lost = kv.get("lost", False) + result = "loss" if lost else "win" + + # Clean up hand progression: drop initial score=0 entries, + # deduplicate same-score same-side updates (life-loss broadcasts), + # and skip late re-broadcasts of a side's final score + cleaned_hands = [] + seen_final = {} # side -> score at hands_left=0 + for h in game.pvp_hands: + if h.score == "0" and h.hands_left >= 4: + continue + if cleaned_hands and ( + cleaned_hands[-1].score == h.score + and cleaned_hands[-1].side == h.side + ): + continue + # Skip if this side already posted a final score + if h.side in seen_final and h.score == seen_final[h.side]: + continue + if h.hands_left == 0: + seen_final[h.side] = h.score + cleaned_hands.append(h) + + snap = AnteSnapshot( + ante=game.current_ante, + player_score=game.pvp_player_score, + enemy_score=game.pvp_enemy_score, + player_lives=game.player_lives, + enemy_lives=game.enemy_lives, + result=result, + hands=cleaned_hands, + ) + game.ante_snapshots[game.current_ante] = snap + + # Reset PvP tracking + game.in_pvp = False + game.pvp_player_score = "0" + game.pvp_enemy_score = "0" + game.pvp_hands = [] + + elif action == "winGame": + game.winner = "player" + + elif action == "loseGame": + game.winner = "nemesis" + + elif action == "nemesisEndGameStats": + # Nemesis stats received from the server + game.nemesis_stats = {k: v for k, v in kv.items() if k != "action"} + + elif action == "stopGame": + if "seed" in kv: + game.seed = str(kv["seed"]) + if ts: + game.game_end_ts = ts + # Finalize this game record and start a fresh one for the next game + if in_game: + games.append(game) + in_game = False + # Carry over player_name and preserve lobby state for next game + prev_name = game.player_name + game = GameRecord() + game.player_name = prev_name + last_lobby_options = None + + # If the log ends mid-game (no stopGame), still capture it + if in_game and game.winner: + games.append(game) + + return games + + +# --------------------------------------------------------------------------- +# Output formatters +# --------------------------------------------------------------------------- + + +def _format_duration(start: str, end: str) -> Optional[str]: + """Compute duration string from two timestamp strings.""" + try: + fmt = "%Y-%m-%d %H:%M:%S" + t0 = datetime.strptime(start, fmt) + t1 = datetime.strptime(end, fmt) + delta = t1 - t0 + secs = int(delta.total_seconds()) + if secs < 0: + return None + mins, s = divmod(secs, 60) + return f"{mins}m{s:02d}s" + except (ValueError, TypeError): + return None + + +def _hands_to_list(hands: list) -> list: + """Convert HandScore objects to serializable dicts.""" + return [ + {"score": h.score, "hands_left": h.hands_left, "side": h.side} + for h in hands + ] + + +def to_json(game: GameRecord) -> str: + """Convert a GameRecord to JSON.""" + snapshots = {} + for ante, snap in sorted(game.ante_snapshots.items()): + snap_dict = { + "player_score": snap.player_score, + "enemy_score": snap.enemy_score, + "player_lives": snap.player_lives, + "enemy_lives": snap.enemy_lives, + "result": snap.result, + } + if snap.hands: + snap_dict["hands"] = _hands_to_list(snap.hands) + snapshots[str(ante)] = snap_dict + + obj = { + "gamemode": game.gamemode or "gamemode_mp_attrition", + "final_ante": game.final_ante, + "ante_snapshots": snapshots, + "winner": game.winner or "unknown", + "timestamp": int(time.time()), + "ruleset": game.ruleset or "ruleset_mp_blitz", + "seed": game.seed or "UNKNOWN", + "deck": game.deck or "Red Deck", + "stake": game.stake or 1, + } + if game.player_name: + obj["player_name"] = game.player_name + if game.nemesis_name: + obj["nemesis_name"] = game.nemesis_name + if game.lobby_code: + obj["lobby_code"] = game.lobby_code + + # Duration + if game.game_start_ts and game.game_end_ts: + dur = _format_duration(game.game_start_ts, game.game_end_ts) + if dur: + obj["duration"] = dur + + # End-game jokers + if game.player_jokers: + obj["player_jokers"] = game.player_jokers + if game.nemesis_jokers: + obj["nemesis_jokers"] = game.nemesis_jokers + + # End-game stats + if game.player_stats: + obj["player_stats"] = game.player_stats + if game.nemesis_stats: + obj["nemesis_stats"] = game.nemesis_stats + + # Shop spending per ante + if game.shop_spending: + obj["shop_spending"] = { + str(k): v for k, v in sorted(game.shop_spending.items()) + } + + # Non-PvP round failures + if game.failed_rounds: + obj["failed_rounds"] = game.failed_rounds + + # Card activity + if game.cards_bought: + obj["cards_bought"] = game.cards_bought + if game.cards_sold: + obj["cards_sold"] = game.cards_sold + if game.cards_used: + obj["cards_used"] = game.cards_used + + return _compact_json(obj) + + +def _compact_json(obj, indent=2) -> str: + """JSON with indent, but small objects/arrays collapsed to one line. + + Any value that serialises to <= *threshold* chars is kept on a single line, + so arrays-of-small-dicts (hands, jokers, card activity) stay readable + without burning vertical space. + """ + threshold = 120 + + def _fmt(value, level): + if isinstance(value, dict): + if not value: + return "{}" + # Try compact first + compact = json.dumps(value, separators=(", ", ": ")) + if len(compact) <= threshold and "\n" not in compact: + return compact + # Expanded + pad = " " * (indent * (level + 1)) + end_pad = " " * (indent * level) + items = [] + for k, v in value.items(): + items.append(f"{pad}{json.dumps(k)}: {_fmt(v, level + 1)}") + return "{\n" + ",\n".join(items) + "\n" + end_pad + "}" + elif isinstance(value, list): + if not value: + return "[]" + compact = json.dumps(value, separators=(", ", ": ")) + if len(compact) <= threshold and "\n" not in compact: + return compact + pad = " " * (indent * (level + 1)) + end_pad = " " * (indent * level) + items = [f"{pad}{_fmt(v, level + 1)}" for v in value] + return "[\n" + ",\n".join(items) + "\n" + end_pad + "]" + else: + return json.dumps(value) + + return _fmt(obj, 0) + + +def to_lua_table(game: GameRecord) -> str: + """Convert a GameRecord to a Lua table string matching ghost_replays format.""" + indent = "\t" + + lines = ["{"] + lines.append( + f'{indent}["gamemode"] = "{game.gamemode or "gamemode_mp_attrition"}",' + ) + lines.append(f'{indent}["final_ante"] = {game.final_ante},') + lines.append(f'{indent}["ante_snapshots"] = {{') + + for ante in sorted(game.ante_snapshots.keys()): + snap = game.ante_snapshots[ante] + lines.append(f"{indent}{indent}[{ante}] = {{") + lines.append(f'{indent}{indent}{indent}["result"] = "{snap.result}",') + lines.append( + f'{indent}{indent}{indent}["enemy_score"] = "{snap.enemy_score}",' + ) + lines.append( + f'{indent}{indent}{indent}["enemy_lives"] = {snap.enemy_lives},' + ) + lines.append( + f'{indent}{indent}{indent}["player_score"] = "{snap.player_score}",' + ) + lines.append( + f'{indent}{indent}{indent}["player_lives"] = {snap.player_lives},' + ) + lines.append(f"{indent}{indent}}},") + + lines.append(f"{indent}}},") + lines.append(f'{indent}["winner"] = "{game.winner or "unknown"}",') + lines.append(f'{indent}["timestamp"] = {int(time.time())},') + lines.append( + f'{indent}["ruleset"] = "{game.ruleset or "ruleset_mp_blitz"}",' + ) + lines.append(f'{indent}["seed"] = "{game.seed or "UNKNOWN"}",') + lines.append(f'{indent}["deck"] = "{game.deck or "Red Deck"}",') + lines.append(f'{indent}["stake"] = {game.stake or 1},') + if game.player_name: + lines.append(f'{indent}["player_name"] = "{game.player_name}",') + if game.nemesis_name: + lines.append(f'{indent}["nemesis_name"] = "{game.nemesis_name}",') + lines.append("}") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def print_summary(game: GameRecord, idx: int = 0, total: int = 1): + """Print a human-readable summary of a parsed game to stderr.""" + header = f"# Game {idx + 1}/{total}" if total > 1 else "# Parsed game" + print(header, file=sys.stderr) + print(f"# Seed: {game.seed}", file=sys.stderr) + print(f"# Lobby: {game.lobby_code}", file=sys.stderr) + print(f"# Ruleset: {game.ruleset}", file=sys.stderr) + print(f"# Gamemode: {game.gamemode}", file=sys.stderr) + print(f"# Deck: {game.deck} (stake {game.stake})", file=sys.stderr) + print(f"# Player: {game.player_name}", file=sys.stderr) + print(f"# Nemesis: {game.nemesis_name}", file=sys.stderr) + print(f"# Winner: {game.winner}", file=sys.stderr) + print(f"# Final ante: {game.final_ante}", file=sys.stderr) + + if game.game_start_ts and game.game_end_ts: + dur = _format_duration(game.game_start_ts, game.game_end_ts) + if dur: + print(f"# Duration: {dur}", file=sys.stderr) + + print( + f"# PvP snapshots: {len(game.ante_snapshots)} antes", file=sys.stderr + ) + for ante in sorted(game.ante_snapshots.keys()): + s = game.ante_snapshots[ante] + n_hands = len(s.hands) + hand_info = f" ({n_hands} hand updates)" if n_hands else "" + print( + f"# Ante {ante}: {s.result} | " + f"player={s.player_score} ({s.player_lives}hp) vs " + f"enemy={s.enemy_score} ({s.enemy_lives}hp){hand_info}", + file=sys.stderr, + ) + + if game.failed_rounds: + print( + f"# Failed non-PvP rounds at antes: {game.failed_rounds}", + file=sys.stderr, + ) + + if game.player_jokers: + jkeys = [j["key"] for j in game.player_jokers] + print(f"# Player jokers: {', '.join(jkeys)}", file=sys.stderr) + if game.nemesis_jokers: + jkeys = [j["key"] for j in game.nemesis_jokers] + print(f"# Nemesis jokers: {', '.join(jkeys)}", file=sys.stderr) + + if game.player_stats: + print(f"# Player stats: {game.player_stats}", file=sys.stderr) + if game.nemesis_stats: + print(f"# Nemesis stats: {game.nemesis_stats}", file=sys.stderr) + + if game.shop_spending: + total_spent = sum(game.shop_spending.values()) + print(f"# Total shop spending: {total_spent}", file=sys.stderr) + + print(file=sys.stderr) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + filepath = sys.argv[1] + output_format = "json" + if "--lua" in sys.argv: + output_format = "lua" + + games = process_log(filepath) + + if not games: + print(f"# No complete games found in {filepath}", file=sys.stderr) + sys.exit(1) + + for idx, game in enumerate(games): + print_summary(game, idx, len(games)) + + if output_format == "lua": + for game in games: + print(to_lua_table(game)) + else: + script_dir = os.path.dirname(os.path.abspath(__file__)) + replays_dir = os.path.join(script_dir, "..", "replays") + os.makedirs(replays_dir, exist_ok=True) + + for idx, game in enumerate(games): + seed = game.seed or "unknown" + player = (game.player_name or "unknown").replace("~", "-") + nemesis = (game.nemesis_name or "unknown").replace("~", "-") + # Add index suffix when multiple games share the same names + suffix = f"_{idx + 1}" if len(games) > 1 else "" + filename = f"{seed}_{player}_vs_{nemesis}{suffix}.json" + out_path = os.path.join(replays_dir, filename) + + with open(out_path, "w") as f: + f.write(to_json(game)) + f.write("\n") + + print(f"Wrote {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua index 1a9fe6d8..ed57ed41 100644 --- a/ui/game/blind_choice.lua +++ b/ui/game/blind_choice.lua @@ -1,7 +1,7 @@ local create_UIBox_blind_choice_ref = create_UIBox_blind_choice ---@diagnostic disable-next-line: lowercase-global function create_UIBox_blind_choice(type, run_info) - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then if not G.GAME.blind_on_deck then G.GAME.blind_on_deck = "Small" end if not run_info then G.GAME.round_resets.blind_states[G.GAME.blind_on_deck] = "Select" end @@ -134,10 +134,16 @@ function create_UIBox_blind_choice(type, run_info) or "", }, }) - local loc_name = ( - G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" - and (MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username) - ) or localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" }) + local loc_name + if G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" then + if MP.GHOST.is_active() then + loc_name = MP.GHOST.get_nemesis_name() + else + loc_name = MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username + end + else + loc_name = localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" }) + end local blind_col = get_blind_main_colour(type) @@ -213,8 +219,9 @@ function create_UIBox_blind_choice(type, run_info) hover = true, one_press = true, func = ( - G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" - or G.GAME.round_resets.pvp_blind_choices[type] + not MP.GHOST.is_active() + and (G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" + or G.GAME.round_resets.pvp_blind_choices[type]) ) and "pvp_ready_button" or nil, diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index 4df6cfb8..7630eee7 100644 --- a/ui/game/blind_hud.lua +++ b/ui/game/blind_hud.lua @@ -1,5 +1,5 @@ function MP.UI.update_blind_HUD() - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then G.HUD_blind.alignment.offset.y = -10 G.E_MANAGER:add_event(Event({ trigger = "after", @@ -29,7 +29,7 @@ function MP.UI.update_blind_HUD() end function MP.UI.reset_blind_HUD() - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = { { ref_table = G.GAME.blind, ref_value = "loc_name" } } G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text() @@ -122,7 +122,7 @@ end local blind_defeat_ref = Blind.defeat function Blind:defeat(silent) blind_defeat_ref(self, silent) - if MP.LOBBY.code and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end + if (MP.LOBBY.code or MP.GHOST.is_active()) and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end end local blind_disable_ref = Blind.disable diff --git a/ui/game/functions.lua b/ui/game/functions.lua index e813f7f2..573aaaa2 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -51,12 +51,14 @@ function G.FUNCS.select_blind(e) MP.GAME.end_pvp = false MP.GAME.prevent_eval = false select_blind_ref(e) - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then MP.GAME.ante_key = tostring(math.random()) - MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) - MP.ACTIONS.new_round() - MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name)) - if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end + if not MP.GHOST.is_active() then + MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) + MP.ACTIONS.new_round() + MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name)) + if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end + end end end diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index bc03dbd1..8ee52df0 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -1,9 +1,13 @@ -- Contains function overrides (monkey-patches) for game state management -- Overrides Game methods like update_draw_to_hand, update_hand_played, update_new_round, etc. +local function is_mp_or_ghost() + return MP.LOBBY.code or MP.GHOST.is_active() +end + local update_draw_to_hand_ref = Game.update_draw_to_hand function Game:update_draw_to_hand(dt) - if MP.LOBBY.code then + if is_mp_or_ghost() then if not G.STATE_COMPLETE and G.GAME.current_round.hands_played == 0 @@ -28,12 +32,18 @@ function Game:update_draw_to_hand(dt) delay = 0.45, blockable = false, func = function() - G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = { - { - ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host, - ref_value = "username", - }, - } + local blind_name_string + if MP.GHOST.is_active() then + blind_name_string = { { string = MP.GHOST.get_nemesis_name() } } + else + blind_name_string = { + { + ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host, + ref_value = "username", + }, + } + end + G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = blind_name_string G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text() G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_in(0) return true @@ -69,8 +79,10 @@ function Game:update_draw_to_hand(dt) end G.E_MANAGER:add_event(Event({ func = function() - for i = 1, MP.GAME.asteroids do - MP.ACTIONS.asteroid() + if not MP.GHOST.is_active() then + for i = 1, MP.GAME.asteroids do + MP.ACTIONS.asteroid() + end end MP.GAME.asteroids = 0 return true @@ -182,7 +194,8 @@ local update_hand_played_ref = Game.update_hand_played ---@diagnostic disable-next-line: duplicate-set-field function Game:update_hand_played(dt) -- Ignore for singleplayer or regular blinds - if not MP.LOBBY.connected or not MP.LOBBY.code or not MP.is_pvp_boss() then + local ghost = MP.GHOST.is_active() + if (not ghost and (not MP.LOBBY.connected or not MP.LOBBY.code)) or not MP.is_pvp_boss() then update_hand_played_ref(self, dt) return end @@ -201,21 +214,116 @@ function Game:update_hand_played(dt) G.E_MANAGER:add_event(Event({ trigger = "immediate", func = function() - MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) - -- For now, never advance to next round + if not ghost then + MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) + end + if G.GAME.current_round.hands_left < 1 then - attention_text({ - scale = 0.8, - text = localize("k_wait_enemy"), - hold = 5, - align = "cm", - offset = { x = 0, y = -1.5 }, - major = G.play, - }) + if ghost then + local target + if MP.GHOST.has_hand_data() then + target = MP.GHOST.current_target_big() + else + local es = MP.GAME.enemy.score + target = to_big(es.coeffiocient * (10 ^ es.exponent)) + end + local beat_current = to_big(G.GAME.chips) >= target + local all_exhausted = MP.GHOST.playback_exhausted() + + if beat_current and all_exhausted then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + MP.MATCH_RECORD.finalize(true) + win_game() + return true + end + else + if MP.LOBBY.config.gold_on_life_loss then + MP.GAME.comeback_bonus_given = false + MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1 + end + MP.GAME.lives = MP.GAME.lives - 1 + MP.UI.ease_lives(-1) + 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 + if MP.GAME.lives <= 0 then + MP.MATCH_RECORD.finalize(false) + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return true + end + end + MP.GAME.end_pvp = true + else + attention_text({ + scale = 0.8, + text = localize("k_wait_enemy"), + hold = 5, + align = "cm", + offset = { x = 0, y = -1.5 }, + major = G.play, + }) + end if G.hand.cards[1] and G.STATE == G.STATES.HAND_PLAYED then eval_hand_and_jokers() G.FUNCS.draw_from_hand_to_discard() end + elseif ghost and MP.GHOST.has_hand_data() then + local beat_current = to_big(G.GAME.chips) >= MP.GHOST.current_target_big() + + if beat_current and MP.GHOST.playback_exhausted() then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + MP.MATCH_RECORD.finalize(true) + win_game() + return true + end + MP.GAME.end_pvp = true + elseif beat_current and not MP.GHOST.playback_exhausted() and not MP.GHOST._advancing then + MP.GHOST._advancing = true + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.5, + func = function() + MP.GHOST.advance_hand() + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.6, + func = function() + while to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() do + MP.GHOST.advance_hand() + end + if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and MP.GHOST.playback_exhausted() then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + MP.MATCH_RECORD.finalize(true) + win_game() + MP.GHOST._advancing = false + return true + end + MP.GAME.end_pvp = true + end + MP.GHOST._advancing = false + return true + end, + })) + return true + end, + })) + end + + if not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then + G.STATE_COMPLETE = false + G.STATE = G.STATES.DRAW_TO_HAND + end elseif not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then G.STATE_COMPLETE = false G.STATE = G.STATES.DRAW_TO_HAND @@ -242,19 +350,36 @@ function Game:update_new_round(dt) G.STATE = G.STATES.NEW_ROUND MP.GAME.end_pvp = false end - if MP.LOBBY.code and not G.STATE_COMPLETE then + if is_mp_or_ghost() and not G.STATE_COMPLETE then + local ghost = MP.GHOST.is_active() -- Prevent player from losing if to_big(G.GAME.chips) < to_big(G.GAME.blind.chips) and not MP.is_pvp_boss() then G.GAME.blind.chips = -1 - MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival") - and (tonumber(MP.GAME.lives) == 1) -- In Survival Mode, if this is the last live, wait for the enemy. - MP.ACTIONS.fail_round(G.GAME.current_round.hands_played) + if ghost then + if MP.LOBBY.config.death_on_round_loss and G.GAME.current_round.hands_played > 0 then + MP.GAME.lives = MP.GAME.lives - 1 + MP.UI.ease_lives(-1) + 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 + if MP.GAME.lives <= 0 then + MP.MATCH_RECORD.finalize(false) + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return + end + end + else + MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival") + and (tonumber(MP.GAME.lives) == 1) + MP.ACTIONS.fail_round(G.GAME.current_round.hands_played) + end end -- Prevent player from winning G.GAME.win_ante = 999 - if MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then + if not ghost and MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then G.STATE_COMPLETE = true G.FUNCS.draw_from_hand_to_discard() attention_text({ @@ -283,14 +408,16 @@ function Game:update_selecting_hand(dt) and #G.hand.cards < 1 and #G.deck.cards < 1 and #G.play.cards < 1 - and MP.LOBBY.code + and is_mp_or_ghost() then G.GAME.current_round.hands_left = 0 if not MP.is_pvp_boss() then G.STATE_COMPLETE = false G.STATE = G.STATES.NEW_ROUND else - MP.ACTIONS.play_hand(G.GAME.chips, 0) + if not MP.GHOST.is_active() then + MP.ACTIONS.play_hand(G.GAME.chips, 0) + end G.STATE_COMPLETE = false G.STATE = G.STATES.HAND_PLAYED end @@ -298,7 +425,7 @@ function Game:update_selecting_hand(dt) end update_selecting_hand_ref(self, dt) - if MP.GAME.end_pvp and MP.is_pvp_boss() then + if MP.GAME.end_pvp and MP.is_pvp_boss() and is_mp_or_ghost() then G.hand:unhighlight_all() G.STATE_COMPLETE = false G.STATE = G.STATES.NEW_ROUND @@ -345,7 +472,8 @@ function Game:start_run(args) start_run_ref(self, args) - if not MP.LOBBY.connected or not MP.LOBBY.code or MP.LOBBY.config.disable_live_and_timer_hud then return end + local show_lives_hud = (MP.LOBBY.connected and MP.LOBBY.code) or MP.GHOST.is_active() + if not show_lives_hud or MP.LOBBY.config.disable_live_and_timer_hud then return end local scale = 0.4 local hud_ante = G.HUD:get_UIE_by_ID("hud_ante") @@ -370,7 +498,7 @@ end -- This prevents duplicate execution during certain cases. e.g. Full deck discard before playing any hands. function MP.handle_duplicate_end() - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then if MP.GAME.round_ended then if not MP.GAME.duplicate_end then MP.GAME.duplicate_end = true @@ -385,15 +513,16 @@ end -- This handles an edge case where a player plays no hands, and discards the only cards in their deck. -- Allows opponent to advance after playing anything, and eases a life from the person who discarded their deck. function MP.handle_deck_out() - if MP.LOBBY.code then + if MP.LOBBY.code or MP.GHOST.is_active() then if G.GAME.current_round.hands_played == 0 and G.GAME.current_round.discards_used > 0 and MP.LOBBY.config.gamemode ~= "gamemode_mp_survival" then - if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end - - MP.ACTIONS.fail_round(1) + if not MP.GHOST.is_active() then + if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end + MP.ACTIONS.fail_round(1) + end end end end diff --git a/ui/game/round.lua b/ui/game/round.lua index dbd5f5bf..d86abf53 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -3,7 +3,7 @@ local ease_ante_ref = ease_ante function ease_ante(mod) - if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then + if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud then -- Prevents easing multiple times at once if MP.GAME.antes_keyed[MP.GAME.ante_key] then return end @@ -15,7 +15,9 @@ function ease_ante(mod) end MP.GAME.antes_keyed[MP.GAME.ante_key] = true - MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod) + if not MP.GHOST.is_active() then + MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod) + end G.E_MANAGER:add_event(Event({ trigger = "immediate", func = function() @@ -30,7 +32,7 @@ end local ease_round_ref = ease_round function ease_round(mod) - if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end + if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end ease_round_ref(mod) end @@ -38,13 +40,19 @@ local reset_blinds_ref = reset_blinds function reset_blinds() reset_blinds_ref() G.GAME.round_resets.pvp_blind_choices = {} - if MP.LOBBY.code then + + local gamemode_key = MP.get_active_gamemode() + if gamemode_key and MP.Gamemodes[gamemode_key] then local mp_small_choice, mp_big_choice, mp_boss_choice = - MP.Gamemodes[MP.LOBBY.config.gamemode]:get_blinds_by_ante(G.GAME.round_resets.ante) + MP.Gamemodes[gamemode_key]:get_blinds_by_ante(G.GAME.round_resets.ante) G.GAME.round_resets.blind_choices.Small = mp_small_choice or G.GAME.round_resets.blind_choices.Small G.GAME.round_resets.blind_choices.Big = mp_big_choice or G.GAME.round_resets.blind_choices.Big G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss end + + if MP.GHOST.is_active() then + MP.GHOST.init_playback(G.GAME.round_resets.ante) + end end -- necessary for showdown mode to ensure rounds progress properly, only affects nemesis blind to avoid possible incompatibilities (though i know many mods like to do this exact hook) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua new file mode 100644 index 00000000..5522e2cd --- /dev/null +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -0,0 +1,678 @@ +-- Match Replay Picker UI +-- Shown in practice mode to select a past match replay for ghost PvP +-- Two-column layout: replay list (left) + match details panel (right) + +local function reopen_practice_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +local function refresh_picker() + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +function G.FUNCS.open_ghost_replay_picker(e) + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +-- Stashed merged replay list so callbacks can index into it +local _picker_replays = {} +-- Currently previewed replay (shown in right panel) +local _preview_idx = nil +-- Perspective flip for the previewed replay (before loading) +local _preview_flipped = false + +function G.FUNCS.preview_ghost_replay(e) + local idx = tonumber(e.config.id:match("ghost_replay_(%d+)")) + if idx ~= _preview_idx then + _preview_flipped = false + end + _preview_idx = idx + refresh_picker() +end + +function G.FUNCS.load_previewed_ghost(e) + local replay = _picker_replays[_preview_idx] + if not replay then return end + if not is_replay_ruleset_supported(replay) then return end + + MP.GHOST.load(replay) + MP.GHOST.flipped = _preview_flipped + + if replay.ruleset then + MP.SP.ruleset = replay.ruleset + local ruleset_name = replay.ruleset:gsub("^ruleset_mp_", "") + MP.LoadReworks(ruleset_name) + end + + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +-- Keep old name working for any external callers +function G.FUNCS.select_ghost_replay(e) + G.FUNCS.preview_ghost_replay(e) +end + +function G.FUNCS.clear_ghost_replay(e) + MP.GHOST.clear() + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +function G.FUNCS.flip_ghost_perspective(e) + if _preview_idx then + _preview_flipped = not _preview_flipped + else + MP.GHOST.flip() + end + refresh_picker() +end + +G.FUNCS.ghost_picker_back = function(e) + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------- + +local function is_replay_ruleset_supported(replay) + if not replay or not replay.ruleset then return true end + return MP.Rulesets[replay.ruleset] ~= nil +end + +local function build_replay_label(r) + local result_text = (r.winner == "player") and "W" or "L" + local player_display = r.player_name or "?" + local nemesis_display = r.nemesis_name or "?" + local ante_display = tostring(r.final_ante or "?") + + local timestamp_display = "" + if r.timestamp then timestamp_display = os.date("%m/%d", r.timestamp) end + + local game_tag = "" + if r._game_index and r._game_count and r._game_count > 1 then + game_tag = string.format(" [%d/%d]", r._game_index, r._game_count) + end + + return string.format( + "%s %s v %s A%s %s%s", + result_text, + player_display, + nemesis_display, + ante_display, + timestamp_display, + game_tag + ) +end + +local function text_row(label, value, scale, label_colour, value_colour) + scale = scale or 0.3 + label_colour = label_colour or G.C.UI.TEXT_INACTIVE + value_colour = value_colour or G.C.WHITE + return { + n = G.UIT.R, + config = { align = "cl", padding = 0.02 }, + nodes = { + { n = G.UIT.T, config = { text = label .. " ", scale = scale, colour = label_colour } }, + { n = G.UIT.T, config = { text = tostring(value), scale = scale, colour = value_colour } }, + }, + } +end + +local function section_header(title, scale) + scale = scale or 0.32 + return { + n = G.UIT.R, + config = { align = "cl", padding = 0.04 }, + nodes = { + { n = G.UIT.T, config = { text = title, scale = scale, colour = G.C.GOLD } }, + }, + } +end + +local function format_score(s) + local n = tonumber(s) + if not n then return tostring(s) end + if n >= 1000000 then + return string.format("%.1fM", n / 1000000) + elseif n >= 1000 then + return string.format("%.1fK", n / 1000) + end + return tostring(n) +end + +local function build_joker_card_area(jokers, width) + if not jokers or #jokers == 0 then return nil end + + width = width or 5.5 + local card_size = math.max(0.3, 0.8 - 0.01 * #jokers) + local card_area = CardArea(0, 0, width, G.CARD_H * card_size, { + card_limit = nil, + type = "title_2", + view_deck = true, + highlight_limit = 0, + card_w = G.CARD_W * card_size, + }) + + for _, j in ipairs(jokers) do + local key = j.key or j + local center = G.P_CENTERS[key] + if center then + local card = Card( + 0, + 0, + G.CARD_W * card_size, + G.CARD_H * card_size, + nil, + center, + { bypass_discovery_center = true, bypass_discovery_ui = true } + ) + card_area:emplace(card) + end + end + + return { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { n = G.UIT.O, config = { object = card_area } }, + }, + } +end + +------------------------------------------------------------------------------- +-- Stats detail panel (right column) +------------------------------------------------------------------------------- + +local function build_stats_panel(r) + if not r then + return { + n = G.UIT.C, + config = { align = "cm", padding = 0.2, minw = 6, minh = 5 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Select a match", + scale = 0.35, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + end + + -- Header row (spans both columns) + local header_nodes = {} + + local result_str = (r.winner == "player") and "VICTORY" or "DEFEAT" + local result_colour = (r.winner == "player") and G.C.GREEN or G.C.RED + header_nodes[#header_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.04 }, + nodes = { + { n = G.UIT.T, config = { text = result_str, scale = 0.4, colour = result_colour } }, + }, + } + + local player_display = r.player_name or "?" + local nemesis_display = r.nemesis_name or "?" + header_nodes[#header_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { text = player_display .. " vs " .. nemesis_display, scale = 0.35, colour = G.C.WHITE }, + }, + }, + } + + -- Left inner column: match info + ante breakdown + stats + local left_nodes = {} + + left_nodes[#left_nodes + 1] = section_header("Match Info") + if r._filename then + local source_label = r._filename + if r._game_index and r._game_count and r._game_count > 1 then + source_label = source_label .. string.format(" (game %d of %d)", r._game_index, r._game_count) + end + left_nodes[#left_nodes + 1] = text_row("Source:", source_label, 0.25) + end + local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" + local gamemode_display = r.gamemode and r.gamemode:gsub("^gamemode_mp_", "") or "?" + local deck_display = r.deck or "?" + left_nodes[#left_nodes + 1] = text_row("Ruleset:", ruleset_display) + left_nodes[#left_nodes + 1] = text_row("Gamemode:", gamemode_display) + left_nodes[#left_nodes + 1] = text_row("Deck:", deck_display) + if r.seed then left_nodes[#left_nodes + 1] = text_row("Seed:", r.seed) end + if r.stake then left_nodes[#left_nodes + 1] = text_row("Stake:", tostring(r.stake)) end + left_nodes[#left_nodes + 1] = text_row("Final Ante:", tostring(r.final_ante or "?")) + if r.duration then left_nodes[#left_nodes + 1] = text_row("Duration:", r.duration) end + if r.timestamp then left_nodes[#left_nodes + 1] = text_row("Date:", os.date("%Y-%m-%d %H:%M", r.timestamp)) end + + -- Ante breakdown + if r.ante_snapshots then + left_nodes[#left_nodes + 1] = section_header("Ante Breakdown") + local antes = {} + for k in pairs(r.ante_snapshots) do + antes[#antes + 1] = tonumber(k) + end + table.sort(antes) + + for _, ante_num in ipairs(antes) do + local snap = r.ante_snapshots[tostring(ante_num)] or r.ante_snapshots[ante_num] + if snap then + local result_icon = snap.result == "win" and "W" or "L" + local r_col = snap.result == "win" and G.C.GREEN or G.C.RED + local p_score = format_score(snap.player_score or 0) + local e_score = format_score(snap.enemy_score or 0) + local lives_str = "" + if snap.player_lives and snap.enemy_lives then + lives_str = string.format(" [%d-%d]", snap.player_lives, snap.enemy_lives) + end + left_nodes[#left_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.01 }, + nodes = { + { + n = G.UIT.T, + config = { + text = string.format("A%d ", ante_num), + scale = 0.28, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + { n = G.UIT.T, config = { text = result_icon, scale = 0.28, colour = r_col } }, + { + n = G.UIT.T, + config = { + text = string.format(" %s - %s%s", p_score, e_score, lives_str), + scale = 0.28, + colour = G.C.WHITE, + }, + }, + }, + } + end + end + end + + -- Player/opponent stats + local function add_stats(nodes, stats, label) + if not stats then return end + nodes[#nodes + 1] = section_header(label) + if stats.reroll_count then nodes[#nodes + 1] = text_row("Rerolls:", tostring(stats.reroll_count), 0.28) end + if stats.reroll_cost_total then + nodes[#nodes + 1] = text_row("Reroll $:", tostring(stats.reroll_cost_total), 0.28) + end + if stats.vouchers then + nodes[#nodes + 1] = + text_row("Vouchers:", stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) + end + end + + add_stats(left_nodes, r.player_stats, "Your Stats") + add_stats(left_nodes, r.nemesis_stats, "Opponent Stats") + + -- Failed rounds + if r.failed_rounds and #r.failed_rounds > 0 then + local fr_parts = {} + for _, a in ipairs(r.failed_rounds) do + fr_parts[#fr_parts + 1] = "A" .. tostring(a) + end + left_nodes[#left_nodes + 1] = + text_row("Failed Rounds:", table.concat(fr_parts, ", "), 0.28, G.C.UI.TEXT_INACTIVE, G.C.RED) + end + + -- Right inner column: jokers + shop spending + local right_nodes = {} + + if r.player_jokers then + right_nodes[#right_nodes + 1] = section_header("Your Jokers") + local joker_area = build_joker_card_area(r.player_jokers, 4) + if joker_area then right_nodes[#right_nodes + 1] = joker_area end + end + if r.nemesis_jokers then + right_nodes[#right_nodes + 1] = section_header("Opponent Jokers") + local joker_area = build_joker_card_area(r.nemesis_jokers, 4) + if joker_area then right_nodes[#right_nodes + 1] = joker_area end + end + + -- Shop spending + if r.shop_spending then + right_nodes[#right_nodes + 1] = section_header("Shop Spending") + local total = 0 + local antes = {} + for k, v in pairs(r.shop_spending) do + antes[#antes + 1] = tonumber(k) + total = total + v + end + table.sort(antes) + local parts = {} + for _, a in ipairs(antes) do + parts[#parts + 1] = string.format("A%d:$%d", a, r.shop_spending[tostring(a)] or r.shop_spending[a]) + end + right_nodes[#right_nodes + 1] = text_row("Total:", "$" .. tostring(total), 0.28) + right_nodes[#right_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02, maxw = 4 }, + nodes = { + { + n = G.UIT.T, + config = { text = table.concat(parts, " "), scale = 0.24, colour = G.C.UI.TEXT_INACTIVE }, + }, + }, + } + end + + -- Assemble two-column body + local body_row = { + n = G.UIT.R, + config = { align = "tm", padding = 0.05 }, + nodes = { + { + n = G.UIT.C, + config = { align = "tm", padding = 0.08, minw = 4 }, + nodes = left_nodes, + }, + { + n = G.UIT.C, + config = { align = "tm", padding = 0.08, minw = 4.5 }, + nodes = right_nodes, + }, + }, + } + + -- Playing-as flip button + Load button (spans full width) + local playing_as = _preview_flipped + and (r.nemesis_name or "?") + or (r.player_name or "?") + + local supported = is_replay_ruleset_supported(r) + local action_nodes = {} + + if supported then + action_nodes[#action_nodes + 1] = UIBox_button({ + id = "flip_ghost_perspective", + button = "flip_ghost_perspective", + label = { "Playing as: " .. playing_as }, + minw = 3.5, + minh = 0.5, + scale = 0.3, + colour = G.C.BLUE, + hover = true, + shadow = true, + }) + action_nodes[#action_nodes + 1] = UIBox_button({ + id = "load_previewed_ghost", + button = "load_previewed_ghost", + label = { "Play Match" }, + minw = 3.5, + minh = 0.5, + scale = 0.35, + colour = G.C.GREEN, + hover = true, + shadow = true, + }) + else + action_nodes[#action_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.04 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Unsupported ruleset — cannot play this replay", + scale = 0.3, + colour = G.C.RED, + }, + }, + }, + } + end + + local load_button = { + n = G.UIT.R, + config = { align = "cm", padding = 0.08 }, + nodes = action_nodes, + } + + return { + n = G.UIT.C, + config = { align = "tm", padding = 0.1, minw = 9, r = 0.1, colour = G.C.L_BLACK }, + nodes = { + -- Header spanning both columns + { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { { n = G.UIT.C, config = { align = "cm" }, nodes = header_nodes } }, + }, + -- Two-column body + body_row, + -- Full-width button + load_button, + }, + } +end + +------------------------------------------------------------------------------- +-- Main picker UI +------------------------------------------------------------------------------- + +function G.UIDEF.ghost_replay_picker() + -- Load replays from the replays/ folder, sorted newest-first + local all = MP.GHOST.load_folder_replays() + + -- Sort newest-first + table.sort(all, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + -- Stash for callbacks to index into + _picker_replays = all + + -- Left column: replay list + local replay_nodes = {} + + if #all == 0 then + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.2 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_no_ghost_replays"), + scale = 0.35, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Place .log or .json files in the replays/ folder", + scale = 0.28, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + else + local last_filename = nil + for i, r in ipairs(all) do + -- Show filename header when entering a new log file group with multiple games + if r._filename and r._game_count and r._game_count > 1 then + if r._filename ~= last_filename then + last_filename = r._filename + local display_name = r._filename:gsub("%.log$", "") + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { + text = display_name .. " (" .. r._game_count .. " games)", + scale = 0.25, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + end + else + last_filename = nil + end + + local label = build_replay_label(r) + local is_selected = (_preview_idx == i) + local btn_colour + if is_selected then + btn_colour = G.C.WHITE + elseif r._source == "file" then + btn_colour = G.C.BLUE + else + btn_colour = G.C.GREY + end + + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "ghost_replay_" .. i, + button = "preview_ghost_replay", + label = { label }, + minw = 5.5, + minh = 0.45, + scale = 0.3, + colour = btn_colour, + hover = true, + shadow = true, + }), + }, + } + end + end + + -- Control buttons below the list + local control_nodes = {} + + if MP.GHOST.is_active() then + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "clear_ghost_replay", + button = "clear_ghost_replay", + label = { "Clear Replay" }, + minw = 3, + minh = 0.45, + scale = 0.3, + colour = G.C.RED, + hover = true, + shadow = true, + }), + }, + } + end + + -- Back button + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + UIBox_button({ + id = "ghost_picker_back", + button = "ghost_picker_back", + label = { localize("b_back") }, + minw = 3, + minh = 0.5, + scale = 0.35, + colour = G.C.ORANGE, + hover = true, + shadow = true, + }), + }, + } + + -- Left column + local left_col = { + n = G.UIT.C, + config = { align = "tm", padding = 0.1, minw = 6, r = 0.1, colour = G.C.L_BLACK }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.06 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_ghost_replays"), + scale = 0.45, + colour = G.C.WHITE, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05, maxh = 5 }, + nodes = replay_nodes, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = control_nodes, + }, + }, + } + + -- Right column: stats detail + local preview_replay = _preview_idx and _picker_replays[_preview_idx] or nil + local right_col = build_stats_panel(preview_replay) + + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR, minh = 7, minw = 13 }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.15 }, + nodes = { + { + n = G.UIT.C, + config = { align = "tm", padding = 0.15, r = 0.1, colour = G.C.BLACK }, + nodes = { + { + n = G.UIT.R, + config = { align = "tm", padding = 0.05 }, + nodes = { left_col, right_col }, + }, + }, + }, + }, + }, + }, + } +end diff --git a/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua index 71de6c20..3c34fd54 100644 --- a/ui/main_menu/play_button/play_button.lua +++ b/ui/main_menu/play_button/play_button.lua @@ -49,7 +49,7 @@ function G.UIDEF.override_main_menu_play_button() UIBox_button({ label = { localize("b_sp_with_ruleset") }, colour = G.C.ORANGE, - button = "setup_run_singleplayer", + button = "setup_practice_mode", minw = 5, }), MP.LOBBY.connected and UIBox_button({ diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 6e1a7baf..56c6505c 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,11 +1,17 @@ -- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -MP.SP = { ruleset = nil } +MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, edition_cycling = false } + +function MP.is_practice_mode() + return MP.SP.practice == true +end function G.FUNCS.setup_run_singleplayer(e) G.SETTINGS.paused = true MP.LOBBY.config.ruleset = nil MP.LOBBY.config.gamemode = nil MP.SP.ruleset = nil + MP.SP.practice = false + MP.GHOST.clear() G.FUNCS.overlay_menu({ definition = G.UIDEF.ruleset_selection_options("sp"), @@ -21,9 +27,61 @@ function G.FUNCS.start_vanilla_sp(e) MP.LOBBY.config.ruleset = nil MP.LOBBY.config.gamemode = nil MP.SP.ruleset = nil + MP.SP.practice = false + MP.GHOST.clear() G.FUNCS.setup_run(e) end +function G.FUNCS.setup_practice_mode(e) + G.SETTINGS.paused = true + MP.LOBBY.config.ruleset = nil + MP.LOBBY.config.gamemode = nil + MP.SP.ruleset = nil + MP.SP.practice = true + MP.SP.unlimited_slots = false + MP.SP.edition_cycling = false + MP.GHOST.clear() + + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +function G.FUNCS.start_practice_run(e) + G.FUNCS.exit_overlay_menu() + if MP.GHOST.is_active() then + local r = MP.GHOST.replay + MP.reset_game_states() + local starting_lives = MP.LOBBY.config.starting_lives or 4 + MP.GAME.lives = starting_lives + MP.GAME.enemy.lives = starting_lives + local deck_key = MP.UTILS.get_deck_key_from_name(r.deck) + if deck_key then G.GAME.viewed_back = G.P_CENTERS[deck_key] end + G.FUNCS.start_run(e, { seed = r.seed, stake = r.stake or 1 }) + sendDebugMessage( + string.format( + "Practice run state: practice=%s, ghost=%s, ruleset=%s, gamemode=%s, deck_key=%s, lives=%s, enemy_lives=%s, seed=%s, stake=%s", + tostring(MP.is_practice_mode()), + tostring(MP.GHOST.is_active()), + tostring(MP.get_active_ruleset()), + tostring(MP.get_active_gamemode()), + tostring(deck_key), + tostring(MP.GAME.lives), + tostring(MP.GAME.enemy.lives), + tostring(G.GAME.pseudorandom and G.GAME.pseudorandom.seed or "?"), + tostring(G.GAME.stake or "?") + ), + "MULTIPLAYER" + ) + else + G.FUNCS.setup_run(e) + end +end + +function G.FUNCS.toggle_unlimited_slots(e) + MP.SP.unlimited_slots = not MP.SP.unlimited_slots +end + function G.FUNCS.play_options(e) G.SETTINGS.paused = true @@ -92,6 +150,9 @@ end function G.FUNCS.start_lobby(e) G.SETTINGS.paused = false + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_lobby_config(true) MP.LOBBY.config.multiplayer_jokers = MP.Rulesets[MP.LOBBY.config.ruleset].multiplayer_content diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index d6146eb0..77b298e5 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -2,11 +2,16 @@ function G.UIDEF.ruleset_selection_options(mode) mode = mode or "mp" MP.LOBBY.fetched_weekly = "smallworld" -- temp - -- SP defaults to vanilla, MP defaults to ranked - local default_ruleset = "standard_ranked" + -- If ghost is active, preserve the replay's ruleset instead of resetting to default + local default_ruleset + if mode == "practice" and MP.GHOST.is_active() and MP.SP.ruleset then + default_ruleset = MP.SP.ruleset:gsub("^ruleset_mp_", "") + else + default_ruleset = "standard_ranked" + end local default_button = default_ruleset .. "_ruleset_button" - if mode == "sp" then + if mode == "sp" or mode == "practice" then MP.SP.ruleset = "ruleset_mp_" .. default_ruleset else MP.LOBBY.config.ruleset = "ruleset_mp_" .. default_ruleset @@ -76,7 +81,7 @@ function G.FUNCS.change_ruleset_selection(e) end, default_button, function(ruleset_name) - if mode == "sp" then + if mode == "sp" or mode == "practice" then MP.SP.ruleset = "ruleset_mp_" .. ruleset_name else MP.LOBBY.config.ruleset = "ruleset_mp_" .. ruleset_name @@ -99,7 +104,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) local ruleset_disabled = ruleset.is_disabled() - -- Different button config for SP vs MP + -- Different button config for SP vs MP vs Practice local button_config if mode == "sp" then button_config = { @@ -108,6 +113,13 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) label = { localize("b_play_cap") }, colour = G.C.GREEN, } + elseif mode == "practice" then + button_config = { + id = "start_practice_button", + button = "start_practice_run", + label = { localize("b_play_cap") }, + colour = G.C.GREEN, + } else button_config = { id = "select_gamemode_button", @@ -117,6 +129,84 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) } end + local content_nodes = { + { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } }, + }, + }, + } + + if mode == "practice" then + local practice_toggles = { + { id = "unlimited_slots_toggle", label = "k_unlimited_slots", ref_value = "unlimited_slots" }, + { id = "edition_cycling_toggle", label = "k_edition_cycling", ref_value = "edition_cycling" }, + } + local toggle_nodes = {} + for _, t in ipairs(practice_toggles) do + toggle_nodes[#toggle_nodes + 1] = create_toggle({ + id = t.id, + label = localize(t.label), + ref_table = MP.SP, + ref_value = t.ref_value, + }) + end + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = toggle_nodes, + } + + -- Ghost replay picker button + local ghost_label = localize("k_ghost_replays") + if MP.GHOST.is_active() then + ghost_label = ghost_label .. " (Active)" + end + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + UIBox_button({ + id = "ghost_replay_button", + button = "open_ghost_replay_picker", + label = { ghost_label }, + minw = 4, + minh = 0.6, + scale = 0.4, + colour = MP.GHOST.is_active() and G.C.GREEN or G.C.BLUE, + hover = true, + shadow = true, + }), + }, + } + end + + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + MP.UI.Disableable_Button({ + id = button_config.id, + button = button_config.button, + align = "cm", + padding = 0.05, + r = 0.1, + minw = 8, + minh = 0.8, + colour = button_config.colour, + hover = true, + shadow = true, + label = button_config.label, + scale = 0.5, + enabled_ref_table = { val = not ruleset_disabled }, + enabled_ref_value = "val", + disabled_text = { ruleset_disabled }, + }), + }, + } + return { n = G.UIT.ROOT, config = { align = "tm", minh = 8, maxh = 8, minw = 11, maxw = 11, colour = G.C.CLEAR }, @@ -124,38 +214,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) { n = G.UIT.C, config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } }, - }, - }, - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - MP.UI.Disableable_Button({ - id = button_config.id, - button = button_config.button, - align = "cm", - padding = 0.05, - r = 0.1, - minw = 8, - minh = 0.8, - colour = button_config.colour, - hover = true, - shadow = true, - label = button_config.label, - scale = 0.5, - enabled_ref_table = { val = not ruleset_disabled }, - enabled_ref_value = "val", - disabled_text = { ruleset_disabled }, - }), - }, - }, - }, + nodes = content_nodes, }, }, }