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/.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/ghost_replay.lua b/lib/ghost_replay.lua new file mode 100644 index 00000000..53b87ea0 --- /dev/null +++ b/lib/ghost_replay.lua @@ -0,0 +1,365 @@ +-- 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 +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 + +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 + end + return false +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 + +-- 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 + +-- 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_big() + 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 + +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. + +-- 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) + 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("%.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 + 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 + + -- 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/lib/log_parser.lua b/lib/log_parser.lua new file mode 100644 index 00000000..c1921e5d --- /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 LOG_PARSER = {} + +------------------------------------------------------------------------------- +-- 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 LOG_PARSER.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 LOG_PARSER.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 LOG_PARSER 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/localization/en-us.lua b/localization/en-us.lua index 8df7f5fc..3ecb0f23 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -901,6 +901,10 @@ return { dictionary = { b_singleplayer = "Singleplayer", b_sp_with_ruleset = "Practice Mode", + b_practice = "Practice", + 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", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", @@ -1095,6 +1099,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..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]]
@@ -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.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}}
+    }}
+}} 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..1656215a 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,7 @@ 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
+
 end
 
 local function begin_pvp_blind()
@@ -340,6 +345,7 @@ local function action_end_pvp()
 	MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
 	MP.GAME.timer_started = false
 	MP.GAME.ready_blind = false
+
 end
 
 ---@param lives number
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/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..3d7e9054 100644
--- a/ui/game/game_state.lua
+++ b/ui/game/game_state.lua
@@ -3,7 +3,7 @@
 
 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 +28,20 @@ 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",
-									},
-								}
+								-- 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 = MP.GHOST.get_blind_name_ui()
+								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 +77,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 +192,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 +212,42 @@ 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 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
+					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
+					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
+						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 +274,28 @@ 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.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")
+					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 +324,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 +341,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 +388,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 +414,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 +429,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
@@ -548,6 +593,29 @@ function MP.UI.jimbo_say(text)
 	}))
 end
 
+-- 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 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
+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
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..19d5706e
--- /dev/null
+++ b/ui/main_menu/play_button/ghost_replay_picker.lua
@@ -0,0 +1,639 @@
+-- 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 MP.GHOST.is_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 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 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 = 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)
+				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 = MP.GHOST.is_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 = MP.GHOST.build_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..db319dc9 100644
--- a/ui/main_menu/play_button/play_button_callbacks.lua
+++ b/ui/main_menu/play_button/play_button_callbacks.lua
@@ -1,11 +1,10 @@
--- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer)
-MP.SP = { ruleset = nil }
-
 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,6 +20,8 @@ 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
 
@@ -92,6 +93,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,
 			},
 		},
 	}