From 3bc5eca67f7897b8f2417e408cab2255ab9762c3 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Fri, 3 Apr 2026 01:54:13 +0200 Subject: [PATCH 01/15] practice mode 1 --- .gitignore | 2 + config.lua | 1 + core.lua | 7 +- lib/log_parser.lua | 436 +++++++++++ lib/match_history.lua | 274 +++++++ localization/en-us.lua | 6 + lovely/end_round.toml | 6 +- lovely/hud.toml | 17 + lovely/practice.toml | 87 +++ networking-old/action_handlers.lua | 4 + networking/action_handlers.lua | 26 + replays/.gitkeep | 0 rulesets/_rulesets.lua | 30 +- tools/log_to_ghost_replay.html | 284 +++++++ tools/log_to_ghost_replay.py | 732 ++++++++++++++++++ ui/game/blind_choice.lua | 21 +- ui/game/blind_hud.lua | 6 +- ui/game/functions.lua | 12 +- ui/game/game_state.lua | 201 ++++- ui/game/round.lua | 18 +- .../play_button/ghost_replay_picker.lua | 678 ++++++++++++++++ ui/main_menu/play_button/play_button.lua | 2 +- .../play_button/play_button_callbacks.lua | 63 +- .../play_button/ruleset_selection.lua | 133 +++- 24 files changed, 2938 insertions(+), 108 deletions(-) create mode 100644 lib/log_parser.lua create mode 100644 lib/match_history.lua create mode 100644 lovely/practice.toml create mode 100644 replays/.gitkeep create mode 100644 tools/log_to_ghost_replay.html create mode 100644 tools/log_to_ghost_replay.py create mode 100644 ui/main_menu/play_button/ghost_replay_picker.lua 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..9040be60 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.is_mp_or_ghost() 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.is_mp_or_ghost() 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.is_mp_or_ghost() 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..0bc7459e 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.is_mp_or_ghost() 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..b9b617ab 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.is_mp_or_ghost() 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.is_mp_or_ghost() 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.is_mp_or_ghost() 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..0e551a4f 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.is_mp_or_ghost() 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..cc4e5283 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. +function MP.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 MP.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,120 @@ 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 + local function ghost_advance_step() + MP.GHOST.advance_hand() + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.6, + func = function() + if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() then + ghost_advance_step() + else + 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 + end + return true + end, + })) + end + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.5, + func = function() + ghost_advance_step() + 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 +354,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 MP.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 +412,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 MP.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 +429,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 MP.is_mp_or_ghost() then G.hand:unhighlight_all() G.STATE_COMPLETE = false G.STATE = G.STATES.NEW_ROUND @@ -345,7 +476,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 +502,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.is_mp_or_ghost() then if MP.GAME.round_ended then if not MP.GAME.duplicate_end then MP.GAME.duplicate_end = true @@ -385,15 +517,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.is_mp_or_ghost() 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..fb619915 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.is_mp_or_ghost() 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.is_mp_or_ghost() 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, }, }, } From 7b02a4e95a2bd86019123f80795b5188dce5b972 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sat, 4 Apr 2026 21:50:41 +0200 Subject: [PATCH 02/15] fix collection button height in practice mode --- lovely/hud.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lovely/hud.toml b/lovely/hud.toml index 3c65f852..02f9cda8 100644 --- a/lovely/hud.toml +++ b/lovely/hud.toml @@ -8,7 +8,7 @@ priority = 2147483600 target = "functions/UI_definitions.lua" pattern = '''contents\.buttons = \{(?
[\s\S]*?)minh = 1\.75(?[\s\S]*?)minh = 1\.75'''
 position = 'at'
-payload = '''contents.buttons = {$pre minh = MP.LOBBY.code and 1.2 or 1.75$between minh = MP.LOBBY.code and 1.2 or 1.75'''
+payload = '''contents.buttons = {$pre minh = (MP.LOBBY.code or (MP.is_practice_mode and MP.is_practice_mode())) and 1.2 or 1.75$between minh = (MP.LOBBY.code or (MP.is_practice_mode and MP.is_practice_mode())) and 1.2 or 1.75'''
 times = 1
 
 [[patches]]
@@ -47,7 +47,7 @@ pattern = '''{n=G.UIT.T, config={text = localize('b_options'), scale = scale, co
           }}'''
 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={
+(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.2, 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}}
     }}

From da4d835c7e56415e4afbdc87240e4f90aeb8fbf2 Mon Sep 17 00:00:00 2001
From: Stephen Kirk 
Date: Sat, 4 Apr 2026 22:02:06 +0200
Subject: [PATCH 03/15] jimbo hint

---
 localization/en-us.lua |  1 +
 ui/game/game_state.lua | 21 +++++++++++++++++++++
 2 files changed, 22 insertions(+)

diff --git a/localization/en-us.lua b/localization/en-us.lua
index a7af9656..f3c7be23 100644
--- a/localization/en-us.lua
+++ b/localization/en-us.lua
@@ -902,6 +902,7 @@ return {
 			b_singleplayer = "Singleplayer",
 			b_sp_with_ruleset = "Practice Mode",
 			b_practice = "Practice",
+			k_practice_collection_hint = "Click on any card to add it to your hand!",
 			k_unlimited_slots = "Unlimited Slots",
 			k_edition_cycling = "Edition Cycling (Q)",
 			b_join_lobby = "Join Lobby",
diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua
index cc4e5283..0bda11ad 100644
--- a/ui/game/game_state.lua
+++ b/ui/game/game_state.lua
@@ -681,6 +681,27 @@ function MP.UI.jimbo_say(text)
 	}))
 end
 
+-- Practice mode: spawn Jimbo when opening collection to hint at card spawning
+local practice_collection_jimbo = false
+
+local your_collection_ref = G.FUNCS.your_collection
+function G.FUNCS.your_collection(e)
+	your_collection_ref(e)
+	if MP.is_practice_mode() and not MP.LOBBY.code then
+		practice_collection_jimbo = true
+		MP.UI.create_jimbo(3, localize("k_practice_collection_hint"))
+	end
+end
+
+local exit_overlay_menu_ref_jimbo = G.FUNCS.exit_overlay_menu
+function G.FUNCS:exit_overlay_menu()
+	if practice_collection_jimbo then
+		practice_collection_jimbo = false
+		MP.UI.remove_jimbo()
+	end
+	exit_overlay_menu_ref_jimbo(self)
+end
+
 function MP.UI.remove_jimbo()
 	if not mp_jimbo then return end
 	local jimbo = mp_jimbo

From 7bf96b6f0dc2bf08044f8af4d0a96978d262d736 Mon Sep 17 00:00:00 2001
From: Stephen Kirk 
Date: Sat, 4 Apr 2026 22:08:07 +0200
Subject: [PATCH 04/15] Adjust practice collection Jimbo position and copy

Use position 2 for better alignment and give Jimbo a more
in-character, playful hint message.
---
 localization/en-us.lua | 2 +-
 ui/game/game_state.lua | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/localization/en-us.lua b/localization/en-us.lua
index f3c7be23..3ecb0f23 100644
--- a/localization/en-us.lua
+++ b/localization/en-us.lua
@@ -902,7 +902,7 @@ return {
 			b_singleplayer = "Singleplayer",
 			b_sp_with_ruleset = "Practice Mode",
 			b_practice = "Practice",
-			k_practice_collection_hint = "Click on any card to add it to your hand!",
+			k_practice_collection_hint = "Psst... click a card and it's yours. No questions asked!",
 			k_unlimited_slots = "Unlimited Slots",
 			k_edition_cycling = "Edition Cycling (Q)",
 			b_join_lobby = "Join Lobby",
diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua
index 0bda11ad..ceec46c9 100644
--- a/ui/game/game_state.lua
+++ b/ui/game/game_state.lua
@@ -689,7 +689,7 @@ function G.FUNCS.your_collection(e)
 	your_collection_ref(e)
 	if MP.is_practice_mode() and not MP.LOBBY.code then
 		practice_collection_jimbo = true
-		MP.UI.create_jimbo(3, localize("k_practice_collection_hint"))
+		MP.UI.create_jimbo(2, localize("k_practice_collection_hint"))
 	end
 end
 

From e9ab34f1ea5224981cc7607b9b85ce5e127773b4 Mon Sep 17 00:00:00 2001
From: Stephen Kirk 
Date: Sat, 4 Apr 2026 22:17:23 +0200
Subject: [PATCH 05/15] Only show practice collection Jimbo hint once per boot

---
 ui/game/game_state.lua | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua
index ceec46c9..12e6b3cb 100644
--- a/ui/game/game_state.lua
+++ b/ui/game/game_state.lua
@@ -681,13 +681,15 @@ function MP.UI.jimbo_say(text)
 	}))
 end
 
--- Practice mode: spawn Jimbo when opening collection to hint at card spawning
+-- Practice mode: spawn Jimbo when opening collection to hint at card spawning (once per boot)
 local practice_collection_jimbo = false
+local practice_collection_hint_shown = false
 
 local your_collection_ref = G.FUNCS.your_collection
 function G.FUNCS.your_collection(e)
 	your_collection_ref(e)
-	if MP.is_practice_mode() and not MP.LOBBY.code then
+	if MP.is_practice_mode() and not MP.LOBBY.code and not practice_collection_hint_shown then
+		practice_collection_hint_shown = true
 		practice_collection_jimbo = true
 		MP.UI.create_jimbo(2, localize("k_practice_collection_hint"))
 	end

From f64fc05f74124e6e6af093b8d868575506dd5a00 Mon Sep 17 00:00:00 2001
From: Stephen Kirk 
Date: Sun, 5 Apr 2026 13:27:08 +0200
Subject: [PATCH 06/15] Remove `log_to_ghost_replay` scripts for now

---
 tools/log_to_ghost_replay.html | 284 -------------
 tools/log_to_ghost_replay.py   | 732 ---------------------------------
 2 files changed, 1016 deletions(-)
 delete mode 100644 tools/log_to_ghost_replay.html
 delete mode 100644 tools/log_to_ghost_replay.py

diff --git a/tools/log_to_ghost_replay.html b/tools/log_to_ghost_replay.html
deleted file mode 100644
index 2342778e..00000000
--- a/tools/log_to_ghost_replay.html
+++ /dev/null
@@ -1,284 +0,0 @@
-
-
-
-
-
-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 deleted file mode 100644 index 01305ce3..00000000 --- a/tools/log_to_ghost_replay.py +++ /dev/null @@ -1,732 +0,0 @@ -#!/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() From 02439807ca9b0a5ab50202904f0bfd3821134abe Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 13:28:04 +0200 Subject: [PATCH 07/15] Remove MP.MATCH_RECORD code path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match recording (snapshot_ante, init, finalize) was dead code — written but never read. Replays will come from Lovely log files instead. Removes the table, all methods, and all call sites in action_handlers and game_state. --- lib/match_history.lua | 67 +--------------------------------- networking/action_handlers.lua | 20 ---------- ui/game/game_state.lua | 5 --- 3 files changed, 1 insertion(+), 91 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 6239464f..c8da7a65 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -1,69 +1,4 @@ --- 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 +-- Ghost Replay: load and play back ghost replays from log files. MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil } diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 6625311f..1656215a 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -241,10 +241,6 @@ local function action_start_game(seed, stake_str) 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() @@ -350,20 +346,6 @@ local function action_end_pvp() 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 @@ -390,7 +372,6 @@ 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 @@ -400,7 +381,6 @@ 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/ui/game/game_state.lua b/ui/game/game_state.lua index 12e6b3cb..4e1b6310 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -234,7 +234,6 @@ function Game:update_hand_played(dt) 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 @@ -249,7 +248,6 @@ function Game:update_hand_played(dt) 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 @@ -277,7 +275,6 @@ function Game:update_hand_played(dt) 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 @@ -299,7 +296,6 @@ function Game:update_hand_played(dt) 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 @@ -367,7 +363,6 @@ function Game:update_new_round(dt) 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 From 0607874b8910875d47a5f2063b2db53a69bd1925 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 13:30:43 +0200 Subject: [PATCH 08/15] Move MP.is_mp_or_ghost() from UI file to lib/match_history.lua Utility predicate doesn't belong in a UI monkey-patch file. Moved to lib/ where MP.GHOST lives, which loads before ui/. --- lib/match_history.lua | 4 ++++ ui/game/game_state.lua | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index c8da7a65..e86e8f52 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -1,5 +1,9 @@ -- Ghost Replay: load and play back ghost replays from log files. +function MP.is_mp_or_ghost() + return MP.LOBBY.code or MP.GHOST.is_active() +end + MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil } -- Per-ante playback state diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 4e1b6310..26195f5d 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -1,10 +1,6 @@ -- Contains function overrides (monkey-patches) for game state management -- Overrides Game methods like update_draw_to_hand, update_hand_played, update_new_round, etc. -function MP.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.is_mp_or_ghost() then From b677a0e600b2e00443a0176c9e8fdcae4996637e Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 13:32:52 +0200 Subject: [PATCH 09/15] Rename log_parser module table from M to LOG_PARSER More descriptive local name for the module table, consistent with how it's used by callers via pcall/load_mp_file. --- lib/log_parser.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/log_parser.lua b/lib/log_parser.lua index 06be8bc2..c1921e5d 100644 --- a/lib/log_parser.lua +++ b/lib/log_parser.lua @@ -1,7 +1,7 @@ -- Log Parser: parse Lovely log files into ghost replay tables. -- Lua port of tools/log_to_ghost_replay.py -local M = {} +local LOG_PARSER = {} ------------------------------------------------------------------------------- -- Helpers @@ -141,7 +141,7 @@ end -- Core parser ------------------------------------------------------------------------------- -function M.process_log(content) +function LOG_PARSER.process_log(content) local games = {} local game = new_game() local last_lobby_options = nil @@ -377,7 +377,7 @@ end -- Convert a parsed game record to a replay table (same shape as JSON replays) ------------------------------------------------------------------------------- -function M.to_replay(game) +function LOG_PARSER.to_replay(game) local snapshots = {} for ante, snap in pairs(game.ante_snapshots) do local snap_t = { @@ -433,4 +433,4 @@ function M.to_replay(game) return replay end -return M +return LOG_PARSER From 20b7adbc998d031e0040317569120a07ae702d68 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 13:36:19 +0200 Subject: [PATCH 10/15] Extract JSON replay loading to local function, make .log the main path The .json path is mainly for verifying the Lua log parser against the Python tool output. Extracted to load_json_replay() with a comment explaining its purpose. The .log branch is now checked first as the primary code path. --- lib/match_history.lua | 56 ++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index e86e8f52..fdc4f8e4 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -145,13 +145,35 @@ 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. +-- Load ghost replays from .log and .json files in the replays/ folder. +-- Files are read once when the picker is opened; drop a Lovely log or +-- a .json file into replays/ and it shows up in the ghost replay picker. -function MP.GHOST.load_folder_replays() +-- Load a .json replay file — useful for verifying log parser output or +-- loading replays exported from external tools. +local function load_json_replay(filepath, filename) local json = require("json") + local content = NFS.read(filepath) + if not content then return nil end + + local ok, replay = pcall(json.decode, content) + if not ok or not replay or not replay.ante_snapshots then + sendWarnMessage("Failed to parse replay: " .. filename, "MULTIPLAYER") + return nil + end + + -- 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 = filename + return replay +end + +function MP.GHOST.load_folder_replays() local log_parser = MP.load_mp_file("lib/log_parser.lua") local replays_dir = MP.path .. "/replays" local dir_info = NFS.getInfo(replays_dir) @@ -161,26 +183,7 @@ function MP.GHOST.load_folder_replays() 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 + if 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 @@ -201,6 +204,9 @@ function MP.GHOST.load_folder_replays() sendWarnMessage("Failed to parse log: " .. item.name, "MULTIPLAYER") end end + elseif item.type == "file" and item.name:match("%.json$") then + local replay = load_json_replay(replays_dir .. "/" .. item.name, item.name) + if replay then table.insert(results, replay) end end end From 2ca08f8f022180465ac93df02f75391cfc74bada Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 13:44:17 +0200 Subject: [PATCH 11/15] Move data helpers from ghost_replay_picker to lib/match_history Moved is_ruleset_supported, format_score, and build_label from the picker UI file to MP.GHOST in the lib layer where they belong. --- lib/match_history.lua | 41 ++++++++++++++++ .../play_button/ghost_replay_picker.lua | 49 ++----------------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index fdc4f8e4..119a3f3c 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -145,6 +145,47 @@ function MP.GHOST.is_active() return MP.GHOST.active and MP.GHOST.replay ~= nil end +function MP.GHOST.is_ruleset_supported(replay) + if not replay or not replay.ruleset then return true end + return MP.Rulesets[replay.ruleset] ~= nil +end + +function MP.GHOST.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 + +function MP.GHOST.build_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 + -- Load ghost replays from .log and .json files in the replays/ folder. -- Files are read once when the picker is opened; drop a Lovely log or -- a .json file into replays/ and it shows up in the ghost replay picker. diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 5522e2cd..19d5706e 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -40,7 +40,7 @@ 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 + if not MP.GHOST.is_ruleset_supported(replay) then return end MP.GHOST.load(replay) MP.GHOST.flipped = _preview_flipped @@ -87,35 +87,6 @@ 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 @@ -142,16 +113,6 @@ local function section_header(title, scale) } 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 @@ -277,8 +238,8 @@ local function build_stats_panel(r) 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 p_score = MP.GHOST.format_score(snap.player_score or 0) + local e_score = MP.GHOST.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) @@ -401,7 +362,7 @@ local function build_stats_panel(r) and (r.nemesis_name or "?") or (r.player_name or "?") - local supported = is_replay_ruleset_supported(r) + local supported = MP.GHOST.is_ruleset_supported(r) local action_nodes = {} if supported then @@ -543,7 +504,7 @@ function G.UIDEF.ghost_replay_picker() last_filename = nil end - local label = build_replay_label(r) + local label = MP.GHOST.build_label(r) local is_selected = (_preview_idx == i) local btn_colour if is_selected then From 59804df467f48e4929d7730d5db5ac87c6b6d450 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 14:00:17 +0200 Subject: [PATCH 12/15] Move practice mode logic from UI callbacks to lib/practice_mode.lua --- lib/practice_mode.lua | 56 ++++++++++++++++++ .../play_button/play_button_callbacks.lua | 57 ------------------- 2 files changed, 56 insertions(+), 57 deletions(-) create mode 100644 lib/practice_mode.lua diff --git a/lib/practice_mode.lua b/lib/practice_mode.lua new file mode 100644 index 00000000..0b950b5c --- /dev/null +++ b/lib/practice_mode.lua @@ -0,0 +1,56 @@ +-- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) +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_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 diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 56c6505c..db319dc9 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,10 +1,3 @@ --- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -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 @@ -32,56 +25,6 @@ function G.FUNCS.start_vanilla_sp(e) 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 From bbb7fbf6a4e142ddd5789fef0531e847a7ab52b8 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 15:15:22 +0200 Subject: [PATCH 13/15] Rename lib/match_history.lua to lib/ghost_replay.lua MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File no longer contains match recording — it's purely ghost replay load/playback logic now. --- .claude/plans/practice-mode-review.md | 105 ++++++++++++++++++++ lib/{match_history.lua => ghost_replay.lua} | 0 2 files changed, 105 insertions(+) create mode 100644 .claude/plans/practice-mode-review.md rename lib/{match_history.lua => ghost_replay.lua} (100%) diff --git a/.claude/plans/practice-mode-review.md b/.claude/plans/practice-mode-review.md new file mode 100644 index 00000000..8c020295 --- /dev/null +++ b/.claude/plans/practice-mode-review.md @@ -0,0 +1,105 @@ +# Practice Mode Review Pass + +## Progress + +- [x] **Step 1** — Remove `MP.MATCH_RECORD` code path *(commit 0243980)* +- [x] **Step 2** — Move `MP.is_mp_or_ghost()` out of UI file *(commit 0607874)* +- [x] **Step 3** — Rename `M` in `log_parser.lua` to `LOG_PARSER` *(commit b677a0e)* +- [x] **Step 4** — Extract JSON loading to local function in `match_history.lua` *(commit e8d976b)* +- [x] **Step 5** — Move data helpers from `ghost_replay_picker.lua` to `lib/match_history.lua` *(commit 2ca08f8)* +- [ ] **Step 6** — Move practice mode logic to its own `lib/` file +- [ ] **Step 7** — Update stale comments + +--- + +## Step 1 — Remove `MP.MATCH_RECORD` code path +**Comment #5** (match_history.lua:49-58) + +Snapshot/recording machinery is dead code — written but never read. Replays come from log files. + +- Remove `MP.MATCH_RECORD` table, `reset()`, `init()`, `snapshot_ante()`, `finalize()` +- Remove callers in `networking/action_handlers.lua` (init, snapshot_ante, 2x finalize) +- Remove callers in `ui/game/game_state.lua` (5x finalize) +- Update file header comment + +**Files:** `lib/match_history.lua`, `networking/action_handlers.lua`, `ui/game/game_state.lua` + +--- + +## Step 2 — Move `MP.is_mp_or_ghost()` out of UI file +**Comment #7** (game_state.lua:4-6) + +`MP.is_mp_or_ghost()` is a utility predicate — shouldn't live in a UI file that monkey-patches Game methods. + +- Move the function definition to `lib/match_history.lua` (where `MP.GHOST` lives) +- Verify load order: `match_history.lua` must be loaded before `game_state.lua` +- Grep all callers to confirm nothing breaks + +**Files:** `ui/game/game_state.lua`, `lib/match_history.lua` + +--- + +## Step 3 — Rename `M` in `log_parser.lua` to MP-namespace +**Comment #1** (log_parser.lua:4) + +The log parser uses `local M = {}` as its module table. Should use something in the MP namespace. + +- Propose a few name options to the user before changing +- Replace `M` throughout the file once agreed +- Check callers (`MP.load_mp_file("lib/log_parser.lua")` returns the module — callers may not care about the internal name, but verify) + +**Files:** `lib/log_parser.lua` + +--- + +## Step 4 — Extract JSON loading to local function in `match_history.lua` +**Comment #4** (match_history.lua:214-266) + +The `.log` path should be the main code path. The `.json` path is mostly for verifying the Lua log parser produces the same output as the Python tool. + +- Extract the `.json` loading branch into a `local function load_json_replay(filepath, filename)` +- Add a comment explaining it's for verification against the Python tool +- Keep `.log` path as the primary inline code + +**Files:** `lib/match_history.lua` + +--- + +## Step 5 — Move data-loading logic from picker to `lib/match_history.lua` +**Comment #8** (ghost_replay_picker.lua:1-3) + +Some non-UI logic in the picker should live in the lib layer. + +- Identify functions in `ghost_replay_picker.lua` that are data-processing (not UI) +- Move them to `lib/match_history.lua` +- Picker keeps only UI layout and event handlers + +**Files:** `ui/main_menu/play_button/ghost_replay_picker.lua`, `lib/match_history.lua` + +--- + +## Step 6 — Move practice mode logic to its own `lib/` file +**Comment #6** (play_button_callbacks.lua:2-6) + +`MP.SP`, `MP.is_practice_mode()`, and setup/start logic should live in `lib/practice_mode.lua`, not a UI callbacks file. Also clarify the naming situation (`MP.SP`, `MP.GHOST`, `MP.SP.practice`, `MP.is_practice_mode()`). + +- Create `lib/practice_mode.lua` +- Move `MP.SP` table definition + `MP.is_practice_mode()` there +- Move `G.FUNCS.setup_practice_mode()` and `G.FUNCS.start_practice_run()` there +- Move `G.FUNCS.toggle_unlimited_slots()` there +- Wire up in `core.lua` load order +- Consider renaming / documenting the SP/GHOST/practice relationship + +**Files:** `lib/practice_mode.lua` (new), `ui/main_menu/play_button/play_button_callbacks.lua`, `core.lua` + +--- + +## Step 7 — Update stale comments +**Comments #2 and #3** (match_history.lua:1-12, match_history.lua:209-212) + +After all structural changes, update comments to reflect reality. + +- Rewrite file header for `match_history.lua` (no longer about "data capture" / persistence) +- Update the `load_folder_replays` comment (no longer just JSON; log files are primary) + +**Files:** `lib/match_history.lua` diff --git a/lib/match_history.lua b/lib/ghost_replay.lua similarity index 100% rename from lib/match_history.lua rename to lib/ghost_replay.lua From b413a475a9470d359ff8fd15828786b39e6d8f07 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 16:21:45 +0200 Subject: [PATCH 14/15] Extract ghost PvP resolution logic from game_state.lua to lib/ghost_replay.lua Move inline ghost replay branching into focused helpers: - resolve_pvp_hands_exhausted, resolve_pvp_mid_hand, resolve_round_fail - get_blind_name_ui, current_target, _start_advance_sequence --- lib/ghost_replay.lua | 130 +++++++++++++++++++++++++++++++++++++++++ ui/game/game_state.lua | 111 +++++------------------------------ 2 files changed, 146 insertions(+), 95 deletions(-) diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index 119a3f3c..1279f982 100644 --- a/lib/ghost_replay.lua +++ b/lib/ghost_replay.lua @@ -141,6 +141,136 @@ function MP.GHOST.get_nemesis_name() end end +-- Returns a UI string table for the PvP blind name. +-- Uses a static { string = ... } entry (ghost name is fixed for the run), +-- unlike live MP which uses { ref_table, ref_value } for reactive updates. +function MP.GHOST.get_blind_name_ui() + return { { string = MP.GHOST.get_nemesis_name() } } +end + +-- Compute the ghost's target score for the current ante. +-- Prefers per-hand data when available, falls back to aggregate enemy score. +function MP.GHOST.current_target() + if MP.GHOST.has_hand_data() then + return MP.GHOST.current_target_big() + end + local es = MP.GAME.enemy.score + return to_big(es.coeffiocient * (10 ^ es.exponent)) +end + +-- Resolve the end of a PvP round when the player has no hands left. +-- Returns "won", "game_over", or "continue". +function MP.GHOST.resolve_pvp_hands_exhausted(chips) + local beat_current = to_big(chips) >= MP.GHOST.current_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 + return "won" + 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 + return "game_over" + end + end + MP.GAME.end_pvp = true + return "continue" +end + +-- Resolve mid-hand state when the player still has hands remaining. +-- Checks whether the player has already beaten all ghost hands; if so, takes +-- an enemy life. If the ghost has more hands, kicks off the advance animation. +-- Returns true if the PvP round ended (win or end_pvp set). +function MP.GHOST.resolve_pvp_mid_hand(chips) + if not MP.GHOST.has_hand_data() then return false end + + local beat_current = to_big(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 + win_game() + return true + end + MP.GAME.end_pvp = true + return true + elseif beat_current and not MP.GHOST.playback_exhausted() and not MP.GHOST._advancing then + MP.GHOST._start_advance_sequence() + end + return false +end + +-- Animate advancing through remaining ghost hands until the player's score +-- no longer beats the ghost, or all ghost hands are exhausted. +function MP.GHOST._start_advance_sequence() + MP.GHOST._advancing = true + local function step() + MP.GHOST.advance_hand() + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.6, + func = function() + if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() then + step() + else + 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 + win_game() + MP.GHOST._advancing = false + return true + end + MP.GAME.end_pvp = true + end + MP.GHOST._advancing = false + end + return true + end, + })) + end + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.5, + func = function() + step() + return true + end, + })) +end + +-- Handle life loss when the player fails a non-PvP round in ghost mode. +-- Returns "game_over" or nil. +function MP.GHOST.resolve_round_fail() + 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 + return "game_over" + end + end + return nil +end + function MP.GHOST.is_active() return MP.GHOST.active and MP.GHOST.replay ~= nil end diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 26195f5d..3d7e9054 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -28,9 +28,11 @@ function Game:update_draw_to_hand(dt) delay = 0.45, blockable = false, func = function() + -- Ghost uses static { string = ... } (name is fixed for the run); + -- live MP uses { ref_table, ref_value } so the HUD reacts to name changes. local blind_name_string if MP.GHOST.is_active() then - blind_name_string = { { string = MP.GHOST.get_nemesis_name() } } + blind_name_string = MP.GHOST.get_blind_name_ui() else blind_name_string = { { @@ -216,40 +218,15 @@ function Game:update_hand_played(dt) if G.GAME.current_round.hands_left < 1 then 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 - 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 - G.STATE = G.STATES.GAME_OVER - G.STATE_COMPLETE = false - return true - end + local result = MP.GHOST.resolve_pvp_hands_exhausted(G.GAME.chips) + if result == "won" then + win_game() + return true + elseif result == "game_over" then + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return true end - MP.GAME.end_pvp = true else attention_text({ scale = 0.8, @@ -265,56 +242,7 @@ function Game:update_hand_played(dt) 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 - 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 - local function ghost_advance_step() - MP.GHOST.advance_hand() - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "after", - delay = 0.6, - func = function() - if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() then - ghost_advance_step() - else - 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 - win_game() - MP.GHOST._advancing = false - return true - end - MP.GAME.end_pvp = true - end - MP.GHOST._advancing = false - end - return true - end, - })) - end - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "after", - delay = 0.5, - func = function() - ghost_advance_step() - return true - end, - })) - end + MP.GHOST.resolve_pvp_mid_hand(G.GAME.chips) if not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then G.STATE_COMPLETE = false @@ -352,17 +280,10 @@ function Game:update_new_round(dt) if to_big(G.GAME.chips) < to_big(G.GAME.blind.chips) and not MP.is_pvp_boss() then G.GAME.blind.chips = -1 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 - G.STATE = G.STATES.GAME_OVER - G.STATE_COMPLETE = false - return - end + if MP.GHOST.resolve_round_fail() == "game_over" then + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return end else MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival") From b3cbd279b7904c73560c779cc86c16de772a796b Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 5 Apr 2026 17:36:30 +0200 Subject: [PATCH 15/15] Remove aggregate score fallback from ghost replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All replay sources now emit per-hand data. The fallback path that set a static enemy score from ante-level aggregates was vestigial. Removes get_enemy_score(), the else-branch in init_playback(), and the current_target() wrapper — its sole callsite now uses current_target_big() directly. --- lib/ghost_replay.lua | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index 1279f982..53b87ea0 100644 --- a/lib/ghost_replay.lua +++ b/lib/ghost_replay.lua @@ -49,15 +49,6 @@ function MP.GHOST.get_enemy_hands(ante) 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 @@ -70,14 +61,8 @@ function MP.GHOST.init_playback(ante) 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 + return false end function MP.GHOST.advance_hand() @@ -148,20 +133,10 @@ function MP.GHOST.get_blind_name_ui() return { { string = MP.GHOST.get_nemesis_name() } } end --- Compute the ghost's target score for the current ante. --- Prefers per-hand data when available, falls back to aggregate enemy score. -function MP.GHOST.current_target() - if MP.GHOST.has_hand_data() then - return MP.GHOST.current_target_big() - end - local es = MP.GAME.enemy.score - return to_big(es.coeffiocient * (10 ^ es.exponent)) -end - -- Resolve the end of a PvP round when the player has no hands left. -- Returns "won", "game_over", or "continue". function MP.GHOST.resolve_pvp_hands_exhausted(chips) - local beat_current = to_big(chips) >= MP.GHOST.current_target() + local beat_current = to_big(chips) >= MP.GHOST.current_target_big() local all_exhausted = MP.GHOST.playback_exhausted() if beat_current and all_exhausted then