diff --git a/.gitignore b/.gitignore
index ae5a6e0e..688cd47a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@
.vs
.DS_Store
.env
+replays/*
+!replays/.gitkeep
diff --git a/config.lua b/config.lua
index 3a036cae..50922d1d 100644
--- a/config.lua
+++ b/config.lua
@@ -13,4 +13,5 @@ return {
["preview"] = {},
["joker_stats"] = {},
["match_history"] = {},
+ ["ghost_replays"] = {},
}
diff --git a/core.lua b/core.lua
index 8391e4a9..665890e8 100644
--- a/core.lua
+++ b/core.lua
@@ -97,7 +97,12 @@ MP.SMODS_VERSION = "1.0.0~BETA-1503a"
MP.REQUIRED_LOVELY_VERSION = "0.9"
function MP.should_use_the_order()
- return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code
+ if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code then
+ return true
+ elseif MP.is_practice_mode() then -- should actually check the ruleset but okay for now
+ return true
+ end
+ return false
end
function MP.is_major_league_ruleset()
diff --git a/lib/log_parser.lua b/lib/log_parser.lua
new file mode 100644
index 00000000..06be8bc2
--- /dev/null
+++ b/lib/log_parser.lua
@@ -0,0 +1,436 @@
+-- Log Parser: parse Lovely log files into ghost replay tables.
+-- Lua port of tools/log_to_ghost_replay.py
+
+local M = {}
+
+-------------------------------------------------------------------------------
+-- Helpers
+-------------------------------------------------------------------------------
+
+local function parse_timestamp(line)
+ return line:match("(%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d)")
+end
+
+local function parse_sent_json(line)
+ local raw = line:match("Client sent message: ({.*})%s*$")
+ if not raw then return nil end
+ local json = require("json")
+ local ok, obj = pcall(json.decode, raw)
+ if ok and type(obj) == "table" then return obj end
+ return nil
+end
+
+local function parse_sent_kv(line)
+ local raw = line:match("Client sent message: (action:%w+.-)%s*$")
+ if not raw then return nil end
+ local pairs_t = {}
+ for part in raw:gmatch("[^,]+") do
+ local k, v = part:match("^%s*(.-):%s*(.-)%s*$")
+ if k then pairs_t[k] = v end
+ end
+ return pairs_t
+end
+
+local function parse_got_kv(line)
+ local action, kv_str = line:match("Client got (%w+) message:%s*(.-)%s*$")
+ if not action then return nil end
+ local pairs_t = {}
+ for key, val in kv_str:gmatch("%((%w+):%s*([^)]*)%)") do
+ val = val:match("^%s*(.-)%s*$")
+ if val == "true" then
+ pairs_t[key] = true
+ elseif val == "false" then
+ pairs_t[key] = false
+ elseif val:match("^%-?%d+%.%d+$") then
+ pairs_t[key] = tonumber(val)
+ elseif val:match("^%-?%d+$") then
+ pairs_t[key] = tonumber(val)
+ else
+ pairs_t[key] = val
+ end
+ end
+ return action, pairs_t
+end
+
+local function parse_joker_list_full(raw)
+ local jokers = {}
+ for entry in raw:gmatch("[^;]+") do
+ entry = entry:match("^%s*(.-)%s*$")
+ if entry ~= "" then
+ local parts = {}
+ for p in entry:gmatch("[^%-]+") do parts[#parts + 1] = p end
+ local joker = { key = parts[1] }
+ if parts[2] and parts[2] ~= "none" then joker.edition = parts[2] end
+ if parts[3] and parts[3] ~= "none" then joker.sticker1 = parts[3] end
+ if parts[4] and parts[4] ~= "none" then joker.sticker2 = parts[4] end
+ jokers[#jokers + 1] = joker
+ end
+ end
+ return jokers
+end
+
+-------------------------------------------------------------------------------
+-- Game record (fresh state)
+-------------------------------------------------------------------------------
+
+local function new_game()
+ return {
+ seed = nil,
+ ruleset = nil,
+ gamemode = nil,
+ deck = nil,
+ stake = nil,
+ player_name = nil,
+ nemesis_name = nil,
+ starting_lives = 4,
+ is_host = nil,
+ lobby_code = nil,
+ ante_snapshots = {},
+ winner = nil,
+ final_ante = 1,
+ current_ante = 0,
+ player_lives = 4,
+ enemy_lives = 4,
+ -- PvP round tracking (transient)
+ pvp_player_score = "0",
+ pvp_enemy_score = "0",
+ pvp_hands = {},
+ in_pvp = false,
+ -- End-game data
+ player_jokers = {},
+ nemesis_jokers = {},
+ player_stats = {},
+ nemesis_stats = {},
+ -- Per-ante shop spending
+ shop_spending = {},
+ -- Non-PvP round failures
+ failed_rounds = {},
+ -- Timing
+ game_start_ts = nil,
+ game_end_ts = nil,
+ -- Card activity
+ cards_bought = {},
+ cards_sold = {},
+ cards_used = {},
+ }
+end
+
+-------------------------------------------------------------------------------
+-- Duration helper
+-------------------------------------------------------------------------------
+
+local function ts_to_epoch(ts_str)
+ local y, mo, d, h, mi, s = ts_str:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)")
+ if not y then return nil end
+ return os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d),
+ hour = tonumber(h), min = tonumber(mi), sec = tonumber(s) })
+end
+
+local function format_duration(start_ts, end_ts)
+ local t0 = ts_to_epoch(start_ts)
+ local t1 = ts_to_epoch(end_ts)
+ if not t0 or not t1 then return nil end
+ local secs = t1 - t0
+ if secs < 0 then return nil end
+ local mins = math.floor(secs / 60)
+ local s = secs % 60
+ return string.format("%dm%02ds", mins, s)
+end
+
+-------------------------------------------------------------------------------
+-- Core parser
+-------------------------------------------------------------------------------
+
+function M.process_log(content)
+ local games = {}
+ local game = new_game()
+ local last_lobby_options = nil
+ local in_game = false
+
+ for line in content:gmatch("[^\r\n]+") do
+ if line:find("MULTIPLAYER", 1, true) then
+ local ts = parse_timestamp(line)
+
+ -- Direct log messages (not Client sent/got)
+ if line:find("Sending end game jokers:", 1, true) then
+ local raw = line:match("Sending end game jokers:%s*(.-)%s*$")
+ if raw then game.player_jokers = parse_joker_list_full(raw) end
+ goto continue
+ end
+
+ if line:find("Received end game jokers:", 1, true) then
+ local raw = line:match("Received end game jokers:%s*(.-)%s*$")
+ if raw then game.nemesis_jokers = parse_joker_list_full(raw) end
+ goto continue
+ end
+
+ -- Sent messages (JSON)
+ local sent = parse_sent_json(line)
+ if sent then
+ local action = sent.action
+
+ if action == "username" then
+ game.player_name = sent.username
+
+ elseif action == "lobbyOptions" then
+ last_lobby_options = sent
+
+ elseif action == "setAnte" then
+ local ante = sent.ante or 0
+ game.current_ante = ante
+ if ante > game.final_ante then game.final_ante = ante end
+
+ elseif action == "playHand" then
+ local score = tostring(sent.score or "0")
+ local hands_left = sent.handsLeft or 0
+ if game.in_pvp then
+ game.pvp_player_score = score
+ game.pvp_hands[#game.pvp_hands + 1] = {
+ score = score,
+ hands_left = hands_left,
+ side = "player",
+ }
+ end
+
+ elseif action == "setLocation" then
+ local loc = sent.location or ""
+ if loc:find("bl_mp_nemesis", 1, true) then
+ game.in_pvp = true
+ end
+
+ elseif action == "failRound" then
+ game.failed_rounds[#game.failed_rounds + 1] = game.current_ante
+
+ elseif action == "spentLastShop" then
+ local amount = sent.amount or 0
+ game.shop_spending[game.current_ante] =
+ (game.shop_spending[game.current_ante] or 0) + amount
+
+ elseif action == "nemesisEndGameStats" then
+ local stats = {}
+ for k, v in pairs(sent) do
+ if k ~= "action" then stats[k] = v end
+ end
+ game.player_stats = stats
+
+ elseif action == "startGame" then
+ if ts then game.game_start_ts = game.game_start_ts or ts end
+ end
+
+ goto continue
+ end
+
+ -- Sent messages (key:value format — card activity)
+ local sent_kv = parse_sent_kv(line)
+ if sent_kv then
+ local action = sent_kv.action
+ if action == "boughtCardFromShop" then
+ game.cards_bought[#game.cards_bought + 1] = {
+ card = sent_kv.card or "",
+ cost = tonumber(sent_kv.cost) or 0,
+ ante = game.current_ante,
+ }
+ elseif action == "soldCard" then
+ game.cards_sold[#game.cards_sold + 1] = {
+ card = sent_kv.card or "",
+ ante = game.current_ante,
+ }
+ elseif action == "usedCard" then
+ game.cards_used[#game.cards_used + 1] = {
+ card = sent_kv.card or "",
+ ante = game.current_ante,
+ }
+ end
+ goto continue
+ end
+
+ -- Received messages (key-value)
+ local action, kv = parse_got_kv(line)
+ if not action then goto continue end
+
+ if action == "joinedLobby" then
+ if kv.code then game.lobby_code = tostring(kv.code) end
+
+ elseif action == "lobbyInfo" then
+ if kv.isHost ~= nil then game.is_host = kv.isHost end
+ if game.is_host == true and kv.guest then
+ game.nemesis_name = tostring(kv.guest)
+ elseif game.is_host == false and kv.host then
+ game.nemesis_name = tostring(kv.host)
+ end
+
+ elseif action == "startGame" then
+ in_game = true
+ if ts then game.game_start_ts = game.game_start_ts or ts end
+ if last_lobby_options then
+ game.ruleset = last_lobby_options.ruleset
+ game.gamemode = last_lobby_options.gamemode
+ game.deck = last_lobby_options.back or "Red Deck"
+ game.stake = last_lobby_options.stake or 1
+ game.starting_lives = last_lobby_options.starting_lives or 4
+ game.player_lives = game.starting_lives
+ game.enemy_lives = game.starting_lives
+ end
+
+ elseif action == "playerInfo" then
+ if kv.lives then game.player_lives = kv.lives end
+
+ elseif action == "enemyInfo" then
+ if kv.lives then game.enemy_lives = kv.lives end
+ if kv.score then
+ local score_str = tostring(kv.score)
+ if game.in_pvp then
+ game.pvp_enemy_score = score_str
+ game.pvp_hands[#game.pvp_hands + 1] = {
+ score = score_str,
+ hands_left = kv.handsLeft or 0,
+ side = "enemy",
+ }
+ end
+ end
+
+ elseif action == "enemyLocation" then
+ local loc = kv.location or ""
+ if tostring(loc):find("bl_mp_nemesis", 1, true) then
+ game.in_pvp = true
+ end
+
+ elseif action == "endPvP" then
+ local lost = kv.lost
+ local result = lost and "loss" or "win"
+
+ -- Clean up hand progression
+ local cleaned = {}
+ local seen_final = {}
+ for _, h in ipairs(game.pvp_hands) do
+ if h.score == "0" and h.hands_left >= 4 then
+ -- skip initial zero-score entries
+ elseif #cleaned > 0
+ and cleaned[#cleaned].score == h.score
+ and cleaned[#cleaned].side == h.side then
+ -- deduplicate consecutive same-score same-side
+ elseif seen_final[h.side] and h.score == seen_final[h.side] then
+ -- skip re-broadcast of final score
+ else
+ if h.hands_left == 0 then
+ seen_final[h.side] = h.score
+ end
+ cleaned[#cleaned + 1] = h
+ end
+ end
+
+ game.ante_snapshots[game.current_ante] = {
+ ante = game.current_ante,
+ player_score = game.pvp_player_score,
+ enemy_score = game.pvp_enemy_score,
+ player_lives = game.player_lives,
+ enemy_lives = game.enemy_lives,
+ result = result,
+ hands = cleaned,
+ }
+
+ -- Reset PvP tracking
+ game.in_pvp = false
+ game.pvp_player_score = "0"
+ game.pvp_enemy_score = "0"
+ game.pvp_hands = {}
+
+ elseif action == "winGame" then
+ game.winner = "player"
+
+ elseif action == "loseGame" then
+ game.winner = "nemesis"
+
+ elseif action == "nemesisEndGameStats" then
+ local stats = {}
+ for k, v in pairs(kv) do
+ if k ~= "action" then stats[k] = v end
+ end
+ game.nemesis_stats = stats
+
+ elseif action == "stopGame" then
+ if kv.seed then game.seed = tostring(kv.seed) end
+ if ts then game.game_end_ts = ts end
+ if in_game then
+ games[#games + 1] = game
+ end
+ in_game = false
+ local prev_name = game.player_name
+ game = new_game()
+ game.player_name = prev_name
+ last_lobby_options = nil
+ end
+
+ ::continue::
+ end
+ end
+
+ -- Capture mid-game record if log ends without stopGame
+ if in_game and game.winner then
+ games[#games + 1] = game
+ end
+
+ return games
+end
+
+-------------------------------------------------------------------------------
+-- Convert a parsed game record to a replay table (same shape as JSON replays)
+-------------------------------------------------------------------------------
+
+function M.to_replay(game)
+ local snapshots = {}
+ for ante, snap in pairs(game.ante_snapshots) do
+ local snap_t = {
+ player_score = snap.player_score,
+ enemy_score = snap.enemy_score,
+ player_lives = snap.player_lives,
+ enemy_lives = snap.enemy_lives,
+ result = snap.result,
+ }
+ if snap.hands and #snap.hands > 0 then
+ snap_t.hands = {}
+ for _, h in ipairs(snap.hands) do
+ snap_t.hands[#snap_t.hands + 1] = {
+ score = h.score,
+ hands_left = h.hands_left,
+ side = h.side,
+ }
+ end
+ end
+ snapshots[ante] = snap_t
+ end
+
+ local replay = {
+ gamemode = game.gamemode or "gamemode_mp_attrition",
+ final_ante = game.final_ante,
+ ante_snapshots = snapshots,
+ winner = game.winner or "unknown",
+ timestamp = os.time(),
+ ruleset = game.ruleset or "ruleset_mp_blitz",
+ seed = game.seed or "UNKNOWN",
+ deck = game.deck or "Red Deck",
+ stake = game.stake or 1,
+ }
+ if game.player_name then replay.player_name = game.player_name end
+ if game.nemesis_name then replay.nemesis_name = game.nemesis_name end
+ if game.lobby_code then replay.lobby_code = game.lobby_code end
+
+ if game.game_start_ts and game.game_end_ts then
+ local dur = format_duration(game.game_start_ts, game.game_end_ts)
+ if dur then replay.duration = dur end
+ end
+
+ if #game.player_jokers > 0 then replay.player_jokers = game.player_jokers end
+ if #game.nemesis_jokers > 0 then replay.nemesis_jokers = game.nemesis_jokers end
+ if next(game.player_stats) then replay.player_stats = game.player_stats end
+ if next(game.nemesis_stats) then replay.nemesis_stats = game.nemesis_stats end
+ if next(game.shop_spending) then replay.shop_spending = game.shop_spending end
+ if #game.failed_rounds > 0 then replay.failed_rounds = game.failed_rounds end
+ if #game.cards_bought > 0 then replay.cards_bought = game.cards_bought end
+ if #game.cards_sold > 0 then replay.cards_sold = game.cards_sold end
+ if #game.cards_used > 0 then replay.cards_used = game.cards_used end
+
+ return replay
+end
+
+return M
diff --git a/lib/match_history.lua b/lib/match_history.lua
new file mode 100644
index 00000000..6239464f
--- /dev/null
+++ b/lib/match_history.lua
@@ -0,0 +1,274 @@
+-- Ghost Replay data capture
+--
+-- Records every match you play: seed, ruleset, gamemode, deck, stake, and
+-- per-ante snapshots of both players' scores and lives. Persisted to config
+-- so the data survives between sessions (last 20 matches kept).
+--
+-- What this enables:
+-- - Replay any seed you already played in practice mode,
+-- with your opponent's scores as ghost data
+-- - Practice mode can load a ghost replay and use the recorded enemy scores
+-- as the PvP blind target, so you're "playing against" a past opponent
+-- - Match history / post-game review: see how scores diverged ante by ante
+
+MP.MATCH_RECORD = {
+ seed = nil,
+ ruleset = nil,
+ gamemode = nil,
+ deck = nil,
+ player_name = nil,
+ nemesis_name = nil,
+ ante_snapshots = {},
+ winner = nil,
+ final_ante = nil,
+}
+
+function MP.MATCH_RECORD.reset()
+ MP.MATCH_RECORD.seed = nil
+ MP.MATCH_RECORD.ruleset = nil
+ MP.MATCH_RECORD.gamemode = nil
+ MP.MATCH_RECORD.deck = nil
+ MP.MATCH_RECORD.player_name = nil
+ MP.MATCH_RECORD.nemesis_name = nil
+ MP.MATCH_RECORD.ante_snapshots = {}
+ MP.MATCH_RECORD.winner = nil
+ MP.MATCH_RECORD.final_ante = nil
+end
+
+function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck, stake, player_name, nemesis_name)
+ MP.MATCH_RECORD.reset()
+ MP.MATCH_RECORD.seed = seed
+ MP.MATCH_RECORD.ruleset = ruleset
+ MP.MATCH_RECORD.gamemode = gamemode
+ MP.MATCH_RECORD.deck = deck
+ MP.MATCH_RECORD.stake = stake
+ MP.MATCH_RECORD.player_name = player_name
+ MP.MATCH_RECORD.nemesis_name = nemesis_name
+end
+
+function MP.MATCH_RECORD.snapshot_ante(ante, data)
+ MP.MATCH_RECORD.ante_snapshots[ante] = {
+ player_score = data.player_score,
+ enemy_score = data.enemy_score,
+ player_lives = data.player_lives,
+ enemy_lives = data.enemy_lives,
+ blind_key = data.blind_key,
+ result = data.result,
+ }
+end
+
+function MP.MATCH_RECORD.finalize(won)
+ -- Don't save ghost practice games as new replays
+ if MP.is_practice_mode() then return end
+
+ MP.MATCH_RECORD.winner = won and "player" or "nemesis"
+ MP.MATCH_RECORD.final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1
+end
+
+MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil }
+
+-- Per-ante playback state
+MP.GHOST._hands = {}
+MP.GHOST._hand_idx = 0
+MP.GHOST._advancing = false
+
+function MP.GHOST.load(replay)
+ MP.GHOST.active = true
+ MP.GHOST.replay = replay
+ MP.GHOST.flipped = false
+ MP.GHOST.gamemode = replay and replay.gamemode or nil
+ MP.GHOST._hands = {}
+ MP.GHOST._hand_idx = 0
+ MP.GHOST._advancing = false
+end
+
+function MP.GHOST.clear()
+ MP.GHOST.active = false
+ MP.GHOST.replay = nil
+ MP.GHOST.flipped = false
+ MP.GHOST.gamemode = nil
+ MP.GHOST._hands = {}
+ MP.GHOST._hand_idx = 0
+ MP.GHOST._advancing = false
+end
+
+function MP.GHOST.flip()
+ MP.GHOST.flipped = not MP.GHOST.flipped
+end
+
+function MP.GHOST.get_enemy_hands(ante)
+ if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return {} end
+ local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)]
+ if not snapshot or not snapshot.hands then return {} end
+ local enemy_side = MP.GHOST.flipped and "player" or "enemy"
+ local out = {}
+ for _, h in ipairs(snapshot.hands) do
+ if h.side == enemy_side then
+ out[#out + 1] = h
+ end
+ end
+ return out
+end
+
+-- Fallback for replays without hand-level data
+function MP.GHOST.get_enemy_score(ante)
+ if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return nil end
+ local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)]
+ if not snapshot then return nil end
+ local key = MP.GHOST.flipped and "player_score" or "enemy_score"
+ return snapshot[key]
+end
+
+function MP.GHOST.init_playback(ante)
+ local hands = MP.GHOST.get_enemy_hands(ante)
+ MP.GHOST._hands = hands
+ MP.GHOST._hand_idx = 0
+ MP.GHOST._advancing = false
+ if #hands > 0 then
+ MP.GHOST._hand_idx = 1
+ local score = MP.INSANE_INT.from_string(hands[1].score)
+ MP.GAME.enemy.score = score
+ MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score)
+ MP.GAME.enemy.hands = hands[1].hands_left or 0
+ return true
+ else
+ local score_str = MP.GHOST.get_enemy_score(ante)
+ if score_str then
+ MP.GAME.enemy.score = MP.INSANE_INT.from_string(score_str)
+ MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score)
+ end
+ return false
+ end
+end
+
+function MP.GHOST.advance_hand()
+ if MP.GHOST._hand_idx >= #MP.GHOST._hands then return false end
+ MP.GHOST._hand_idx = MP.GHOST._hand_idx + 1
+ local entry = MP.GHOST._hands[MP.GHOST._hand_idx]
+ local score = MP.INSANE_INT.from_string(entry.score)
+
+ G.E_MANAGER:add_event(Event({
+ blockable = false, blocking = false,
+ trigger = "ease", delay = 0.5,
+ ref_table = MP.GAME.enemy.score,
+ ref_value = "e_count",
+ ease_to = score.e_count,
+ func = function(t) return math.floor(t) end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ blockable = false, blocking = false,
+ trigger = "ease", delay = 0.5,
+ ref_table = MP.GAME.enemy.score,
+ ref_value = "coeffiocient",
+ ease_to = score.coeffiocient,
+ func = function(t) return math.floor(t) end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ blockable = false, blocking = false,
+ trigger = "ease", delay = 0.5,
+ ref_table = MP.GAME.enemy.score,
+ ref_value = "exponent",
+ ease_to = score.exponent,
+ func = function(t) return math.floor(t) end,
+ }))
+
+ MP.GAME.enemy.hands = entry.hands_left or 0
+ if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
+ return true
+end
+
+function MP.GHOST.playback_exhausted()
+ return #MP.GHOST._hands == 0 or MP.GHOST._hand_idx >= #MP.GHOST._hands
+end
+
+function MP.GHOST.has_hand_data()
+ return #MP.GHOST._hands > 0
+end
+
+-- Reads target from hands array directly, bypassing the eased score table.
+function MP.GHOST.current_target_big()
+ if MP.GHOST._hand_idx < 1 or MP.GHOST._hand_idx > #MP.GHOST._hands then return to_big(0) end
+ local entry = MP.GHOST._hands[MP.GHOST._hand_idx]
+ local score = MP.INSANE_INT.from_string(entry.score)
+ return to_big(score.coeffiocient * (10 ^ score.exponent))
+end
+
+function MP.GHOST.get_nemesis_name()
+ if not MP.GHOST.replay then return nil end
+ if MP.GHOST.flipped then
+ return MP.GHOST.replay.player_name or localize("k_ghost")
+ else
+ return MP.GHOST.replay.nemesis_name or localize("k_ghost")
+ end
+end
+
+function MP.GHOST.is_active()
+ return MP.GHOST.active and MP.GHOST.replay ~= nil
+end
+
+-- Load ghost replays from JSON files in the replays/ folder.
+-- Files are read once when the picker is opened; drop a .json file
+-- generated by tools/log_to_ghost_replay.py into replays/ and it
+-- shows up alongside config-stored replays.
+
+function MP.GHOST.load_folder_replays()
+ local json = require("json")
+ local log_parser = MP.load_mp_file("lib/log_parser.lua")
+ local replays_dir = MP.path .. "/replays"
+ local dir_info = NFS.getInfo(replays_dir)
+ if not dir_info or dir_info.type ~= "directory" then return {} end
+
+ local items = NFS.getDirectoryItemsInfo(replays_dir)
+ local results = {}
+
+ for _, item in ipairs(items) do
+ if item.type == "file" and item.name:match("%.json$") then
+ local filepath = replays_dir .. "/" .. item.name
+ local content = NFS.read(filepath)
+ if content then
+ local ok, replay = pcall(json.decode, content)
+ if ok and replay and replay.ante_snapshots then
+ -- Convert string ante keys to numbers for consistency
+ local fixed = {}
+ for k, v in pairs(replay.ante_snapshots) do
+ fixed[tonumber(k) or k] = v
+ end
+ replay.ante_snapshots = fixed
+ replay._source = "file"
+ replay._filename = item.name
+ table.insert(results, replay)
+ else
+ sendWarnMessage("Failed to parse replay: " .. item.name, "MULTIPLAYER")
+ end
+ end
+ elseif item.type == "file" and item.name:match("%.log$") then
+ local filepath = replays_dir .. "/" .. item.name
+ local content = NFS.read(filepath)
+ if content and log_parser then
+ local ok, game_records = pcall(log_parser.process_log, content)
+ if ok and game_records then
+ local total = #game_records
+ for idx, game in ipairs(game_records) do
+ local ok2, replay = pcall(log_parser.to_replay, game)
+ if ok2 and replay and replay.ante_snapshots and next(replay.ante_snapshots) then
+ replay._source = "file"
+ replay._filename = item.name
+ replay._game_index = idx
+ replay._game_count = total
+ table.insert(results, replay)
+ end
+ end
+ else
+ sendWarnMessage("Failed to parse log: " .. item.name, "MULTIPLAYER")
+ end
+ end
+ end
+ end
+
+ -- Sort by timestamp descending (newest first)
+ table.sort(results, function(a, b)
+ return (a.timestamp or 0) > (b.timestamp or 0)
+ end)
+
+ return results
+end
diff --git a/localization/en-us.lua b/localization/en-us.lua
index 8df7f5fc..a7af9656 100644
--- a/localization/en-us.lua
+++ b/localization/en-us.lua
@@ -901,6 +901,9 @@ return {
dictionary = {
b_singleplayer = "Singleplayer",
b_sp_with_ruleset = "Practice Mode",
+ b_practice = "Practice",
+ k_unlimited_slots = "Unlimited Slots",
+ k_edition_cycling = "Edition Cycling (Q)",
b_join_lobby = "Join Lobby",
b_join_lobby_clipboard = "Join From Clipboard",
b_return_lobby = "Return to Lobby",
@@ -1095,6 +1098,9 @@ return {
"Enemy",
"location",
},
+ k_ghost_replays = "Match Replays",
+ k_no_ghost_replays = "No replays yet",
+ k_ghost = "Ghost",
k_hide_mp_content = "Hide Multiplayer content*",
k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets",
k_timer_sfx = "Timer Sound Effects",
diff --git a/lovely/end_round.toml b/lovely/end_round.toml
index ae13e938..d4625c08 100644
--- a/lovely/end_round.toml
+++ b/lovely/end_round.toml
@@ -28,7 +28,7 @@ target = "functions/state_events.lua"
pattern = '''-- context.end_of_round calculations'''
position = 'before'
payload = '''
-if MP.LOBBY.code then
+if MP.LOBBY.code or MP.GHOST.is_active() then
game_over = false
end
'''
@@ -42,7 +42,7 @@ target = "functions/state_events.lua"
pattern = '''if game_over then'''
position = 'before'
payload = '''
-if MP.LOBBY.code then
+if MP.LOBBY.code or MP.GHOST.is_active() then
game_won = nil
G.GAME.won = nil
end
@@ -149,7 +149,7 @@ function ease_ante(mod)
'''
position = 'after'
payload = '''
-if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then
+if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud then
MP.suppress_next_event = true
end
'''
diff --git a/lovely/hud.toml b/lovely/hud.toml
index 22fbe3f5..3c65f852 100644
--- a/lovely/hud.toml
+++ b/lovely/hud.toml
@@ -37,3 +37,20 @@ pattern = '''\{n=G\.UIT\.C, config=\{align = "cm", padding = 0\.05, minw = 1\.45
position = 'at'
payload = '''MP.LOBBY.code and (not MP.LOBBY.config.disable_live_and_timer_hud) and MP.UI.timer_hud() or {n=G.UIT.C, config={align = "cm", padding = 0.05, minw = 1.45, minh = 1,'''
times = 1
+
+# Practice mode: add collection button to HUD (after lobby info button injection)
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''{n=G.UIT.T, config={text = localize('b_options'), scale = scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
+ }},
+ }}'''
+position = 'after'
+payload = ''',
+(MP and MP.is_practice_mode and MP.is_practice_mode()) and {n=G.UIT.R, config={id = 'practice_collection_button', align = "cm", minh = 1.75, minw = 1.5, padding = 0.05, r = 0.1, hover = true, colour = G.C.SECONDARY_SET.Planet, button = "your_collection", shadow = true}, nodes={
+ {n=G.UIT.R, config={align = "cm", padding = 0, maxw = 1.4}, nodes={
+ {n=G.UIT.T, config={text = localize("k_collection"), scale = 0.9*scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
+ }}
+}} or nil'''
+match_indent = true
+times = 1
diff --git a/lovely/practice.toml b/lovely/practice.toml
new file mode 100644
index 00000000..06180042
--- /dev/null
+++ b/lovely/practice.toml
@@ -0,0 +1,87 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+# Practice mode: give cards from collection via key "3" or left-click.
+# Also: edition cycling.
+
+# Shared helper function for giving a hovered card to the player
+[[patches]]
+[patches.pattern]
+target = "engine/controller.lua"
+pattern = '''function Controller:key_press_update(key, dt)'''
+position = 'before'
+payload = '''
+function Controller:practice_give_card()
+ if not (MP and MP.is_practice_mode()) then return end
+ if MP.LOBBY.code then return end
+ if not (self.hovering.target and self.hovering.target:is(Card)) then return end
+ if not G.OVERLAY_MENU then return end
+ local _card = self.hovering.target
+ if _card.ability.set == 'Joker' and G.jokers then
+ if MP.SP.unlimited_slots or #G.jokers.cards < G.jokers.config.card_limit then
+ if MP.SP.unlimited_slots and #G.jokers.cards >= G.jokers.config.card_limit then
+ G.jokers.config.card_limit = G.jokers.config.card_limit + 1
+ end
+ add_joker(_card.config.center.key)
+ _card:set_sprites(_card.config.center)
+ end
+ end
+ if _card.ability.consumeable and G.consumeables then
+ if MP.SP.unlimited_slots or #G.consumeables.cards < G.consumeables.config.card_limit then
+ if MP.SP.unlimited_slots and #G.consumeables.cards >= G.consumeables.config.card_limit then
+ G.consumeables.config.card_limit = G.consumeables.config.card_limit + 1
+ end
+ add_joker(_card.config.center.key)
+ _card:set_sprites(_card.config.center)
+ end
+ end
+end
+'''
+match_indent = true
+times = 1
+
+# Key "3" triggers give in practice mode
+[[patches]]
+[patches.pattern]
+target = "engine/controller.lua"
+pattern = '''if not _RELEASE_MODE then'''
+position = 'before'
+payload = '''
+if MP and MP.is_practice_mode() and not MP.LOBBY.code then
+ if key == "3" then
+ self:practice_give_card()
+ end
+ if key == "q" and MP.SP.edition_cycling then
+ if self.hovering.target and self.hovering.target:is(Card) then
+ local _card = self.hovering.target
+ if _card.ability.set == 'Joker' or _card.playing_card or _card.area then
+ local _edition = {
+ foil = not _card.edition,
+ holo = _card.edition and _card.edition.foil,
+ polychrome = _card.edition and _card.edition.holo,
+ negative = _card.edition and _card.edition.polychrome,
+ }
+ _card:set_edition(_edition, true, true)
+ end
+ end
+ end
+end
+'''
+match_indent = true
+times = 1
+
+# Left-click on hovered card in collection triggers give in practice mode
+[[patches]]
+[patches.pattern]
+target = "main.lua"
+pattern = '''G.CONTROLLER:queue_L_cursor_press(x, y)'''
+position = 'before'
+payload = '''
+ if MP and MP.is_practice_mode() then
+ G.CONTROLLER:practice_give_card()
+ end
+'''
+match_indent = true
+times = 1
diff --git a/networking-old/action_handlers.lua b/networking-old/action_handlers.lua
index 8961b54a..8f6f035f 100644
--- a/networking-old/action_handlers.lua
+++ b/networking-old/action_handlers.lua
@@ -114,6 +114,10 @@ end
---@param seed string
---@param stake_str string
local function action_start_game(seed, stake_str)
+ -- Clear any stale practice/ghost state so it can't leak into real MP
+ MP.SP.practice = false
+ MP.GHOST.clear()
+
MP.reset_game_states()
local stake = tonumber(stake_str)
MP.ACTIONS.set_ante(0)
diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua
index 3f52ad00..6625311f 100644
--- a/networking/action_handlers.lua
+++ b/networking/action_handlers.lua
@@ -228,6 +228,10 @@ end
---@param seed string
---@param stake_str string
local function action_start_game(seed, stake_str)
+ -- Clear any stale practice/ghost state so it can't leak into real MP
+ MP.SP.practice = false
+ MP.GHOST.clear()
+
MP.reset_game_states()
local stake = tonumber(stake_str)
MP.ACTIONS.set_ante(0)
@@ -236,6 +240,11 @@ local function action_start_game(seed, stake_str)
end
G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake })
MP.LOBBY.ready_to_start = false
+
+ local player_name = MP.UI.get_username()
+ local nemesis = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host
+ local nemesis_name = nemesis and nemesis.username or "Unknown"
+ MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back, stake_str, player_name, nemesis_name)
end
local function begin_pvp_blind()
@@ -340,6 +349,21 @@ local function action_end_pvp()
MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
MP.GAME.timer_started = false
MP.GAME.ready_blind = false
+
+ -- needs a cleaner place than the networking layer but whatever
+ local ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1
+ MP.MATCH_RECORD.snapshot_ante(ante, {
+ player_score = MP.GAME.highest_score and tostring(MP.GAME.highest_score) or "0",
+ enemy_score = MP.GAME.enemy.score_text or "0",
+ player_lives = MP.GAME.lives,
+ enemy_lives = MP.GAME.enemy.lives,
+ blind_key = G.GAME.blind
+ and G.GAME.blind.config
+ and G.GAME.blind.config.blind
+ and G.GAME.blind.config.blind.key
+ or nil,
+ result = nil,
+ })
end
---@param lives number
@@ -366,6 +390,7 @@ local function action_win_game()
MP.nemesis_deck_received = false
MP.GAME.won = true
MP.STATS.record_match(true)
+ MP.MATCH_RECORD.finalize(true)
win_game()
end
@@ -375,6 +400,7 @@ local function action_lose_game()
MP.end_game_jokers_received = false
MP.nemesis_deck_received = false
MP.STATS.record_match(false)
+ MP.MATCH_RECORD.finalize(false)
G.STATE_COMPLETE = false
G.STATE = G.STATES.GAME_OVER
end
diff --git a/replays/.gitkeep b/replays/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua
index 4763a9c3..13d84f4e 100644
--- a/rulesets/_rulesets.lua
+++ b/rulesets/_rulesets.lua
@@ -41,7 +41,7 @@ function MP.is_ruleset_active(ruleset_name)
local key = "ruleset_mp_" .. ruleset_name
if MP.LOBBY.code then
return MP.LOBBY.config.ruleset == key
- elseif MP.SP and MP.SP.ruleset then
+ elseif MP.is_practice_mode() then
return MP.SP.ruleset == key
end
return false
@@ -50,22 +50,30 @@ end
function MP.get_active_ruleset()
if MP.LOBBY.code then
return MP.LOBBY.config.ruleset
- elseif MP.SP and MP.SP.ruleset then
+ elseif MP.is_practice_mode() then
return MP.SP.ruleset
end
return nil
end
-function MP.ApplyBans()
- local ruleset_key = nil
- local gamemode = nil
-
- if MP.LOBBY.code and MP.LOBBY.config.ruleset then
- ruleset_key = MP.LOBBY.config.ruleset
- gamemode = MP.Gamemodes["gamemode_mp_" .. MP.LOBBY.type]
- elseif MP.SP and MP.SP.ruleset then
- ruleset_key = MP.SP.ruleset
+function MP.get_active_gamemode()
+ if MP.LOBBY.code then
+ return MP.LOBBY.config.gamemode
+ elseif MP.is_practice_mode() then
+ -- Ghost replay stores the gamemode directly
+ if MP.GHOST.is_active() and MP.GHOST.gamemode then
+ return MP.GHOST.gamemode
+ end
+ local ruleset_key = MP.SP and MP.SP.ruleset
+ if ruleset_key and MP.Rulesets[ruleset_key] then return MP.Rulesets[ruleset_key].forced_gamemode end
end
+ return nil
+end
+
+function MP.ApplyBans()
+ local ruleset_key = MP.get_active_ruleset()
+ local gamemode_key = MP.get_active_gamemode()
+ local gamemode = gamemode_key and MP.Gamemodes[gamemode_key] or nil
if ruleset_key then
local ruleset = MP.Rulesets[ruleset_key]
diff --git a/tools/log_to_ghost_replay.html b/tools/log_to_ghost_replay.html
new file mode 100644
index 00000000..2342778e
--- /dev/null
+++ b/tools/log_to_ghost_replay.html
@@ -0,0 +1,284 @@
+
+
+
+
+
+Ghost Replay Generator
+
+
+
+
+
Ghost Replay Generator
+
Balatro Multiplayer — drop a Lovely log, get a ghost replay JSON
+
+
+
Drop your lovely-*.log here
+
or click to browse
+
+
+
+
+
+
+
+
diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py
new file mode 100644
index 00000000..01305ce3
--- /dev/null
+++ b/tools/log_to_ghost_replay.py
@@ -0,0 +1,732 @@
+#!/usr/bin/env python3
+"""Parse a Lovely log file and generate ghost replay JSON(s).
+
+Handles multiple games within a single log file — each startGame/stopGame
+cycle produces a separate replay. Output defaults to JSON written into
+replays/; use --lua for Lua table output to stdout.
+
+Usage:
+ python3 tools/log_to_ghost_replay.py # writes to replays/
+ python3 tools/log_to_ghost_replay.py --lua # output Lua table to stdout
+"""
+
+import json
+import os
+import re
+import sys
+import time
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Optional
+
+
+# ---------------------------------------------------------------------------
+# Data model
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class HandScore:
+ """A single hand played during a PvP round."""
+
+ score: str
+ hands_left: int
+ side: str # "player" or "enemy"
+
+
+@dataclass
+class AnteSnapshot:
+ ante: int
+ player_score: str = "0"
+ enemy_score: str = "0"
+ player_lives: int = 4
+ enemy_lives: int = 4
+ result: Optional[str] = None # "win" or "loss"
+ hands: list = field(default_factory=list) # list[HandScore]
+
+
+@dataclass
+class GameRecord:
+ seed: Optional[str] = None
+ ruleset: Optional[str] = None
+ gamemode: Optional[str] = None
+ deck: Optional[str] = None
+ stake: Optional[int] = None
+ player_name: Optional[str] = None
+ nemesis_name: Optional[str] = None
+ starting_lives: int = 4
+ is_host: Optional[bool] = None
+ lobby_code: Optional[str] = None
+ ante_snapshots: dict = field(default_factory=dict)
+ winner: Optional[str] = None
+ final_ante: int = 1
+ current_ante: int = 0
+ player_lives: int = 4
+ enemy_lives: int = 4
+
+ # PvP round tracking (transient)
+ pvp_player_score: str = "0"
+ pvp_enemy_score: str = "0"
+ pvp_hands: list = field(default_factory=list)
+ in_pvp: bool = False
+
+ # End-game data
+ player_jokers: list = field(default_factory=list)
+ nemesis_jokers: list = field(default_factory=list)
+ player_stats: dict = field(default_factory=dict)
+ nemesis_stats: dict = field(default_factory=dict)
+
+ # Per-ante shop spending
+ shop_spending: dict = field(default_factory=dict)
+
+ # Non-PvP round failures
+ failed_rounds: list = field(default_factory=list)
+
+ # Timing
+ game_start_ts: Optional[str] = None
+ game_end_ts: Optional[str] = None
+
+ # Card activity log
+ cards_bought: list = field(default_factory=list)
+ cards_sold: list = field(default_factory=list)
+ cards_used: list = field(default_factory=list)
+
+
+# ---------------------------------------------------------------------------
+# Parsers
+# ---------------------------------------------------------------------------
+
+_TS_RE = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
+
+
+def parse_timestamp(line: str) -> Optional[str]:
+ """Extract ISO-ish timestamp from a log line."""
+ m = _TS_RE.search(line)
+ return m.group(1) if m else None
+
+
+def parse_client_sent_json(line: str) -> Optional[dict]:
+ """Extract JSON from 'Client sent message: {...}' lines."""
+ m = re.search(r"Client sent message: (\{.*\})\s*$", line)
+ if m:
+ try:
+ return json.loads(m.group(1))
+ except json.JSONDecodeError:
+ return None
+ return None
+
+
+def parse_client_sent_kv(line: str) -> Optional[dict]:
+ """Extract key:value pairs from 'Client sent message: action:foo,key:val' lines."""
+ m = re.search(r"Client sent message: (action:\w+.*?)\s*$", line)
+ if not m:
+ return None
+ raw = m.group(1)
+ pairs = {}
+ for part in raw.split(","):
+ if ":" in part:
+ k, v = part.split(":", 1)
+ pairs[k.strip()] = v.strip()
+ return pairs
+
+
+def parse_client_got_kv(line: str) -> Optional[tuple]:
+ """Extract action and key-value pairs from 'Client got message: (k: v) ...' lines."""
+ m = re.search(r"Client got (\w+) message:\s*(.*?)\s*$", line)
+ if not m:
+ return None
+ action = m.group(1)
+ kv_str = m.group(2)
+ pairs = {}
+ for km in re.finditer(r"\((\w+):\s*([^)]*)\)", kv_str):
+ key = km.group(1)
+ val = km.group(2).strip()
+ try:
+ if "." in val:
+ val = float(val)
+ else:
+ val = int(val)
+ except ValueError:
+ if val == "true":
+ val = True
+ elif val == "false":
+ val = False
+ pairs[key] = val
+ return action, pairs
+
+
+def parse_joker_list(raw: str) -> list:
+ """Parse a ';'-separated joker string like ';j_foo-none-none-none;j_bar-...'
+ into a list of joker key strings."""
+ jokers = []
+ for entry in raw.split(";"):
+ entry = entry.strip()
+ if not entry:
+ continue
+ # Format: j_key-edition-sticker1-sticker2
+ parts = entry.split("-", 1)
+ jokers.append(parts[0] if parts else entry)
+ return jokers
+
+
+def parse_joker_list_full(raw: str) -> list:
+ """Parse joker string preserving edition/sticker info as dicts."""
+ jokers = []
+ for entry in raw.split(";"):
+ entry = entry.strip()
+ if not entry:
+ continue
+ parts = entry.split("-")
+ joker = {"key": parts[0]}
+ if len(parts) >= 2 and parts[1] != "none":
+ joker["edition"] = parts[1]
+ if len(parts) >= 3 and parts[2] != "none":
+ joker["sticker1"] = parts[2]
+ if len(parts) >= 4 and parts[3] != "none":
+ joker["sticker2"] = parts[3]
+ jokers.append(joker)
+ return jokers
+
+
+# ---------------------------------------------------------------------------
+# Multi-game log processing
+# ---------------------------------------------------------------------------
+
+
+def process_log(filepath: str) -> list:
+ """Process a log file and return a list of GameRecords (one per game)."""
+ games = []
+ game = GameRecord()
+ last_lobby_options = None
+ in_game = False
+
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
+ lines = f.readlines()
+
+ for line in lines:
+ if "MULTIPLAYER" not in line:
+ continue
+
+ ts = parse_timestamp(line)
+
+ # --- Direct log messages (not Client sent/got) ---
+ if "Sending end game jokers:" in line:
+ m = re.search(r"Sending end game jokers:\s*(.*?)\s*$", line)
+ if m:
+ game.player_jokers = parse_joker_list_full(m.group(1))
+ continue
+
+ if "Received end game jokers:" in line:
+ m = re.search(r"Received end game jokers:\s*(.*?)\s*$", line)
+ if m:
+ game.nemesis_jokers = parse_joker_list_full(m.group(1))
+ continue
+
+ # --- Sent messages (JSON) ---
+ sent = parse_client_sent_json(line)
+ if sent:
+ action = sent.get("action")
+
+ if action == "username":
+ game.player_name = sent.get("username")
+
+ elif action == "lobbyOptions":
+ last_lobby_options = sent
+
+ elif action == "setAnte":
+ ante = sent.get("ante", 0)
+ game.current_ante = ante
+ if ante > game.final_ante:
+ game.final_ante = ante
+
+ elif action == "playHand":
+ score = sent.get("score", "0")
+ hands_left = sent.get("handsLeft", 0)
+ if game.in_pvp:
+ game.pvp_player_score = str(score)
+ game.pvp_hands.append(
+ HandScore(
+ score=str(score),
+ hands_left=hands_left,
+ side="player",
+ )
+ )
+
+ elif action == "setLocation":
+ loc = sent.get("location", "")
+ if "bl_mp_nemesis" in loc:
+ game.in_pvp = True
+
+ elif action == "failRound":
+ game.failed_rounds.append(game.current_ante)
+
+ elif action == "spentLastShop":
+ amount = sent.get("amount", 0)
+ game.shop_spending[game.current_ante] = (
+ game.shop_spending.get(game.current_ante, 0) + amount
+ )
+
+ elif action == "nemesisEndGameStats":
+ # Player's own stats sent to the nemesis
+ game.player_stats = {
+ k: v for k, v in sent.items() if k != "action"
+ }
+
+ elif action == "startGame":
+ # Record game start timestamp
+ if ts:
+ game.game_start_ts = ts
+
+ continue
+
+ # --- Sent messages (key:value format — card activity) ---
+ sent_kv = parse_client_sent_kv(line)
+ if sent_kv:
+ action = sent_kv.get("action")
+ if action == "boughtCardFromShop":
+ card = sent_kv.get("card", "")
+ cost = sent_kv.get("cost", "0")
+ game.cards_bought.append(
+ {"card": card, "cost": int(cost), "ante": game.current_ante}
+ )
+ elif action == "soldCard":
+ card = sent_kv.get("card", "")
+ game.cards_sold.append(
+ {"card": card, "ante": game.current_ante}
+ )
+ elif action == "usedCard":
+ card = sent_kv.get("card", "")
+ game.cards_used.append(
+ {"card": card, "ante": game.current_ante}
+ )
+ continue
+
+ # --- Received messages (key-value) ---
+ parsed = parse_client_got_kv(line)
+ if not parsed:
+ continue
+ action, kv = parsed
+
+ if action == "joinedLobby":
+ if "code" in kv:
+ game.lobby_code = str(kv["code"])
+
+ elif action == "lobbyInfo":
+ if "isHost" in kv:
+ game.is_host = kv["isHost"]
+ if game.is_host is True and "guest" in kv:
+ game.nemesis_name = str(kv["guest"])
+ elif game.is_host is False and "host" in kv:
+ game.nemesis_name = str(kv["host"])
+
+ elif action == "startGame":
+ in_game = True
+ if ts:
+ game.game_start_ts = game.game_start_ts or ts
+ # Apply last lobby options
+ if last_lobby_options:
+ game.ruleset = last_lobby_options.get("ruleset")
+ game.gamemode = last_lobby_options.get("gamemode")
+ game.deck = last_lobby_options.get("back", "Red Deck")
+ game.stake = last_lobby_options.get("stake", 1)
+ game.starting_lives = last_lobby_options.get(
+ "starting_lives", 4
+ )
+ game.player_lives = game.starting_lives
+ game.enemy_lives = game.starting_lives
+
+ elif action == "playerInfo":
+ if "lives" in kv:
+ game.player_lives = kv["lives"]
+
+ elif action == "enemyInfo":
+ if "lives" in kv:
+ game.enemy_lives = kv["lives"]
+ if "score" in kv:
+ score_str = str(kv["score"])
+ if game.in_pvp:
+ game.pvp_enemy_score = score_str
+ # Track enemy hand progressions
+ hands_left = kv.get("handsLeft", 0)
+ game.pvp_hands.append(
+ HandScore(
+ score=score_str,
+ hands_left=hands_left,
+ side="enemy",
+ )
+ )
+
+ elif action == "enemyLocation":
+ loc = kv.get("location", "")
+ if "bl_mp_nemesis" in str(loc):
+ game.in_pvp = True
+
+ elif action == "endPvP":
+ lost = kv.get("lost", False)
+ result = "loss" if lost else "win"
+
+ # Clean up hand progression: drop initial score=0 entries,
+ # deduplicate same-score same-side updates (life-loss broadcasts),
+ # and skip late re-broadcasts of a side's final score
+ cleaned_hands = []
+ seen_final = {} # side -> score at hands_left=0
+ for h in game.pvp_hands:
+ if h.score == "0" and h.hands_left >= 4:
+ continue
+ if cleaned_hands and (
+ cleaned_hands[-1].score == h.score
+ and cleaned_hands[-1].side == h.side
+ ):
+ continue
+ # Skip if this side already posted a final score
+ if h.side in seen_final and h.score == seen_final[h.side]:
+ continue
+ if h.hands_left == 0:
+ seen_final[h.side] = h.score
+ cleaned_hands.append(h)
+
+ snap = AnteSnapshot(
+ ante=game.current_ante,
+ player_score=game.pvp_player_score,
+ enemy_score=game.pvp_enemy_score,
+ player_lives=game.player_lives,
+ enemy_lives=game.enemy_lives,
+ result=result,
+ hands=cleaned_hands,
+ )
+ game.ante_snapshots[game.current_ante] = snap
+
+ # Reset PvP tracking
+ game.in_pvp = False
+ game.pvp_player_score = "0"
+ game.pvp_enemy_score = "0"
+ game.pvp_hands = []
+
+ elif action == "winGame":
+ game.winner = "player"
+
+ elif action == "loseGame":
+ game.winner = "nemesis"
+
+ elif action == "nemesisEndGameStats":
+ # Nemesis stats received from the server
+ game.nemesis_stats = {k: v for k, v in kv.items() if k != "action"}
+
+ elif action == "stopGame":
+ if "seed" in kv:
+ game.seed = str(kv["seed"])
+ if ts:
+ game.game_end_ts = ts
+ # Finalize this game record and start a fresh one for the next game
+ if in_game:
+ games.append(game)
+ in_game = False
+ # Carry over player_name and preserve lobby state for next game
+ prev_name = game.player_name
+ game = GameRecord()
+ game.player_name = prev_name
+ last_lobby_options = None
+
+ # If the log ends mid-game (no stopGame), still capture it
+ if in_game and game.winner:
+ games.append(game)
+
+ return games
+
+
+# ---------------------------------------------------------------------------
+# Output formatters
+# ---------------------------------------------------------------------------
+
+
+def _format_duration(start: str, end: str) -> Optional[str]:
+ """Compute duration string from two timestamp strings."""
+ try:
+ fmt = "%Y-%m-%d %H:%M:%S"
+ t0 = datetime.strptime(start, fmt)
+ t1 = datetime.strptime(end, fmt)
+ delta = t1 - t0
+ secs = int(delta.total_seconds())
+ if secs < 0:
+ return None
+ mins, s = divmod(secs, 60)
+ return f"{mins}m{s:02d}s"
+ except (ValueError, TypeError):
+ return None
+
+
+def _hands_to_list(hands: list) -> list:
+ """Convert HandScore objects to serializable dicts."""
+ return [
+ {"score": h.score, "hands_left": h.hands_left, "side": h.side}
+ for h in hands
+ ]
+
+
+def to_json(game: GameRecord) -> str:
+ """Convert a GameRecord to JSON."""
+ snapshots = {}
+ for ante, snap in sorted(game.ante_snapshots.items()):
+ snap_dict = {
+ "player_score": snap.player_score,
+ "enemy_score": snap.enemy_score,
+ "player_lives": snap.player_lives,
+ "enemy_lives": snap.enemy_lives,
+ "result": snap.result,
+ }
+ if snap.hands:
+ snap_dict["hands"] = _hands_to_list(snap.hands)
+ snapshots[str(ante)] = snap_dict
+
+ obj = {
+ "gamemode": game.gamemode or "gamemode_mp_attrition",
+ "final_ante": game.final_ante,
+ "ante_snapshots": snapshots,
+ "winner": game.winner or "unknown",
+ "timestamp": int(time.time()),
+ "ruleset": game.ruleset or "ruleset_mp_blitz",
+ "seed": game.seed or "UNKNOWN",
+ "deck": game.deck or "Red Deck",
+ "stake": game.stake or 1,
+ }
+ if game.player_name:
+ obj["player_name"] = game.player_name
+ if game.nemesis_name:
+ obj["nemesis_name"] = game.nemesis_name
+ if game.lobby_code:
+ obj["lobby_code"] = game.lobby_code
+
+ # Duration
+ if game.game_start_ts and game.game_end_ts:
+ dur = _format_duration(game.game_start_ts, game.game_end_ts)
+ if dur:
+ obj["duration"] = dur
+
+ # End-game jokers
+ if game.player_jokers:
+ obj["player_jokers"] = game.player_jokers
+ if game.nemesis_jokers:
+ obj["nemesis_jokers"] = game.nemesis_jokers
+
+ # End-game stats
+ if game.player_stats:
+ obj["player_stats"] = game.player_stats
+ if game.nemesis_stats:
+ obj["nemesis_stats"] = game.nemesis_stats
+
+ # Shop spending per ante
+ if game.shop_spending:
+ obj["shop_spending"] = {
+ str(k): v for k, v in sorted(game.shop_spending.items())
+ }
+
+ # Non-PvP round failures
+ if game.failed_rounds:
+ obj["failed_rounds"] = game.failed_rounds
+
+ # Card activity
+ if game.cards_bought:
+ obj["cards_bought"] = game.cards_bought
+ if game.cards_sold:
+ obj["cards_sold"] = game.cards_sold
+ if game.cards_used:
+ obj["cards_used"] = game.cards_used
+
+ return _compact_json(obj)
+
+
+def _compact_json(obj, indent=2) -> str:
+ """JSON with indent, but small objects/arrays collapsed to one line.
+
+ Any value that serialises to <= *threshold* chars is kept on a single line,
+ so arrays-of-small-dicts (hands, jokers, card activity) stay readable
+ without burning vertical space.
+ """
+ threshold = 120
+
+ def _fmt(value, level):
+ if isinstance(value, dict):
+ if not value:
+ return "{}"
+ # Try compact first
+ compact = json.dumps(value, separators=(", ", ": "))
+ if len(compact) <= threshold and "\n" not in compact:
+ return compact
+ # Expanded
+ pad = " " * (indent * (level + 1))
+ end_pad = " " * (indent * level)
+ items = []
+ for k, v in value.items():
+ items.append(f"{pad}{json.dumps(k)}: {_fmt(v, level + 1)}")
+ return "{\n" + ",\n".join(items) + "\n" + end_pad + "}"
+ elif isinstance(value, list):
+ if not value:
+ return "[]"
+ compact = json.dumps(value, separators=(", ", ": "))
+ if len(compact) <= threshold and "\n" not in compact:
+ return compact
+ pad = " " * (indent * (level + 1))
+ end_pad = " " * (indent * level)
+ items = [f"{pad}{_fmt(v, level + 1)}" for v in value]
+ return "[\n" + ",\n".join(items) + "\n" + end_pad + "]"
+ else:
+ return json.dumps(value)
+
+ return _fmt(obj, 0)
+
+
+def to_lua_table(game: GameRecord) -> str:
+ """Convert a GameRecord to a Lua table string matching ghost_replays format."""
+ indent = "\t"
+
+ lines = ["{"]
+ lines.append(
+ f'{indent}["gamemode"] = "{game.gamemode or "gamemode_mp_attrition"}",'
+ )
+ lines.append(f'{indent}["final_ante"] = {game.final_ante},')
+ lines.append(f'{indent}["ante_snapshots"] = {{')
+
+ for ante in sorted(game.ante_snapshots.keys()):
+ snap = game.ante_snapshots[ante]
+ lines.append(f"{indent}{indent}[{ante}] = {{")
+ lines.append(f'{indent}{indent}{indent}["result"] = "{snap.result}",')
+ lines.append(
+ f'{indent}{indent}{indent}["enemy_score"] = "{snap.enemy_score}",'
+ )
+ lines.append(
+ f'{indent}{indent}{indent}["enemy_lives"] = {snap.enemy_lives},'
+ )
+ lines.append(
+ f'{indent}{indent}{indent}["player_score"] = "{snap.player_score}",'
+ )
+ lines.append(
+ f'{indent}{indent}{indent}["player_lives"] = {snap.player_lives},'
+ )
+ lines.append(f"{indent}{indent}}},")
+
+ lines.append(f"{indent}}},")
+ lines.append(f'{indent}["winner"] = "{game.winner or "unknown"}",')
+ lines.append(f'{indent}["timestamp"] = {int(time.time())},')
+ lines.append(
+ f'{indent}["ruleset"] = "{game.ruleset or "ruleset_mp_blitz"}",'
+ )
+ lines.append(f'{indent}["seed"] = "{game.seed or "UNKNOWN"}",')
+ lines.append(f'{indent}["deck"] = "{game.deck or "Red Deck"}",')
+ lines.append(f'{indent}["stake"] = {game.stake or 1},')
+ if game.player_name:
+ lines.append(f'{indent}["player_name"] = "{game.player_name}",')
+ if game.nemesis_name:
+ lines.append(f'{indent}["nemesis_name"] = "{game.nemesis_name}",')
+ lines.append("}")
+
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def print_summary(game: GameRecord, idx: int = 0, total: int = 1):
+ """Print a human-readable summary of a parsed game to stderr."""
+ header = f"# Game {idx + 1}/{total}" if total > 1 else "# Parsed game"
+ print(header, file=sys.stderr)
+ print(f"# Seed: {game.seed}", file=sys.stderr)
+ print(f"# Lobby: {game.lobby_code}", file=sys.stderr)
+ print(f"# Ruleset: {game.ruleset}", file=sys.stderr)
+ print(f"# Gamemode: {game.gamemode}", file=sys.stderr)
+ print(f"# Deck: {game.deck} (stake {game.stake})", file=sys.stderr)
+ print(f"# Player: {game.player_name}", file=sys.stderr)
+ print(f"# Nemesis: {game.nemesis_name}", file=sys.stderr)
+ print(f"# Winner: {game.winner}", file=sys.stderr)
+ print(f"# Final ante: {game.final_ante}", file=sys.stderr)
+
+ if game.game_start_ts and game.game_end_ts:
+ dur = _format_duration(game.game_start_ts, game.game_end_ts)
+ if dur:
+ print(f"# Duration: {dur}", file=sys.stderr)
+
+ print(
+ f"# PvP snapshots: {len(game.ante_snapshots)} antes", file=sys.stderr
+ )
+ for ante in sorted(game.ante_snapshots.keys()):
+ s = game.ante_snapshots[ante]
+ n_hands = len(s.hands)
+ hand_info = f" ({n_hands} hand updates)" if n_hands else ""
+ print(
+ f"# Ante {ante}: {s.result} | "
+ f"player={s.player_score} ({s.player_lives}hp) vs "
+ f"enemy={s.enemy_score} ({s.enemy_lives}hp){hand_info}",
+ file=sys.stderr,
+ )
+
+ if game.failed_rounds:
+ print(
+ f"# Failed non-PvP rounds at antes: {game.failed_rounds}",
+ file=sys.stderr,
+ )
+
+ if game.player_jokers:
+ jkeys = [j["key"] for j in game.player_jokers]
+ print(f"# Player jokers: {', '.join(jkeys)}", file=sys.stderr)
+ if game.nemesis_jokers:
+ jkeys = [j["key"] for j in game.nemesis_jokers]
+ print(f"# Nemesis jokers: {', '.join(jkeys)}", file=sys.stderr)
+
+ if game.player_stats:
+ print(f"# Player stats: {game.player_stats}", file=sys.stderr)
+ if game.nemesis_stats:
+ print(f"# Nemesis stats: {game.nemesis_stats}", file=sys.stderr)
+
+ if game.shop_spending:
+ total_spent = sum(game.shop_spending.values())
+ print(f"# Total shop spending: {total_spent}", file=sys.stderr)
+
+ print(file=sys.stderr)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+
+ filepath = sys.argv[1]
+ output_format = "json"
+ if "--lua" in sys.argv:
+ output_format = "lua"
+
+ games = process_log(filepath)
+
+ if not games:
+ print(f"# No complete games found in {filepath}", file=sys.stderr)
+ sys.exit(1)
+
+ for idx, game in enumerate(games):
+ print_summary(game, idx, len(games))
+
+ if output_format == "lua":
+ for game in games:
+ print(to_lua_table(game))
+ else:
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ replays_dir = os.path.join(script_dir, "..", "replays")
+ os.makedirs(replays_dir, exist_ok=True)
+
+ for idx, game in enumerate(games):
+ seed = game.seed or "unknown"
+ player = (game.player_name or "unknown").replace("~", "-")
+ nemesis = (game.nemesis_name or "unknown").replace("~", "-")
+ # Add index suffix when multiple games share the same names
+ suffix = f"_{idx + 1}" if len(games) > 1 else ""
+ filename = f"{seed}_{player}_vs_{nemesis}{suffix}.json"
+ out_path = os.path.join(replays_dir, filename)
+
+ with open(out_path, "w") as f:
+ f.write(to_json(game))
+ f.write("\n")
+
+ print(f"Wrote {out_path}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua
index 1a9fe6d8..ed57ed41 100644
--- a/ui/game/blind_choice.lua
+++ b/ui/game/blind_choice.lua
@@ -1,7 +1,7 @@
local create_UIBox_blind_choice_ref = create_UIBox_blind_choice
---@diagnostic disable-next-line: lowercase-global
function create_UIBox_blind_choice(type, run_info)
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
if not G.GAME.blind_on_deck then G.GAME.blind_on_deck = "Small" end
if not run_info then G.GAME.round_resets.blind_states[G.GAME.blind_on_deck] = "Select" end
@@ -134,10 +134,16 @@ function create_UIBox_blind_choice(type, run_info)
or "",
},
})
- local loc_name = (
- G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis"
- and (MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username)
- ) or localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" })
+ local loc_name
+ if G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" then
+ if MP.GHOST.is_active() then
+ loc_name = MP.GHOST.get_nemesis_name()
+ else
+ loc_name = MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username
+ end
+ else
+ loc_name = localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" })
+ end
local blind_col = get_blind_main_colour(type)
@@ -213,8 +219,9 @@ function create_UIBox_blind_choice(type, run_info)
hover = true,
one_press = true,
func = (
- G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis"
- or G.GAME.round_resets.pvp_blind_choices[type]
+ not MP.GHOST.is_active()
+ and (G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis"
+ or G.GAME.round_resets.pvp_blind_choices[type])
)
and "pvp_ready_button"
or nil,
diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua
index 4df6cfb8..7630eee7 100644
--- a/ui/game/blind_hud.lua
+++ b/ui/game/blind_hud.lua
@@ -1,5 +1,5 @@
function MP.UI.update_blind_HUD()
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
G.HUD_blind.alignment.offset.y = -10
G.E_MANAGER:add_event(Event({
trigger = "after",
@@ -29,7 +29,7 @@ function MP.UI.update_blind_HUD()
end
function MP.UI.reset_blind_HUD()
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string =
{ { ref_table = G.GAME.blind, ref_value = "loc_name" } }
G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
@@ -122,7 +122,7 @@ end
local blind_defeat_ref = Blind.defeat
function Blind:defeat(silent)
blind_defeat_ref(self, silent)
- if MP.LOBBY.code and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end
+ if (MP.LOBBY.code or MP.GHOST.is_active()) and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end
end
local blind_disable_ref = Blind.disable
diff --git a/ui/game/functions.lua b/ui/game/functions.lua
index e813f7f2..573aaaa2 100644
--- a/ui/game/functions.lua
+++ b/ui/game/functions.lua
@@ -51,12 +51,14 @@ function G.FUNCS.select_blind(e)
MP.GAME.end_pvp = false
MP.GAME.prevent_eval = false
select_blind_ref(e)
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
MP.GAME.ante_key = tostring(math.random())
- MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands)
- MP.ACTIONS.new_round()
- MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name))
- if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end
+ if not MP.GHOST.is_active() then
+ MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands)
+ MP.ACTIONS.new_round()
+ MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name))
+ if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end
+ end
end
end
diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua
index bc03dbd1..8ee52df0 100644
--- a/ui/game/game_state.lua
+++ b/ui/game/game_state.lua
@@ -1,9 +1,13 @@
-- Contains function overrides (monkey-patches) for game state management
-- Overrides Game methods like update_draw_to_hand, update_hand_played, update_new_round, etc.
+local function is_mp_or_ghost()
+ return MP.LOBBY.code or MP.GHOST.is_active()
+end
+
local update_draw_to_hand_ref = Game.update_draw_to_hand
function Game:update_draw_to_hand(dt)
- if MP.LOBBY.code then
+ if is_mp_or_ghost() then
if
not G.STATE_COMPLETE
and G.GAME.current_round.hands_played == 0
@@ -28,12 +32,18 @@ function Game:update_draw_to_hand(dt)
delay = 0.45,
blockable = false,
func = function()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = {
- {
- ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host,
- ref_value = "username",
- },
- }
+ local blind_name_string
+ if MP.GHOST.is_active() then
+ blind_name_string = { { string = MP.GHOST.get_nemesis_name() } }
+ else
+ blind_name_string = {
+ {
+ ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host,
+ ref_value = "username",
+ },
+ }
+ end
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = blind_name_string
G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_in(0)
return true
@@ -69,8 +79,10 @@ function Game:update_draw_to_hand(dt)
end
G.E_MANAGER:add_event(Event({
func = function()
- for i = 1, MP.GAME.asteroids do
- MP.ACTIONS.asteroid()
+ if not MP.GHOST.is_active() then
+ for i = 1, MP.GAME.asteroids do
+ MP.ACTIONS.asteroid()
+ end
end
MP.GAME.asteroids = 0
return true
@@ -182,7 +194,8 @@ local update_hand_played_ref = Game.update_hand_played
---@diagnostic disable-next-line: duplicate-set-field
function Game:update_hand_played(dt)
-- Ignore for singleplayer or regular blinds
- if not MP.LOBBY.connected or not MP.LOBBY.code or not MP.is_pvp_boss() then
+ local ghost = MP.GHOST.is_active()
+ if (not ghost and (not MP.LOBBY.connected or not MP.LOBBY.code)) or not MP.is_pvp_boss() then
update_hand_played_ref(self, dt)
return
end
@@ -201,21 +214,116 @@ function Game:update_hand_played(dt)
G.E_MANAGER:add_event(Event({
trigger = "immediate",
func = function()
- MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left)
- -- For now, never advance to next round
+ if not ghost then
+ MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left)
+ end
+
if G.GAME.current_round.hands_left < 1 then
- attention_text({
- scale = 0.8,
- text = localize("k_wait_enemy"),
- hold = 5,
- align = "cm",
- offset = { x = 0, y = -1.5 },
- major = G.play,
- })
+ if ghost then
+ local target
+ if MP.GHOST.has_hand_data() then
+ target = MP.GHOST.current_target_big()
+ else
+ local es = MP.GAME.enemy.score
+ target = to_big(es.coeffiocient * (10 ^ es.exponent))
+ end
+ local beat_current = to_big(G.GAME.chips) >= target
+ local all_exhausted = MP.GHOST.playback_exhausted()
+
+ if beat_current and all_exhausted then
+ MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
+ if MP.GAME.enemy.lives <= 0 then
+ MP.GAME.won = true
+ MP.MATCH_RECORD.finalize(true)
+ win_game()
+ return true
+ end
+ else
+ if MP.LOBBY.config.gold_on_life_loss then
+ MP.GAME.comeback_bonus_given = false
+ MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1
+ end
+ MP.GAME.lives = MP.GAME.lives - 1
+ MP.UI.ease_lives(-1)
+ if MP.LOBBY.config.no_gold_on_round_loss and G.GAME.blind and G.GAME.blind.dollars then
+ G.GAME.blind.dollars = 0
+ end
+ if MP.GAME.lives <= 0 then
+ MP.MATCH_RECORD.finalize(false)
+ G.STATE = G.STATES.GAME_OVER
+ G.STATE_COMPLETE = false
+ return true
+ end
+ end
+ MP.GAME.end_pvp = true
+ else
+ attention_text({
+ scale = 0.8,
+ text = localize("k_wait_enemy"),
+ hold = 5,
+ align = "cm",
+ offset = { x = 0, y = -1.5 },
+ major = G.play,
+ })
+ end
if G.hand.cards[1] and G.STATE == G.STATES.HAND_PLAYED then
eval_hand_and_jokers()
G.FUNCS.draw_from_hand_to_discard()
end
+ elseif ghost and MP.GHOST.has_hand_data() then
+ local beat_current = to_big(G.GAME.chips) >= MP.GHOST.current_target_big()
+
+ if beat_current and MP.GHOST.playback_exhausted() then
+ MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
+ if MP.GAME.enemy.lives <= 0 then
+ MP.GAME.won = true
+ MP.MATCH_RECORD.finalize(true)
+ win_game()
+ return true
+ end
+ MP.GAME.end_pvp = true
+ elseif beat_current and not MP.GHOST.playback_exhausted() and not MP.GHOST._advancing then
+ MP.GHOST._advancing = true
+ G.E_MANAGER:add_event(Event({
+ blockable = false,
+ blocking = false,
+ trigger = "after",
+ delay = 0.5,
+ func = function()
+ MP.GHOST.advance_hand()
+ G.E_MANAGER:add_event(Event({
+ blockable = false,
+ blocking = false,
+ trigger = "after",
+ delay = 0.6,
+ func = function()
+ while to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() do
+ MP.GHOST.advance_hand()
+ end
+ if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and MP.GHOST.playback_exhausted() then
+ MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
+ if MP.GAME.enemy.lives <= 0 then
+ MP.GAME.won = true
+ MP.MATCH_RECORD.finalize(true)
+ win_game()
+ MP.GHOST._advancing = false
+ return true
+ end
+ MP.GAME.end_pvp = true
+ end
+ MP.GHOST._advancing = false
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ end
+
+ if not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.DRAW_TO_HAND
+ end
elseif not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then
G.STATE_COMPLETE = false
G.STATE = G.STATES.DRAW_TO_HAND
@@ -242,19 +350,36 @@ function Game:update_new_round(dt)
G.STATE = G.STATES.NEW_ROUND
MP.GAME.end_pvp = false
end
- if MP.LOBBY.code and not G.STATE_COMPLETE then
+ if is_mp_or_ghost() and not G.STATE_COMPLETE then
+ local ghost = MP.GHOST.is_active()
-- Prevent player from losing
if to_big(G.GAME.chips) < to_big(G.GAME.blind.chips) and not MP.is_pvp_boss() then
G.GAME.blind.chips = -1
- MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival")
- and (tonumber(MP.GAME.lives) == 1) -- In Survival Mode, if this is the last live, wait for the enemy.
- MP.ACTIONS.fail_round(G.GAME.current_round.hands_played)
+ if ghost then
+ if MP.LOBBY.config.death_on_round_loss and G.GAME.current_round.hands_played > 0 then
+ MP.GAME.lives = MP.GAME.lives - 1
+ MP.UI.ease_lives(-1)
+ if MP.LOBBY.config.no_gold_on_round_loss and G.GAME.blind and G.GAME.blind.dollars then
+ G.GAME.blind.dollars = 0
+ end
+ if MP.GAME.lives <= 0 then
+ MP.MATCH_RECORD.finalize(false)
+ G.STATE = G.STATES.GAME_OVER
+ G.STATE_COMPLETE = false
+ return
+ end
+ end
+ else
+ MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival")
+ and (tonumber(MP.GAME.lives) == 1)
+ MP.ACTIONS.fail_round(G.GAME.current_round.hands_played)
+ end
end
-- Prevent player from winning
G.GAME.win_ante = 999
- if MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then
+ if not ghost and MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then
G.STATE_COMPLETE = true
G.FUNCS.draw_from_hand_to_discard()
attention_text({
@@ -283,14 +408,16 @@ function Game:update_selecting_hand(dt)
and #G.hand.cards < 1
and #G.deck.cards < 1
and #G.play.cards < 1
- and MP.LOBBY.code
+ and is_mp_or_ghost()
then
G.GAME.current_round.hands_left = 0
if not MP.is_pvp_boss() then
G.STATE_COMPLETE = false
G.STATE = G.STATES.NEW_ROUND
else
- MP.ACTIONS.play_hand(G.GAME.chips, 0)
+ if not MP.GHOST.is_active() then
+ MP.ACTIONS.play_hand(G.GAME.chips, 0)
+ end
G.STATE_COMPLETE = false
G.STATE = G.STATES.HAND_PLAYED
end
@@ -298,7 +425,7 @@ function Game:update_selecting_hand(dt)
end
update_selecting_hand_ref(self, dt)
- if MP.GAME.end_pvp and MP.is_pvp_boss() then
+ if MP.GAME.end_pvp and MP.is_pvp_boss() and is_mp_or_ghost() then
G.hand:unhighlight_all()
G.STATE_COMPLETE = false
G.STATE = G.STATES.NEW_ROUND
@@ -345,7 +472,8 @@ function Game:start_run(args)
start_run_ref(self, args)
- if not MP.LOBBY.connected or not MP.LOBBY.code or MP.LOBBY.config.disable_live_and_timer_hud then return end
+ local show_lives_hud = (MP.LOBBY.connected and MP.LOBBY.code) or MP.GHOST.is_active()
+ if not show_lives_hud or MP.LOBBY.config.disable_live_and_timer_hud then return end
local scale = 0.4
local hud_ante = G.HUD:get_UIE_by_ID("hud_ante")
@@ -370,7 +498,7 @@ end
-- This prevents duplicate execution during certain cases. e.g. Full deck discard before playing any hands.
function MP.handle_duplicate_end()
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
if MP.GAME.round_ended then
if not MP.GAME.duplicate_end then
MP.GAME.duplicate_end = true
@@ -385,15 +513,16 @@ end
-- This handles an edge case where a player plays no hands, and discards the only cards in their deck.
-- Allows opponent to advance after playing anything, and eases a life from the person who discarded their deck.
function MP.handle_deck_out()
- if MP.LOBBY.code then
+ if MP.LOBBY.code or MP.GHOST.is_active() then
if
G.GAME.current_round.hands_played == 0
and G.GAME.current_round.discards_used > 0
and MP.LOBBY.config.gamemode ~= "gamemode_mp_survival"
then
- if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end
-
- MP.ACTIONS.fail_round(1)
+ if not MP.GHOST.is_active() then
+ if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end
+ MP.ACTIONS.fail_round(1)
+ end
end
end
end
diff --git a/ui/game/round.lua b/ui/game/round.lua
index dbd5f5bf..d86abf53 100644
--- a/ui/game/round.lua
+++ b/ui/game/round.lua
@@ -3,7 +3,7 @@
local ease_ante_ref = ease_ante
function ease_ante(mod)
- if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then
+ if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud then
-- Prevents easing multiple times at once
if MP.GAME.antes_keyed[MP.GAME.ante_key] then return end
@@ -15,7 +15,9 @@ function ease_ante(mod)
end
MP.GAME.antes_keyed[MP.GAME.ante_key] = true
- MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod)
+ if not MP.GHOST.is_active() then
+ MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod)
+ end
G.E_MANAGER:add_event(Event({
trigger = "immediate",
func = function()
@@ -30,7 +32,7 @@ end
local ease_round_ref = ease_round
function ease_round(mod)
- if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end
+ if (MP.LOBBY.code or MP.GHOST.is_active()) and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end
ease_round_ref(mod)
end
@@ -38,13 +40,19 @@ local reset_blinds_ref = reset_blinds
function reset_blinds()
reset_blinds_ref()
G.GAME.round_resets.pvp_blind_choices = {}
- if MP.LOBBY.code then
+
+ local gamemode_key = MP.get_active_gamemode()
+ if gamemode_key and MP.Gamemodes[gamemode_key] then
local mp_small_choice, mp_big_choice, mp_boss_choice =
- MP.Gamemodes[MP.LOBBY.config.gamemode]:get_blinds_by_ante(G.GAME.round_resets.ante)
+ MP.Gamemodes[gamemode_key]:get_blinds_by_ante(G.GAME.round_resets.ante)
G.GAME.round_resets.blind_choices.Small = mp_small_choice or G.GAME.round_resets.blind_choices.Small
G.GAME.round_resets.blind_choices.Big = mp_big_choice or G.GAME.round_resets.blind_choices.Big
G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss
end
+
+ if MP.GHOST.is_active() then
+ MP.GHOST.init_playback(G.GAME.round_resets.ante)
+ end
end
-- necessary for showdown mode to ensure rounds progress properly, only affects nemesis blind to avoid possible incompatibilities (though i know many mods like to do this exact hook)
diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua
new file mode 100644
index 00000000..5522e2cd
--- /dev/null
+++ b/ui/main_menu/play_button/ghost_replay_picker.lua
@@ -0,0 +1,678 @@
+-- Match Replay Picker UI
+-- Shown in practice mode to select a past match replay for ghost PvP
+-- Two-column layout: replay list (left) + match details panel (right)
+
+local function reopen_practice_menu()
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ruleset_selection_options("practice"),
+ })
+end
+
+local function refresh_picker()
+ G.FUNCS.exit_overlay_menu()
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ghost_replay_picker(),
+ })
+end
+
+function G.FUNCS.open_ghost_replay_picker(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ghost_replay_picker(),
+ })
+end
+
+-- Stashed merged replay list so callbacks can index into it
+local _picker_replays = {}
+-- Currently previewed replay (shown in right panel)
+local _preview_idx = nil
+-- Perspective flip for the previewed replay (before loading)
+local _preview_flipped = false
+
+function G.FUNCS.preview_ghost_replay(e)
+ local idx = tonumber(e.config.id:match("ghost_replay_(%d+)"))
+ if idx ~= _preview_idx then
+ _preview_flipped = false
+ end
+ _preview_idx = idx
+ refresh_picker()
+end
+
+function G.FUNCS.load_previewed_ghost(e)
+ local replay = _picker_replays[_preview_idx]
+ if not replay then return end
+ if not is_replay_ruleset_supported(replay) then return end
+
+ MP.GHOST.load(replay)
+ MP.GHOST.flipped = _preview_flipped
+
+ if replay.ruleset then
+ MP.SP.ruleset = replay.ruleset
+ local ruleset_name = replay.ruleset:gsub("^ruleset_mp_", "")
+ MP.LoadReworks(ruleset_name)
+ end
+
+ _preview_idx = nil
+ _preview_flipped = false
+ reopen_practice_menu()
+end
+
+-- Keep old name working for any external callers
+function G.FUNCS.select_ghost_replay(e)
+ G.FUNCS.preview_ghost_replay(e)
+end
+
+function G.FUNCS.clear_ghost_replay(e)
+ MP.GHOST.clear()
+ _preview_idx = nil
+ _preview_flipped = false
+ reopen_practice_menu()
+end
+
+function G.FUNCS.flip_ghost_perspective(e)
+ if _preview_idx then
+ _preview_flipped = not _preview_flipped
+ else
+ MP.GHOST.flip()
+ end
+ refresh_picker()
+end
+
+G.FUNCS.ghost_picker_back = function(e)
+ _preview_idx = nil
+ _preview_flipped = false
+ reopen_practice_menu()
+end
+
+-------------------------------------------------------------------------------
+-- Helpers
+-------------------------------------------------------------------------------
+
+local function is_replay_ruleset_supported(replay)
+ if not replay or not replay.ruleset then return true end
+ return MP.Rulesets[replay.ruleset] ~= nil
+end
+
+local function build_replay_label(r)
+ local result_text = (r.winner == "player") and "W" or "L"
+ local player_display = r.player_name or "?"
+ local nemesis_display = r.nemesis_name or "?"
+ local ante_display = tostring(r.final_ante or "?")
+
+ local timestamp_display = ""
+ if r.timestamp then timestamp_display = os.date("%m/%d", r.timestamp) end
+
+ local game_tag = ""
+ if r._game_index and r._game_count and r._game_count > 1 then
+ game_tag = string.format(" [%d/%d]", r._game_index, r._game_count)
+ end
+
+ return string.format(
+ "%s %s v %s A%s %s%s",
+ result_text,
+ player_display,
+ nemesis_display,
+ ante_display,
+ timestamp_display,
+ game_tag
+ )
+end
+
+local function text_row(label, value, scale, label_colour, value_colour)
+ scale = scale or 0.3
+ label_colour = label_colour or G.C.UI.TEXT_INACTIVE
+ value_colour = value_colour or G.C.WHITE
+ return {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0.02 },
+ nodes = {
+ { n = G.UIT.T, config = { text = label .. " ", scale = scale, colour = label_colour } },
+ { n = G.UIT.T, config = { text = tostring(value), scale = scale, colour = value_colour } },
+ },
+ }
+end
+
+local function section_header(title, scale)
+ scale = scale or 0.32
+ return {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0.04 },
+ nodes = {
+ { n = G.UIT.T, config = { text = title, scale = scale, colour = G.C.GOLD } },
+ },
+ }
+end
+
+local function format_score(s)
+ local n = tonumber(s)
+ if not n then return tostring(s) end
+ if n >= 1000000 then
+ return string.format("%.1fM", n / 1000000)
+ elseif n >= 1000 then
+ return string.format("%.1fK", n / 1000)
+ end
+ return tostring(n)
+end
+
+local function build_joker_card_area(jokers, width)
+ if not jokers or #jokers == 0 then return nil end
+
+ width = width or 5.5
+ local card_size = math.max(0.3, 0.8 - 0.01 * #jokers)
+ local card_area = CardArea(0, 0, width, G.CARD_H * card_size, {
+ card_limit = nil,
+ type = "title_2",
+ view_deck = true,
+ highlight_limit = 0,
+ card_w = G.CARD_W * card_size,
+ })
+
+ for _, j in ipairs(jokers) do
+ local key = j.key or j
+ local center = G.P_CENTERS[key]
+ if center then
+ local card = Card(
+ 0,
+ 0,
+ G.CARD_W * card_size,
+ G.CARD_H * card_size,
+ nil,
+ center,
+ { bypass_discovery_center = true, bypass_discovery_ui = true }
+ )
+ card_area:emplace(card)
+ end
+ end
+
+ return {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.02 },
+ nodes = {
+ { n = G.UIT.O, config = { object = card_area } },
+ },
+ }
+end
+
+-------------------------------------------------------------------------------
+-- Stats detail panel (right column)
+-------------------------------------------------------------------------------
+
+local function build_stats_panel(r)
+ if not r then
+ return {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.2, minw = 6, minh = 5 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "Select a match",
+ scale = 0.35,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ },
+ }
+ end
+
+ -- Header row (spans both columns)
+ local header_nodes = {}
+
+ local result_str = (r.winner == "player") and "VICTORY" or "DEFEAT"
+ local result_colour = (r.winner == "player") and G.C.GREEN or G.C.RED
+ header_nodes[#header_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.04 },
+ nodes = {
+ { n = G.UIT.T, config = { text = result_str, scale = 0.4, colour = result_colour } },
+ },
+ }
+
+ local player_display = r.player_name or "?"
+ local nemesis_display = r.nemesis_name or "?"
+ header_nodes[#header_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.02 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = player_display .. " vs " .. nemesis_display, scale = 0.35, colour = G.C.WHITE },
+ },
+ },
+ }
+
+ -- Left inner column: match info + ante breakdown + stats
+ local left_nodes = {}
+
+ left_nodes[#left_nodes + 1] = section_header("Match Info")
+ if r._filename then
+ local source_label = r._filename
+ if r._game_index and r._game_count and r._game_count > 1 then
+ source_label = source_label .. string.format(" (game %d of %d)", r._game_index, r._game_count)
+ end
+ left_nodes[#left_nodes + 1] = text_row("Source:", source_label, 0.25)
+ end
+ local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?"
+ local gamemode_display = r.gamemode and r.gamemode:gsub("^gamemode_mp_", "") or "?"
+ local deck_display = r.deck or "?"
+ left_nodes[#left_nodes + 1] = text_row("Ruleset:", ruleset_display)
+ left_nodes[#left_nodes + 1] = text_row("Gamemode:", gamemode_display)
+ left_nodes[#left_nodes + 1] = text_row("Deck:", deck_display)
+ if r.seed then left_nodes[#left_nodes + 1] = text_row("Seed:", r.seed) end
+ if r.stake then left_nodes[#left_nodes + 1] = text_row("Stake:", tostring(r.stake)) end
+ left_nodes[#left_nodes + 1] = text_row("Final Ante:", tostring(r.final_ante or "?"))
+ if r.duration then left_nodes[#left_nodes + 1] = text_row("Duration:", r.duration) end
+ if r.timestamp then left_nodes[#left_nodes + 1] = text_row("Date:", os.date("%Y-%m-%d %H:%M", r.timestamp)) end
+
+ -- Ante breakdown
+ if r.ante_snapshots then
+ left_nodes[#left_nodes + 1] = section_header("Ante Breakdown")
+ local antes = {}
+ for k in pairs(r.ante_snapshots) do
+ antes[#antes + 1] = tonumber(k)
+ end
+ table.sort(antes)
+
+ for _, ante_num in ipairs(antes) do
+ local snap = r.ante_snapshots[tostring(ante_num)] or r.ante_snapshots[ante_num]
+ if snap then
+ local result_icon = snap.result == "win" and "W" or "L"
+ local r_col = snap.result == "win" and G.C.GREEN or G.C.RED
+ local p_score = format_score(snap.player_score or 0)
+ local e_score = format_score(snap.enemy_score or 0)
+ local lives_str = ""
+ if snap.player_lives and snap.enemy_lives then
+ lives_str = string.format(" [%d-%d]", snap.player_lives, snap.enemy_lives)
+ end
+ left_nodes[#left_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0.01 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = string.format("A%d ", ante_num),
+ scale = 0.28,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ { n = G.UIT.T, config = { text = result_icon, scale = 0.28, colour = r_col } },
+ {
+ n = G.UIT.T,
+ config = {
+ text = string.format(" %s - %s%s", p_score, e_score, lives_str),
+ scale = 0.28,
+ colour = G.C.WHITE,
+ },
+ },
+ },
+ }
+ end
+ end
+ end
+
+ -- Player/opponent stats
+ local function add_stats(nodes, stats, label)
+ if not stats then return end
+ nodes[#nodes + 1] = section_header(label)
+ if stats.reroll_count then nodes[#nodes + 1] = text_row("Rerolls:", tostring(stats.reroll_count), 0.28) end
+ if stats.reroll_cost_total then
+ nodes[#nodes + 1] = text_row("Reroll $:", tostring(stats.reroll_cost_total), 0.28)
+ end
+ if stats.vouchers then
+ nodes[#nodes + 1] =
+ text_row("Vouchers:", stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28)
+ end
+ end
+
+ add_stats(left_nodes, r.player_stats, "Your Stats")
+ add_stats(left_nodes, r.nemesis_stats, "Opponent Stats")
+
+ -- Failed rounds
+ if r.failed_rounds and #r.failed_rounds > 0 then
+ local fr_parts = {}
+ for _, a in ipairs(r.failed_rounds) do
+ fr_parts[#fr_parts + 1] = "A" .. tostring(a)
+ end
+ left_nodes[#left_nodes + 1] =
+ text_row("Failed Rounds:", table.concat(fr_parts, ", "), 0.28, G.C.UI.TEXT_INACTIVE, G.C.RED)
+ end
+
+ -- Right inner column: jokers + shop spending
+ local right_nodes = {}
+
+ if r.player_jokers then
+ right_nodes[#right_nodes + 1] = section_header("Your Jokers")
+ local joker_area = build_joker_card_area(r.player_jokers, 4)
+ if joker_area then right_nodes[#right_nodes + 1] = joker_area end
+ end
+ if r.nemesis_jokers then
+ right_nodes[#right_nodes + 1] = section_header("Opponent Jokers")
+ local joker_area = build_joker_card_area(r.nemesis_jokers, 4)
+ if joker_area then right_nodes[#right_nodes + 1] = joker_area end
+ end
+
+ -- Shop spending
+ if r.shop_spending then
+ right_nodes[#right_nodes + 1] = section_header("Shop Spending")
+ local total = 0
+ local antes = {}
+ for k, v in pairs(r.shop_spending) do
+ antes[#antes + 1] = tonumber(k)
+ total = total + v
+ end
+ table.sort(antes)
+ local parts = {}
+ for _, a in ipairs(antes) do
+ parts[#parts + 1] = string.format("A%d:$%d", a, r.shop_spending[tostring(a)] or r.shop_spending[a])
+ end
+ right_nodes[#right_nodes + 1] = text_row("Total:", "$" .. tostring(total), 0.28)
+ right_nodes[#right_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0.02, maxw = 4 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = table.concat(parts, " "), scale = 0.24, colour = G.C.UI.TEXT_INACTIVE },
+ },
+ },
+ }
+ end
+
+ -- Assemble two-column body
+ local body_row = {
+ n = G.UIT.R,
+ config = { align = "tm", padding = 0.05 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.08, minw = 4 },
+ nodes = left_nodes,
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.08, minw = 4.5 },
+ nodes = right_nodes,
+ },
+ },
+ }
+
+ -- Playing-as flip button + Load button (spans full width)
+ local playing_as = _preview_flipped
+ and (r.nemesis_name or "?")
+ or (r.player_name or "?")
+
+ local supported = is_replay_ruleset_supported(r)
+ local action_nodes = {}
+
+ if supported then
+ action_nodes[#action_nodes + 1] = UIBox_button({
+ id = "flip_ghost_perspective",
+ button = "flip_ghost_perspective",
+ label = { "Playing as: " .. playing_as },
+ minw = 3.5,
+ minh = 0.5,
+ scale = 0.3,
+ colour = G.C.BLUE,
+ hover = true,
+ shadow = true,
+ })
+ action_nodes[#action_nodes + 1] = UIBox_button({
+ id = "load_previewed_ghost",
+ button = "load_previewed_ghost",
+ label = { "Play Match" },
+ minw = 3.5,
+ minh = 0.5,
+ scale = 0.35,
+ colour = G.C.GREEN,
+ hover = true,
+ shadow = true,
+ })
+ else
+ action_nodes[#action_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.04 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "Unsupported ruleset — cannot play this replay",
+ scale = 0.3,
+ colour = G.C.RED,
+ },
+ },
+ },
+ }
+ end
+
+ local load_button = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08 },
+ nodes = action_nodes,
+ }
+
+ return {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.1, minw = 9, r = 0.1, colour = G.C.L_BLACK },
+ nodes = {
+ -- Header spanning both columns
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = { { n = G.UIT.C, config = { align = "cm" }, nodes = header_nodes } },
+ },
+ -- Two-column body
+ body_row,
+ -- Full-width button
+ load_button,
+ },
+ }
+end
+
+-------------------------------------------------------------------------------
+-- Main picker UI
+-------------------------------------------------------------------------------
+
+function G.UIDEF.ghost_replay_picker()
+ -- Load replays from the replays/ folder, sorted newest-first
+ local all = MP.GHOST.load_folder_replays()
+
+ -- Sort newest-first
+ table.sort(all, function(a, b)
+ return (a.timestamp or 0) > (b.timestamp or 0)
+ end)
+
+ -- Stash for callbacks to index into
+ _picker_replays = all
+
+ -- Left column: replay list
+ local replay_nodes = {}
+
+ if #all == 0 then
+ replay_nodes[#replay_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_no_ghost_replays"),
+ scale = 0.35,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ },
+ }
+ replay_nodes[#replay_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.02 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "Place .log or .json files in the replays/ folder",
+ scale = 0.28,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ },
+ }
+ else
+ local last_filename = nil
+ for i, r in ipairs(all) do
+ -- Show filename header when entering a new log file group with multiple games
+ if r._filename and r._game_count and r._game_count > 1 then
+ if r._filename ~= last_filename then
+ last_filename = r._filename
+ local display_name = r._filename:gsub("%.log$", "")
+ replay_nodes[#replay_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0.02 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = display_name .. " (" .. r._game_count .. " games)",
+ scale = 0.25,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ },
+ }
+ end
+ else
+ last_filename = nil
+ end
+
+ local label = build_replay_label(r)
+ local is_selected = (_preview_idx == i)
+ local btn_colour
+ if is_selected then
+ btn_colour = G.C.WHITE
+ elseif r._source == "file" then
+ btn_colour = G.C.BLUE
+ else
+ btn_colour = G.C.GREY
+ end
+
+ replay_nodes[#replay_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.03 },
+ nodes = {
+ UIBox_button({
+ id = "ghost_replay_" .. i,
+ button = "preview_ghost_replay",
+ label = { label },
+ minw = 5.5,
+ minh = 0.45,
+ scale = 0.3,
+ colour = btn_colour,
+ hover = true,
+ shadow = true,
+ }),
+ },
+ }
+ end
+ end
+
+ -- Control buttons below the list
+ local control_nodes = {}
+
+ if MP.GHOST.is_active() then
+ control_nodes[#control_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.03 },
+ nodes = {
+ UIBox_button({
+ id = "clear_ghost_replay",
+ button = "clear_ghost_replay",
+ label = { "Clear Replay" },
+ minw = 3,
+ minh = 0.45,
+ scale = 0.3,
+ colour = G.C.RED,
+ hover = true,
+ shadow = true,
+ }),
+ },
+ }
+ end
+
+ -- Back button
+ control_nodes[#control_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = {
+ UIBox_button({
+ id = "ghost_picker_back",
+ button = "ghost_picker_back",
+ label = { localize("b_back") },
+ minw = 3,
+ minh = 0.5,
+ scale = 0.35,
+ colour = G.C.ORANGE,
+ hover = true,
+ shadow = true,
+ }),
+ },
+ }
+
+ -- Left column
+ local left_col = {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.1, minw = 6, r = 0.1, colour = G.C.L_BLACK },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.06 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_ghost_replays"),
+ scale = 0.45,
+ colour = G.C.WHITE,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05, maxh = 5 },
+ nodes = replay_nodes,
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = control_nodes,
+ },
+ },
+ }
+
+ -- Right column: stats detail
+ local preview_replay = _preview_idx and _picker_replays[_preview_idx] or nil
+ local right_col = build_stats_panel(preview_replay)
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", colour = G.C.CLEAR, minh = 7, minw = 13 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.15 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.15, r = 0.1, colour = G.C.BLACK },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "tm", padding = 0.05 },
+ nodes = { left_col, right_col },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+end
diff --git a/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua
index 71de6c20..3c34fd54 100644
--- a/ui/main_menu/play_button/play_button.lua
+++ b/ui/main_menu/play_button/play_button.lua
@@ -49,7 +49,7 @@ function G.UIDEF.override_main_menu_play_button()
UIBox_button({
label = { localize("b_sp_with_ruleset") },
colour = G.C.ORANGE,
- button = "setup_run_singleplayer",
+ button = "setup_practice_mode",
minw = 5,
}),
MP.LOBBY.connected and UIBox_button({
diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua
index 6e1a7baf..56c6505c 100644
--- a/ui/main_menu/play_button/play_button_callbacks.lua
+++ b/ui/main_menu/play_button/play_button_callbacks.lua
@@ -1,11 +1,17 @@
-- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer)
-MP.SP = { ruleset = nil }
+MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, edition_cycling = false }
+
+function MP.is_practice_mode()
+ return MP.SP.practice == true
+end
function G.FUNCS.setup_run_singleplayer(e)
G.SETTINGS.paused = true
MP.LOBBY.config.ruleset = nil
MP.LOBBY.config.gamemode = nil
MP.SP.ruleset = nil
+ MP.SP.practice = false
+ MP.GHOST.clear()
G.FUNCS.overlay_menu({
definition = G.UIDEF.ruleset_selection_options("sp"),
@@ -21,9 +27,61 @@ function G.FUNCS.start_vanilla_sp(e)
MP.LOBBY.config.ruleset = nil
MP.LOBBY.config.gamemode = nil
MP.SP.ruleset = nil
+ MP.SP.practice = false
+ MP.GHOST.clear()
G.FUNCS.setup_run(e)
end
+function G.FUNCS.setup_practice_mode(e)
+ G.SETTINGS.paused = true
+ MP.LOBBY.config.ruleset = nil
+ MP.LOBBY.config.gamemode = nil
+ MP.SP.ruleset = nil
+ MP.SP.practice = true
+ MP.SP.unlimited_slots = false
+ MP.SP.edition_cycling = false
+ MP.GHOST.clear()
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ruleset_selection_options("practice"),
+ })
+end
+
+function G.FUNCS.start_practice_run(e)
+ G.FUNCS.exit_overlay_menu()
+ if MP.GHOST.is_active() then
+ local r = MP.GHOST.replay
+ MP.reset_game_states()
+ local starting_lives = MP.LOBBY.config.starting_lives or 4
+ MP.GAME.lives = starting_lives
+ MP.GAME.enemy.lives = starting_lives
+ local deck_key = MP.UTILS.get_deck_key_from_name(r.deck)
+ if deck_key then G.GAME.viewed_back = G.P_CENTERS[deck_key] end
+ G.FUNCS.start_run(e, { seed = r.seed, stake = r.stake or 1 })
+ sendDebugMessage(
+ string.format(
+ "Practice run state: practice=%s, ghost=%s, ruleset=%s, gamemode=%s, deck_key=%s, lives=%s, enemy_lives=%s, seed=%s, stake=%s",
+ tostring(MP.is_practice_mode()),
+ tostring(MP.GHOST.is_active()),
+ tostring(MP.get_active_ruleset()),
+ tostring(MP.get_active_gamemode()),
+ tostring(deck_key),
+ tostring(MP.GAME.lives),
+ tostring(MP.GAME.enemy.lives),
+ tostring(G.GAME.pseudorandom and G.GAME.pseudorandom.seed or "?"),
+ tostring(G.GAME.stake or "?")
+ ),
+ "MULTIPLAYER"
+ )
+ else
+ G.FUNCS.setup_run(e)
+ end
+end
+
+function G.FUNCS.toggle_unlimited_slots(e)
+ MP.SP.unlimited_slots = not MP.SP.unlimited_slots
+end
+
function G.FUNCS.play_options(e)
G.SETTINGS.paused = true
@@ -92,6 +150,9 @@ end
function G.FUNCS.start_lobby(e)
G.SETTINGS.paused = false
+ MP.SP.practice = false
+ MP.GHOST.clear()
+
MP.reset_lobby_config(true)
MP.LOBBY.config.multiplayer_jokers = MP.Rulesets[MP.LOBBY.config.ruleset].multiplayer_content
diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua
index d6146eb0..77b298e5 100644
--- a/ui/main_menu/play_button/ruleset_selection.lua
+++ b/ui/main_menu/play_button/ruleset_selection.lua
@@ -2,11 +2,16 @@ function G.UIDEF.ruleset_selection_options(mode)
mode = mode or "mp"
MP.LOBBY.fetched_weekly = "smallworld" -- temp
- -- SP defaults to vanilla, MP defaults to ranked
- local default_ruleset = "standard_ranked"
+ -- If ghost is active, preserve the replay's ruleset instead of resetting to default
+ local default_ruleset
+ if mode == "practice" and MP.GHOST.is_active() and MP.SP.ruleset then
+ default_ruleset = MP.SP.ruleset:gsub("^ruleset_mp_", "")
+ else
+ default_ruleset = "standard_ranked"
+ end
local default_button = default_ruleset .. "_ruleset_button"
- if mode == "sp" then
+ if mode == "sp" or mode == "practice" then
MP.SP.ruleset = "ruleset_mp_" .. default_ruleset
else
MP.LOBBY.config.ruleset = "ruleset_mp_" .. default_ruleset
@@ -76,7 +81,7 @@ function G.FUNCS.change_ruleset_selection(e)
end,
default_button,
function(ruleset_name)
- if mode == "sp" then
+ if mode == "sp" or mode == "practice" then
MP.SP.ruleset = "ruleset_mp_" .. ruleset_name
else
MP.LOBBY.config.ruleset = "ruleset_mp_" .. ruleset_name
@@ -99,7 +104,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode)
local ruleset_disabled = ruleset.is_disabled()
- -- Different button config for SP vs MP
+ -- Different button config for SP vs MP vs Practice
local button_config
if mode == "sp" then
button_config = {
@@ -108,6 +113,13 @@ function G.UIDEF.ruleset_info(ruleset_name, mode)
label = { localize("b_play_cap") },
colour = G.C.GREEN,
}
+ elseif mode == "practice" then
+ button_config = {
+ id = "start_practice_button",
+ button = "start_practice_run",
+ label = { localize("b_play_cap") },
+ colour = G.C.GREEN,
+ }
else
button_config = {
id = "select_gamemode_button",
@@ -117,6 +129,84 @@ function G.UIDEF.ruleset_info(ruleset_name, mode)
}
end
+ local content_nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } },
+ },
+ },
+ }
+
+ if mode == "practice" then
+ local practice_toggles = {
+ { id = "unlimited_slots_toggle", label = "k_unlimited_slots", ref_value = "unlimited_slots" },
+ { id = "edition_cycling_toggle", label = "k_edition_cycling", ref_value = "edition_cycling" },
+ }
+ local toggle_nodes = {}
+ for _, t in ipairs(practice_toggles) do
+ toggle_nodes[#toggle_nodes + 1] = create_toggle({
+ id = t.id,
+ label = localize(t.label),
+ ref_table = MP.SP,
+ ref_value = t.ref_value,
+ })
+ end
+ content_nodes[#content_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = toggle_nodes,
+ }
+
+ -- Ghost replay picker button
+ local ghost_label = localize("k_ghost_replays")
+ if MP.GHOST.is_active() then
+ ghost_label = ghost_label .. " (Active)"
+ end
+ content_nodes[#content_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = {
+ UIBox_button({
+ id = "ghost_replay_button",
+ button = "open_ghost_replay_picker",
+ label = { ghost_label },
+ minw = 4,
+ minh = 0.6,
+ scale = 0.4,
+ colour = MP.GHOST.is_active() and G.C.GREEN or G.C.BLUE,
+ hover = true,
+ shadow = true,
+ }),
+ },
+ }
+ end
+
+ content_nodes[#content_nodes + 1] = {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ MP.UI.Disableable_Button({
+ id = button_config.id,
+ button = button_config.button,
+ align = "cm",
+ padding = 0.05,
+ r = 0.1,
+ minw = 8,
+ minh = 0.8,
+ colour = button_config.colour,
+ hover = true,
+ shadow = true,
+ label = button_config.label,
+ scale = 0.5,
+ enabled_ref_table = { val = not ruleset_disabled },
+ enabled_ref_value = "val",
+ disabled_text = { ruleset_disabled },
+ }),
+ },
+ }
+
return {
n = G.UIT.ROOT,
config = { align = "tm", minh = 8, maxh = 8, minw = 11, maxw = 11, colour = G.C.CLEAR },
@@ -124,38 +214,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode)
{
n = G.UIT.C,
config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK },
- nodes = {
- {
- n = G.UIT.R,
- config = { align = "cm" },
- nodes = {
- { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } },
- },
- },
- {
- n = G.UIT.R,
- config = { align = "cm" },
- nodes = {
- MP.UI.Disableable_Button({
- id = button_config.id,
- button = button_config.button,
- align = "cm",
- padding = 0.05,
- r = 0.1,
- minw = 8,
- minh = 0.8,
- colour = button_config.colour,
- hover = true,
- shadow = true,
- label = button_config.label,
- scale = 0.5,
- enabled_ref_table = { val = not ruleset_disabled },
- enabled_ref_value = "val",
- disabled_text = { ruleset_disabled },
- }),
- },
- },
- },
+ nodes = content_nodes,
},
},
}