From da683aa3e5f3230556bdea06b036443f24310de4 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 4 Mar 2026 02:09:04 +0100 Subject: [PATCH 01/36] practice collection it1 --- config.lua | 1 + localization/en-us.lua | 3 + lovely/hud.toml | 17 ++++ networking/action_handlers.lua | 19 ++++ ui/main_menu/play_button/play_button.lua | 6 ++ .../play_button/play_button_callbacks.lua | 30 +++++- .../play_button/ruleset_selection.lua | 95 ++++++++++++------- 7 files changed, 135 insertions(+), 36 deletions(-) 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/localization/en-us.lua b/localization/en-us.lua index 8df7f5fc..ace1e806 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_practice_give_hint = "Press 3 on a card in Collection to give it to yourself", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", 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/networking/action_handlers.lua b/networking/action_handlers.lua index 3f52ad00..948aa7a2 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -236,6 +236,8 @@ 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 + + MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back) end local function begin_pvp_blind() @@ -340,6 +342,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 +383,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 +393,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/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua index 71de6c20..5c3a3b28 100644 --- a/ui/main_menu/play_button/play_button.lua +++ b/ui/main_menu/play_button/play_button.lua @@ -52,6 +52,12 @@ function G.UIDEF.override_main_menu_play_button() button = "setup_run_singleplayer", minw = 5, }), + UIBox_button({ + label = { localize("b_practice") }, + colour = G.C.SECONDARY_SET.Planet, + button = "setup_practice_mode", + minw = 5, + }), MP.LOBBY.connected and UIBox_button({ label = { localize("b_create_lobby") }, colour = G.C.GREEN, diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 6e1a7baf..5cc02e3c 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,11 +1,16 @@ -- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -MP.SP = { ruleset = nil } +MP.SP = { ruleset = nil, practice = false, unlimited_slots = 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 G.FUNCS.overlay_menu({ definition = G.UIDEF.ruleset_selection_options("sp"), @@ -21,9 +26,32 @@ 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 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 + + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +function G.FUNCS.start_practice_run(e) + G.FUNCS.exit_overlay_menu() + G.FUNCS.setup_run(e) +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 diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index d6146eb0..1aee3a90 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -6,7 +6,7 @@ function G.UIDEF.ruleset_selection_options(mode) local default_ruleset = "standard_ranked" 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 +76,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 +99,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 +108,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 +124,55 @@ 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 + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + create_toggle({ + id = "unlimited_slots_toggle", + label = localize("k_unlimited_slots"), + ref_table = MP.SP, + ref_value = "unlimited_slots", + }), + }, + } + 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 +180,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) { n = G.UIT.C, config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } }, - }, - }, - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - MP.UI.Disableable_Button({ - id = button_config.id, - button = button_config.button, - align = "cm", - padding = 0.05, - r = 0.1, - minw = 8, - minh = 0.8, - colour = button_config.colour, - hover = true, - shadow = true, - label = button_config.label, - scale = 0.5, - enabled_ref_table = { val = not ruleset_disabled }, - enabled_ref_value = "val", - disabled_text = { ruleset_disabled }, - }), - }, - }, - }, + nodes = content_nodes, }, }, } From 6793fb72f0957271b2ec9328abbe23dd501b6b54 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sun, 8 Mar 2026 12:32:38 +0100 Subject: [PATCH 02/36] practice collection v2 wip - ghost replays and stuff --- lib/match_history.lua | 69 +++++++++++ localization/en-us.lua | 5 +- lovely/practice.toml | 115 ++++++++++++++++++ overrides/game.lua | 9 +- ui/main_menu/play_button/play_button.lua | 6 - .../play_button/play_button_callbacks.lua | 5 +- .../play_button/ruleset_selection.lua | 24 ++-- 7 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 lib/match_history.lua create mode 100644 lovely/practice.toml diff --git a/lib/match_history.lua b/lib/match_history.lua new file mode 100644 index 00000000..539f4c89 --- /dev/null +++ b/lib/match_history.lua @@ -0,0 +1,69 @@ +-- Ghost Replay data foundation (data capture only, no replay UI) +-- Captures per-ante snapshots for future ghost replay feature. + +MP.MATCH_RECORD = { + seed = nil, + ruleset = nil, + gamemode = nil, + deck = 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.ante_snapshots = {} + MP.MATCH_RECORD.winner = nil + MP.MATCH_RECORD.final_ante = nil +end + +function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck) + MP.MATCH_RECORD.reset() + MP.MATCH_RECORD.seed = seed + MP.MATCH_RECORD.ruleset = ruleset + MP.MATCH_RECORD.gamemode = gamemode + MP.MATCH_RECORD.deck = deck +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) + MP.MATCH_RECORD.winner = won and "player" or "enemy" + MP.MATCH_RECORD.final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1 + + local config = SMODS.Mods["Multiplayer"].config + config.ghost_replays = config.ghost_replays or {} + + local entry = { + seed = MP.MATCH_RECORD.seed, + ruleset = MP.MATCH_RECORD.ruleset, + gamemode = MP.MATCH_RECORD.gamemode, + deck = MP.MATCH_RECORD.deck, + ante_snapshots = MP.MATCH_RECORD.ante_snapshots, + winner = MP.MATCH_RECORD.winner, + final_ante = MP.MATCH_RECORD.final_ante, + timestamp = os.time(), + } + + table.insert(config.ghost_replays, entry) + + -- Keep only last 20 replays + while #config.ghost_replays > 20 do + table.remove(config.ghost_replays, 1) + end + + SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) +end diff --git a/localization/en-us.lua b/localization/en-us.lua index ace1e806..d38e0d3a 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -903,7 +903,10 @@ return { b_sp_with_ruleset = "Practice Mode", b_practice = "Practice", k_unlimited_slots = "Unlimited Slots", - k_practice_give_hint = "Press 3 on a card in Collection to give it to yourself", + k_unlimited_money = "Unlimited Money", + k_free_rerolls = "Free Rerolls", + k_edition_cycling = "Edition Cycling (Q)", + k_practice_give_hint = "Click a card in Collection to give it to yourself", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", diff --git a/lovely/practice.toml b/lovely/practice.toml new file mode 100644 index 00000000..b3d13120 --- /dev/null +++ b/lovely/practice.toml @@ -0,0 +1,115 @@ +[manifest] +version = "1.0.0" +dump_lua = true +priority = 2147483600 + +# Practice mode: give cards from collection via key "3" or left-click. +# Also: unlimited money, free rerolls, 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 + +# Unlimited money: refill dollars at start of each round +[[patches]] +[patches.pattern] +target = "functions/state_events.lua" +pattern = '''G.STATE = G.STATES.SELECTING_HAND''' +position = 'after' +payload = ''' +if MP and MP.is_practice_mode() and MP.SP.unlimited_money and not MP.LOBBY.code then + G.GAME.dollars = math.max(G.GAME.dollars, 999) +end +''' +match_indent = true +times = 1 + +# Free rerolls: zero out reroll cost each time shop is entered +[[patches]] +[patches.pattern] +target = "functions/state_events.lua" +pattern = '''G.STATE = G.STATES.SHOP''' +position = 'after' +payload = ''' +if MP and MP.is_practice_mode() and MP.SP.free_rerolls and not MP.LOBBY.code then + G.GAME.current_round.reroll_cost = 0 +end +''' +match_indent = true +times = 1 diff --git a/overrides/game.lua b/overrides/game.lua index 47fbe745..54ab8da0 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -36,7 +36,14 @@ function G.FUNCS.reroll_shop(e) MP.GAME.stats.reroll_cost_total = MP.GAME.stats.reroll_cost_total + G.GAME.current_round.reroll_cost end - return reroll_shop_ref(e) + local result = reroll_shop_ref(e) + + -- Practice mode: keep rerolls free after each reroll + if MP.is_practice_mode() and MP.SP.free_rerolls and not MP.LOBBY.code then + G.GAME.current_round.reroll_cost = 0 + end + + return result end local buy_from_shop_ref = G.FUNCS.buy_from_shop diff --git a/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua index 5c3a3b28..3c34fd54 100644 --- a/ui/main_menu/play_button/play_button.lua +++ b/ui/main_menu/play_button/play_button.lua @@ -49,12 +49,6 @@ function G.UIDEF.override_main_menu_play_button() UIBox_button({ label = { localize("b_sp_with_ruleset") }, colour = G.C.ORANGE, - button = "setup_run_singleplayer", - minw = 5, - }), - UIBox_button({ - label = { localize("b_practice") }, - colour = G.C.SECONDARY_SET.Planet, button = "setup_practice_mode", minw = 5, }), diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 5cc02e3c..2dd45bfa 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,5 +1,5 @@ -- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -MP.SP = { ruleset = nil, practice = false, unlimited_slots = false } +MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, unlimited_money = false, free_rerolls = false, edition_cycling = false } function MP.is_practice_mode() return MP.SP.practice == true @@ -37,6 +37,9 @@ function G.FUNCS.setup_practice_mode(e) MP.SP.ruleset = nil MP.SP.practice = true MP.SP.unlimited_slots = false + MP.SP.unlimited_money = false + MP.SP.free_rerolls = false + MP.SP.edition_cycling = false G.FUNCS.overlay_menu({ definition = G.UIDEF.ruleset_selection_options("practice"), diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 1aee3a90..45ea2c30 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -135,17 +135,25 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) } if mode == "practice" then + local practice_toggles = { + { id = "unlimited_slots_toggle", label = "k_unlimited_slots", ref_value = "unlimited_slots" }, + { id = "unlimited_money_toggle", label = "k_unlimited_money", ref_value = "unlimited_money" }, + { id = "free_rerolls_toggle", label = "k_free_rerolls", ref_value = "free_rerolls" }, + { 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 = { - create_toggle({ - id = "unlimited_slots_toggle", - label = localize("k_unlimited_slots"), - ref_table = MP.SP, - ref_value = "unlimited_slots", - }), - }, + nodes = toggle_nodes, } end From c41d79cc5c246ae85cd19898f0eea21a0e9530b9 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 18:07:10 +0100 Subject: [PATCH 03/36] practice mode supports gamemodes --- rulesets/_rulesets.lua | 23 ++++++++++++++--------- ui/game/round.lua | 6 ++++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 4763a9c3..3d19421c 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -56,16 +56,21 @@ function MP.get_active_ruleset() 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 + 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 + return nil +end + +function MP.ApplyBans() + local ruleset_key = MP.get_active_ruleset() + local gamemode_key = MP.get_active_gamemode() + local gamemode = gamemode_key and MP.Gamemodes[gamemode_key] or nil if ruleset_key then local ruleset = MP.Rulesets[ruleset_key] diff --git a/ui/game/round.lua b/ui/game/round.lua index dbd5f5bf..2a876b8d 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -38,9 +38,11 @@ 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 From e1367eaeb033bb1e124834ef733d75e2e366de8b Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 19:47:10 +0100 Subject: [PATCH 04/36] remove dead string --- localization/en-us.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index d38e0d3a..f7a442cd 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -906,7 +906,6 @@ return { k_unlimited_money = "Unlimited Money", k_free_rerolls = "Free Rerolls", k_edition_cycling = "Edition Cycling (Q)", - k_practice_give_hint = "Click a card in Collection to give it to yourself", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", From 207957fff75b5809dc92480df5b2fde8933cae3f Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:02:06 +0100 Subject: [PATCH 05/36] tighten is_practice_mode checks for documentation --- rulesets/_rulesets.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 3d19421c..4e6571a8 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,7 +50,7 @@ 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 @@ -59,10 +59,9 @@ end function MP.get_active_gamemode() if MP.LOBBY.code then return MP.LOBBY.config.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 + elseif MP.is_practice_mode() then + 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 From 366573af98b9d5f9126c6d3c18fcf798cfc95497 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:10:36 +0100 Subject: [PATCH 06/36] default practice mode to the order on --- core.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core.lua b/core.lua index 8391e4a9..6b39a4a3 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 + return true + end + return false end function MP.is_major_league_ruleset() From a26463fe8adc17b9ade42e1ee13b33a49dc1eb31 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:18:08 +0100 Subject: [PATCH 07/36] remove none working features --- localization/en-us.lua | 2 -- lovely/practice.toml | 28 ------------------- overrides/game.lua | 5 ---- .../play_button/play_button_callbacks.lua | 4 +-- .../play_button/ruleset_selection.lua | 2 -- 5 files changed, 1 insertion(+), 40 deletions(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index f7a442cd..de9dae17 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -903,8 +903,6 @@ return { b_sp_with_ruleset = "Practice Mode", b_practice = "Practice", k_unlimited_slots = "Unlimited Slots", - k_unlimited_money = "Unlimited Money", - k_free_rerolls = "Free Rerolls", k_edition_cycling = "Edition Cycling (Q)", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", diff --git a/lovely/practice.toml b/lovely/practice.toml index b3d13120..cd3b677d 100644 --- a/lovely/practice.toml +++ b/lovely/practice.toml @@ -85,31 +85,3 @@ payload = ''' ''' match_indent = true times = 1 - -# Unlimited money: refill dollars at start of each round -[[patches]] -[patches.pattern] -target = "functions/state_events.lua" -pattern = '''G.STATE = G.STATES.SELECTING_HAND''' -position = 'after' -payload = ''' -if MP and MP.is_practice_mode() and MP.SP.unlimited_money and not MP.LOBBY.code then - G.GAME.dollars = math.max(G.GAME.dollars, 999) -end -''' -match_indent = true -times = 1 - -# Free rerolls: zero out reroll cost each time shop is entered -[[patches]] -[patches.pattern] -target = "functions/state_events.lua" -pattern = '''G.STATE = G.STATES.SHOP''' -position = 'after' -payload = ''' -if MP and MP.is_practice_mode() and MP.SP.free_rerolls and not MP.LOBBY.code then - G.GAME.current_round.reroll_cost = 0 -end -''' -match_indent = true -times = 1 diff --git a/overrides/game.lua b/overrides/game.lua index 54ab8da0..4f1a4183 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -38,11 +38,6 @@ function G.FUNCS.reroll_shop(e) local result = reroll_shop_ref(e) - -- Practice mode: keep rerolls free after each reroll - if MP.is_practice_mode() and MP.SP.free_rerolls and not MP.LOBBY.code then - G.GAME.current_round.reroll_cost = 0 - end - return result end diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 2dd45bfa..f94b049f 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,5 +1,5 @@ -- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, unlimited_money = false, free_rerolls = false, edition_cycling = false } +MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, edition_cycling = false } function MP.is_practice_mode() return MP.SP.practice == true @@ -37,8 +37,6 @@ function G.FUNCS.setup_practice_mode(e) MP.SP.ruleset = nil MP.SP.practice = true MP.SP.unlimited_slots = false - MP.SP.unlimited_money = false - MP.SP.free_rerolls = false MP.SP.edition_cycling = false G.FUNCS.overlay_menu({ diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 45ea2c30..51e870d2 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -137,8 +137,6 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) if mode == "practice" then local practice_toggles = { { id = "unlimited_slots_toggle", label = "k_unlimited_slots", ref_value = "unlimited_slots" }, - { id = "unlimited_money_toggle", label = "k_unlimited_money", ref_value = "unlimited_money" }, - { id = "free_rerolls_toggle", label = "k_free_rerolls", ref_value = "free_rerolls" }, { id = "edition_cycling_toggle", label = "k_edition_cycling", ref_value = "edition_cycling" }, } local toggle_nodes = {} From a7e5eeeb0e9e95bc716aef7c51cd49da9af80652 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:20:42 +0100 Subject: [PATCH 08/36] add comments and remove rebase rests --- core.lua | 2 +- lovely/practice.toml | 2 +- overrides/game.lua | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/core.lua b/core.lua index 6b39a4a3..665890e8 100644 --- a/core.lua +++ b/core.lua @@ -99,7 +99,7 @@ MP.REQUIRED_LOVELY_VERSION = "0.9" function MP.should_use_the_order() 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 + elseif MP.is_practice_mode() then -- should actually check the ruleset but okay for now return true end return false diff --git a/lovely/practice.toml b/lovely/practice.toml index cd3b677d..06180042 100644 --- a/lovely/practice.toml +++ b/lovely/practice.toml @@ -4,7 +4,7 @@ dump_lua = true priority = 2147483600 # Practice mode: give cards from collection via key "3" or left-click. -# Also: unlimited money, free rerolls, edition cycling. +# Also: edition cycling. # Shared helper function for giving a hovered card to the player [[patches]] diff --git a/overrides/game.lua b/overrides/game.lua index 4f1a4183..47fbe745 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -36,9 +36,7 @@ function G.FUNCS.reroll_shop(e) MP.GAME.stats.reroll_cost_total = MP.GAME.stats.reroll_cost_total + G.GAME.current_round.reroll_cost end - local result = reroll_shop_ref(e) - - return result + return reroll_shop_ref(e) end local buy_from_shop_ref = G.FUNCS.buy_from_shop From a92a0a5cd5ae22b4e94051ed4ba58a8000cc8924 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:21:49 +0100 Subject: [PATCH 09/36] save stake in match record --- lib/match_history.lua | 3 ++- networking/action_handlers.lua | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 539f4c89..6361f56c 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -21,12 +21,13 @@ function MP.MATCH_RECORD.reset() MP.MATCH_RECORD.final_ante = nil end -function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck) +function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck, stake) 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 end function MP.MATCH_RECORD.snapshot_ante(ante, data) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 948aa7a2..589daa40 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -237,7 +237,7 @@ local function action_start_game(seed, stake_str) G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false - MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back) + MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back, stake_str) end local function begin_pvp_blind() From c93e5c0a1d8258c0deb19e9dae1bfce2aee13b5d Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 20:26:14 +0100 Subject: [PATCH 10/36] add match history comment --- lib/match_history.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 6361f56c..3e0d0b62 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -1,5 +1,15 @@ --- Ghost Replay data foundation (data capture only, no replay UI) --- Captures per-ante snapshots for future ghost replay feature. +-- 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, From 5d26c50eae7fbcee5647639abbad1b96c2a6751d Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 10 Mar 2026 22:53:54 +0100 Subject: [PATCH 11/36] Basic UI flow --- lib/match_history.lua | 66 +++++- localization/en-us.lua | 3 + ui/game/blind_choice.lua | 21 +- ui/game/blind_hud.lua | 6 +- ui/game/functions.lua | 12 +- ui/game/game_state.lua | 112 ++++++--- ui/game/round.lua | 17 +- .../play_button/ghost_replay_picker.lua | 213 ++++++++++++++++++ .../play_button/play_button_callbacks.lua | 18 +- .../play_button/ruleset_selection.lua | 23 ++ 10 files changed, 438 insertions(+), 53 deletions(-) create mode 100644 ui/main_menu/play_button/ghost_replay_picker.lua diff --git a/lib/match_history.lua b/lib/match_history.lua index 3e0d0b62..46fb1e4e 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -52,7 +52,10 @@ function MP.MATCH_RECORD.snapshot_ante(ante, data) end function MP.MATCH_RECORD.finalize(won) - MP.MATCH_RECORD.winner = won and "player" or "enemy" + -- 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 local config = SMODS.Mods["Multiplayer"].config @@ -78,3 +81,64 @@ function MP.MATCH_RECORD.finalize(won) SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) end + +-- Ghost Replay playback state +-- Loads a stored match record and provides enemy scores for PvP blinds +-- so practice mode can simulate playing against a past opponent. + +MP.GHOST = { active = false, replay = nil } + +function MP.GHOST.load(replay) + MP.GHOST.active = true + MP.GHOST.replay = replay +end + +function MP.GHOST.clear() + MP.GHOST.active = false + MP.GHOST.replay = nil +end + +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 snapshot and snapshot.enemy_score then + return snapshot.enemy_score + end + return nil +end + +function MP.GHOST.is_active() + return MP.GHOST.active and MP.GHOST.replay ~= nil +end + +-- DEBUG: Generate a fake ghost replay for testing. Remove before release. +function MP.GHOST.generate_test_replay() + local config = SMODS.Mods["Multiplayer"].config + config.ghost_replays = config.ghost_replays or {} + + local fake = { + seed = "TESTGHOST", + ruleset = "ruleset_mp_blitz", + gamemode = "gamemode_mp_attrition", + deck = "Red Deck", + stake = 1, + winner = "nemesis", + final_ante = 6, + timestamp = os.time(), + ante_snapshots = { + [1] = { enemy_score = "0", player_score = "0", player_lives = 4, enemy_lives = 4, result = "win" }, + [2] = { enemy_score = "8000", player_score = "5000", player_lives = 4, enemy_lives = 4, result = "win" }, + [3] = { enemy_score = "45000", player_score = "30000", player_lives = 4, enemy_lives = 3, result = "loss" }, + [4] = { enemy_score = "200000", player_score = "150000", player_lives = 3, enemy_lives = 3, result = "win" }, + [5] = { enemy_score = "1200000", player_score = "800000", player_lives = 3, enemy_lives = 2, result = "loss" }, + [6] = { enemy_score = "5000000", player_score = "3000000", player_lives = 2, enemy_lives = 2, result = "loss" }, + [7] = { enemy_score = "25000000", player_score = "15000000", player_lives = 1, enemy_lives = 2, result = "loss" }, + [8] = { enemy_score = "100000000", player_score = "50000000", player_lives = 1, enemy_lives = 2, result = "loss" }, + }, + } + + table.insert(config.ghost_replays, fake) + SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) + sendDebugMessage("Test ghost replay generated", "MULTIPLAYER") +end diff --git a/localization/en-us.lua b/localization/en-us.lua index de9dae17..1ff2a414 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1098,6 +1098,9 @@ return { "Enemy", "location", }, + k_ghost_replays = "Ghost 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/ui/game/blind_choice.lua b/ui/game/blind_choice.lua index 1a9fe6d8..d35e4223 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 = localize("k_ghost") + 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..4468a524 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 = localize("k_ghost") } } + 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,17 +214,43 @@ 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) + if not ghost then + MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) + end -- For now, never advance to next round 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 + -- Auto-resolve PvP round locally + local beat_ghost = to_big(G.GAME.chips) >= to_big(G.GAME.blind.chips) + if beat_ghost 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 + MP.GAME.lives = MP.GAME.lives - 1 + MP.UI.ease_lives(-1) + 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() @@ -242,19 +281,22 @@ 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 not ghost then + 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 +325,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 +342,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 +389,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 +415,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 +430,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 2a876b8d..623ea96f 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 @@ -47,6 +49,15 @@ function reset_blinds() 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 + + -- Inject ghost enemy score for PvP blinds + if MP.GHOST.is_active() then + local ghost_score_str = MP.GHOST.get_enemy_score(G.GAME.round_resets.ante) + if ghost_score_str then + MP.GAME.enemy.score = MP.INSANE_INT.from_string(ghost_score_str) + MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score) + end + 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..efaea7ac --- /dev/null +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -0,0 +1,213 @@ +-- Ghost Replay Picker UI +-- Shown in practice mode to select a past match replay for ghost PvP + +local function reopen_practice_menu() + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +function G.FUNCS.open_ghost_replay_picker(e) + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +function G.FUNCS.select_ghost_replay(e) + local idx = e.config.ghost_replay_idx + local config = SMODS.Mods["Multiplayer"].config + local replays = config.ghost_replays or {} + local replay = replays[idx] + if not replay then return end + + MP.GHOST.load(replay) + + -- Set ruleset from the replay + if replay.ruleset then + MP.SP.ruleset = replay.ruleset + local ruleset_name = replay.ruleset:gsub("^ruleset_mp_", "") + MP.LoadReworks(ruleset_name) + end + + -- Set gamemode from the replay + if replay.gamemode then + MP.LOBBY.config.gamemode = replay.gamemode + end + + reopen_practice_menu() +end + +function G.FUNCS.clear_ghost_replay(e) + MP.GHOST.clear() + reopen_practice_menu() +end + +-- DEBUG: Generate a test ghost replay and refresh the picker +function G.FUNCS.generate_test_ghost_replay(e) + MP.GHOST.generate_test_replay() + -- Refresh the picker to show the new replay + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +function G.UIDEF.ghost_replay_picker() + local config = SMODS.Mods["Multiplayer"].config + local replays = config.ghost_replays or {} + + local replay_nodes = {} + + if #replays == 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.4, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + else + -- Show replays newest-first + for i = #replays, 1, -1 do + local r = replays[i] + local result_text = (r.winner == "player") and "W" or "L" + + local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" + local deck_display = r.deck or "?" + local ante_display = tostring(r.final_ante or "?") + + local timestamp_display = "" + if r.timestamp then + timestamp_display = os.date("%m/%d %H:%M", r.timestamp) + end + + local label = string.format( + "%s | %s | %s | Ante %s | %s", + result_text, ruleset_display, deck_display, ante_display, timestamp_display + ) + + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + UIBox_button({ + id = "ghost_replay_" .. i, + button = "select_ghost_replay", + label = { label }, + minw = 7, + minh = 0.5, + scale = 0.35, + colour = G.C.GREY, + hover = true, + shadow = true, + ghost_replay_idx = i, + }), + }, + } + end + end + + -- Clear ghost button if one is active + local clear_nodes = {} + if MP.GHOST.is_active() then + clear_nodes[#clear_nodes + 1] = UIBox_button({ + id = "clear_ghost_replay", + button = "clear_ghost_replay", + label = { "Clear Ghost" }, + minw = 3, + minh = 0.5, + scale = 0.35, + colour = G.C.RED, + hover = true, + shadow = true, + }) + end + + -- DEBUG: Generate test replay button + local debug_nodes = {} + debug_nodes[#debug_nodes + 1] = UIBox_button({ + id = "generate_test_ghost", + button = "generate_test_ghost_replay", + label = { "DEBUG: Generate Test Replay" }, + minw = 5, + minh = 0.5, + scale = 0.3, + colour = G.C.PURPLE, + hover = true, + shadow = true, + }) + + -- Back button to return to practice menu + local back_nodes = {} + back_nodes[#back_nodes + 1] = UIBox_button({ + id = "ghost_picker_back", + button = "ghost_picker_back", + label = { localize("b_back") }, + minw = 3, + minh = 0.6, + scale = 0.4, + colour = G.C.ORANGE, + hover = true, + shadow = true, + }) + + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR, minh = 6, minw = 8 }, + nodes = { + { + n = G.UIT.C, + config = { align = "cm", padding = 0.2, r = 0.1, colour = G.C.BLACK, minw = 8 }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.1 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_ghost_replays"), + scale = 0.5, + colour = G.C.WHITE, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05, maxh = 4 }, + nodes = replay_nodes, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.1 }, + nodes = clear_nodes, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = debug_nodes, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.1 }, + nodes = back_nodes, + }, + }, + }, + }, + } +end + +G.FUNCS.ghost_picker_back = function(e) + reopen_practice_menu() +end diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index f94b049f..be72370a 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -11,6 +11,7 @@ function G.FUNCS.setup_run_singleplayer(e) 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"), @@ -27,6 +28,7 @@ function G.FUNCS.start_vanilla_sp(e) MP.LOBBY.config.gamemode = nil MP.SP.ruleset = nil MP.SP.practice = false + MP.GHOST.clear() G.FUNCS.setup_run(e) end @@ -38,6 +40,7 @@ function G.FUNCS.setup_practice_mode(e) 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"), @@ -46,7 +49,20 @@ end function G.FUNCS.start_practice_run(e) G.FUNCS.exit_overlay_menu() - G.FUNCS.setup_run(e) + 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 }) + else + G.FUNCS.setup_run(e) + end end function G.FUNCS.toggle_unlimited_slots(e) diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 51e870d2..de94f54d 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -153,6 +153,29 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) 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] = { From b1d9ba97ccd1ca28cfb6bfe50d9ca8ccfb13c77d Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 11 Mar 2026 23:19:35 +0100 Subject: [PATCH 12/36] Fix replay picker jank --- lib/match_history.lua | 39 ++++++++++++++----- rulesets/_rulesets.lua | 4 ++ .../play_button/ghost_replay_picker.lua | 19 +++++---- .../play_button/ruleset_selection.lua | 9 ++++- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 46fb1e4e..96ece5c9 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -100,11 +100,8 @@ end 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 snapshot and snapshot.enemy_score then - return snapshot.enemy_score - end + local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)] + if snapshot and snapshot.enemy_score then return snapshot.enemy_score end return nil end @@ -131,10 +128,34 @@ function MP.GHOST.generate_test_replay() [2] = { enemy_score = "8000", player_score = "5000", player_lives = 4, enemy_lives = 4, result = "win" }, [3] = { enemy_score = "45000", player_score = "30000", player_lives = 4, enemy_lives = 3, result = "loss" }, [4] = { enemy_score = "200000", player_score = "150000", player_lives = 3, enemy_lives = 3, result = "win" }, - [5] = { enemy_score = "1200000", player_score = "800000", player_lives = 3, enemy_lives = 2, result = "loss" }, - [6] = { enemy_score = "5000000", player_score = "3000000", player_lives = 2, enemy_lives = 2, result = "loss" }, - [7] = { enemy_score = "25000000", player_score = "15000000", player_lives = 1, enemy_lives = 2, result = "loss" }, - [8] = { enemy_score = "100000000", player_score = "50000000", player_lives = 1, enemy_lives = 2, result = "loss" }, + [5] = { + enemy_score = "1200000", + player_score = "800000", + player_lives = 3, + enemy_lives = 2, + result = "loss", + }, + [6] = { + enemy_score = "5000000", + player_score = "3000000", + player_lives = 2, + enemy_lives = 2, + result = "loss", + }, + [7] = { + enemy_score = "25000000", + player_score = "15000000", + player_lives = 1, + enemy_lives = 2, + result = "loss", + }, + [8] = { + enemy_score = "100000000", + player_score = "50000000", + player_lives = 1, + enemy_lives = 2, + result = "loss", + }, }, } diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 4e6571a8..778046ae 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -60,6 +60,10 @@ 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.LOBBY.config.gamemode then + return MP.LOBBY.config.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 diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index efaea7ac..8628ccbc 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -2,24 +2,23 @@ -- Shown in practice mode to select a past match replay for ghost PvP local function reopen_practice_menu() - G.FUNCS.exit_overlay_menu() G.FUNCS.overlay_menu({ definition = G.UIDEF.ruleset_selection_options("practice"), }) end function G.FUNCS.open_ghost_replay_picker(e) - G.FUNCS.exit_overlay_menu() G.FUNCS.overlay_menu({ definition = G.UIDEF.ghost_replay_picker(), }) end function G.FUNCS.select_ghost_replay(e) - local idx = e.config.ghost_replay_idx + local idx = tonumber(e.config.id:match("ghost_replay_(%d+)")) local config = SMODS.Mods["Multiplayer"].config local replays = config.ghost_replays or {} local replay = replays[idx] + if not replay then return end MP.GHOST.load(replay) @@ -32,9 +31,7 @@ function G.FUNCS.select_ghost_replay(e) end -- Set gamemode from the replay - if replay.gamemode then - MP.LOBBY.config.gamemode = replay.gamemode - end + if replay.gamemode then MP.LOBBY.config.gamemode = replay.gamemode end reopen_practice_menu() end @@ -86,13 +83,15 @@ function G.UIDEF.ghost_replay_picker() local ante_display = tostring(r.final_ante or "?") local timestamp_display = "" - if r.timestamp then - timestamp_display = os.date("%m/%d %H:%M", r.timestamp) - end + if r.timestamp then timestamp_display = os.date("%m/%d %H:%M", r.timestamp) end local label = string.format( "%s | %s | %s | Ante %s | %s", - result_text, ruleset_display, deck_display, ante_display, timestamp_display + result_text, + ruleset_display, + deck_display, + ante_display, + timestamp_display ) replay_nodes[#replay_nodes + 1] = { diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index de94f54d..77b298e5 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -2,8 +2,13 @@ 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" or mode == "practice" then From 1d7071f8e9f69b1492527d1d7e10fca10fc69ec6 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 11 Mar 2026 23:27:09 +0100 Subject: [PATCH 13/36] print game state, add nemesis_name --- lib/match_history.lua | 13 +++++++++---- networking/action_handlers.lua | 4 +++- ui/main_menu/play_button/ghost_replay_picker.lua | 4 +++- ui/main_menu/play_button/play_button_callbacks.lua | 12 ++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 96ece5c9..1e90ed3f 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -16,6 +16,7 @@ MP.MATCH_RECORD = { ruleset = nil, gamemode = nil, deck = nil, + nemesis_name = nil, ante_snapshots = {}, winner = nil, final_ante = nil, @@ -26,18 +27,20 @@ function MP.MATCH_RECORD.reset() MP.MATCH_RECORD.ruleset = nil MP.MATCH_RECORD.gamemode = nil MP.MATCH_RECORD.deck = 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) +function MP.MATCH_RECORD.init(seed, ruleset, gamemode, deck, stake, 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.nemesis_name = nemesis_name end function MP.MATCH_RECORD.snapshot_ante(ante, data) @@ -66,6 +69,7 @@ function MP.MATCH_RECORD.finalize(won) ruleset = MP.MATCH_RECORD.ruleset, gamemode = MP.MATCH_RECORD.gamemode, deck = MP.MATCH_RECORD.deck, + nemesis_name = MP.MATCH_RECORD.nemesis_name, ante_snapshots = MP.MATCH_RECORD.ante_snapshots, winner = MP.MATCH_RECORD.winner, final_ante = MP.MATCH_RECORD.final_ante, @@ -115,10 +119,11 @@ function MP.GHOST.generate_test_replay() config.ghost_replays = config.ghost_replays or {} local fake = { - seed = "TESTGHOST", - ruleset = "ruleset_mp_blitz", + seed = "ABCDE", + ruleset = "ruleset_mp_standard_ranked", gamemode = "gamemode_mp_attrition", - deck = "Red Deck", + deck = "Abandoned Deck", + nemesis_name = "Zaino", stake = 1, winner = "nemesis", final_ante = 6, diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 589daa40..a4382b43 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -237,7 +237,9 @@ local function action_start_game(seed, stake_str) G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false - MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back, stake_str) + 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, nemesis_name) end local function begin_pvp_blind() diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 8628ccbc..1de4cc08 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -78,6 +78,7 @@ function G.UIDEF.ghost_replay_picker() local r = replays[i] local result_text = (r.winner == "player") and "W" or "L" + local nemesis_display = r.nemesis_name or "?" local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" local deck_display = r.deck or "?" local ante_display = tostring(r.final_ante or "?") @@ -86,8 +87,9 @@ function G.UIDEF.ghost_replay_picker() if r.timestamp then timestamp_display = os.date("%m/%d %H:%M", r.timestamp) end local label = string.format( - "%s | %s | %s | Ante %s | %s", + "%s | vs %s | %s | %s | Ante %s | %s", result_text, + nemesis_display, ruleset_display, deck_display, ante_display, diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index be72370a..680aeaed 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -60,6 +60,18 @@ function G.FUNCS.start_practice_run(e) 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 From cee1bf1020be05eea47ae791dd3b15f6c16d7794 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 11 Mar 2026 23:51:22 +0100 Subject: [PATCH 14/36] fix nemesis name --- ui/game/blind_choice.lua | 2 +- ui/game/game_state.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua index d35e4223..0644c89d 100644 --- a/ui/game/blind_choice.lua +++ b/ui/game/blind_choice.lua @@ -137,7 +137,7 @@ function create_UIBox_blind_choice(type, run_info) local loc_name if G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" then if MP.GHOST.is_active() then - loc_name = localize("k_ghost") + loc_name = MP.GHOST.replay.nemesis_name or localize("k_ghost") else loc_name = MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username end diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 4468a524..148a6ee9 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -34,7 +34,7 @@ function Game:update_draw_to_hand(dt) func = function() local blind_name_string if MP.GHOST.is_active() then - blind_name_string = { { string = localize("k_ghost") } } + blind_name_string = { { string = MP.GHOST.replay.nemesis_name or localize("k_ghost") } } else blind_name_string = { { From 7b288042c842431b8f26f52feeaaa55b1048371a Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 12 Mar 2026 00:11:51 +0100 Subject: [PATCH 15/36] fix end of round game loss in ghost mode --- lovely/end_round.toml | 4 ++-- ui/game/game_state.lua | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lovely/end_round.toml b/lovely/end_round.toml index ae13e938..44bca84d 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 diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 148a6ee9..9c780348 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -231,8 +231,16 @@ function Game:update_hand_played(dt) return true end else + -- Mirror action_player_info: comeback bonus + no gold on loss + 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 From 67e12e7068826a488dcead38d2995bb22fcb9de9 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 12 Mar 2026 00:17:28 +0100 Subject: [PATCH 16/36] Life loss now uses the correct score! --- ui/game/game_state.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 9c780348..3cd5b909 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -221,7 +221,8 @@ function Game:update_hand_played(dt) if G.GAME.current_round.hands_left < 1 then if ghost then -- Auto-resolve PvP round locally - local beat_ghost = to_big(G.GAME.chips) >= to_big(G.GAME.blind.chips) + local enemy_score = MP.GAME.enemy.score.coeffiocient * (10 ^ MP.GAME.enemy.score.exponent) + local beat_ghost = to_big(G.GAME.chips) >= to_big(enemy_score) if beat_ghost then MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 if MP.GAME.enemy.lives <= 0 then From b72a2ccb62eb8f8afe2f929ee26c308d7bf95cbe Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 12 Mar 2026 01:20:33 +0100 Subject: [PATCH 17/36] ghost replays --- tools/log_to_ghost_replay.py | 312 +++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 tools/log_to_ghost_replay.py diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py new file mode 100644 index 00000000..a5f1b1fe --- /dev/null +++ b/tools/log_to_ghost_replay.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Parse a Lovely log file and extract ghost replay data. + +Produces a Lua table fragment matching the ghost_replays entry format +in Multiplayer.jkr, suitable for pasting into the config or feeding +into the ghost replay system. + +Usage: + python3 tools/log_to_ghost_replay.py + python3 tools/log_to_ghost_replay.py --lua # output as Lua table (default) + python3 tools/log_to_ghost_replay.py --json # output as JSON +""" + +import re +import sys +import json +import time +from dataclasses import dataclass, field +from typing import Optional + + +@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" + + +@dataclass +class GameRecord: + seed: Optional[str] = None + ruleset: Optional[str] = None + gamemode: Optional[str] = None + deck: Optional[str] = None + stake: Optional[int] = None + nemesis_name: Optional[str] = None + starting_lives: int = 4 + is_host: Optional[bool] = 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 + # Track the latest scores during a PvP round + pvp_player_score: str = "0" + pvp_enemy_score: str = "0" + in_pvp: bool = False + + +# --- Parsers for specific log message types --- + +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_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 to convert to number + try: + if '.' in val: + val = float(val) + else: + val = int(val) + except ValueError: + # Keep as string, handle booleans + if val == 'true': + val = True + elif val == 'false': + val = False + pairs[key] = val + return action, pairs + + +def parse_lobby_options_json(line: str) -> Optional[dict]: + """Extract lobby options from a lobbyOptions sent message.""" + data = parse_client_sent_json(line) + if data and data.get('action') == 'lobbyOptions': + return data + return None + + +def process_log(filepath: str) -> GameRecord: + """Process a log file and extract ghost replay data.""" + game = GameRecord() + last_lobby_options = None + + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + for line in lines: + if 'MULTIPLAYER' not in line: + continue + + # --- Sent messages (JSON) --- + sent = parse_client_sent_json(line) + if sent: + action = sent.get('action') + + if 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') + if game.in_pvp: + game.pvp_player_score = str(score) + + elif action == 'setLocation': + loc = sent.get('location', '') + if 'bl_mp_nemesis' in loc: + game.in_pvp = True + + continue + + # --- Received messages (key-value) --- + parsed = parse_client_got_kv(line) + if not parsed: + continue + action, kv = parsed + + if action == 'lobbyInfo': + # Determine host/guest status and nemesis name + 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': + # 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: + game.pvp_enemy_score = str(kv['score']) + + 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' + + 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, + ) + game.ante_snapshots[game.current_ante] = snap + + # Reset PvP tracking + game.in_pvp = False + game.pvp_player_score = "0" + game.pvp_enemy_score = "0" + + elif action == 'winGame': + game.winner = 'player' + + elif action == 'loseGame': + game.winner = 'nemesis' + + elif action == 'stopGame': + if 'seed' in kv: + game.seed = str(kv['seed']) + + # Also check sent messages for setLocation to detect PvP entry + # (already handled above in the sent message section via enemyLocation) + + return game + + +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.nemesis_name: + lines.append(f'{indent}["nemesis_name"] = "{game.nemesis_name}",') + lines.append('}') + + return '\n'.join(lines) + + +def to_json(game: GameRecord) -> str: + """Convert a GameRecord to JSON.""" + snapshots = {} + for ante, snap in sorted(game.ante_snapshots.items()): + snapshots[str(ante)] = { + 'result': snap.result, + 'enemy_score': snap.enemy_score, + 'enemy_lives': snap.enemy_lives, + 'player_score': snap.player_score, + 'player_lives': snap.player_lives, + } + + 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.nemesis_name: + obj['nemesis_name'] = game.nemesis_name + + return json.dumps(obj, indent=2) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + filepath = sys.argv[1] + output_format = 'lua' + if '--json' in sys.argv: + output_format = 'json' + + game = process_log(filepath) + + # Print summary + print(f'# Parsed game from log: {filepath}', file=sys.stderr) + print(f'# Seed: {game.seed}', 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'# 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) + 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] + print(f'# Ante {ante}: {s.result} | player={s.player_score} ({s.player_lives}hp) vs enemy={s.enemy_score} ({s.enemy_lives}hp)', file=sys.stderr) + print(file=sys.stderr) + + if output_format == 'json': + print(to_json(game)) + else: + print(to_lua_table(game)) + + +if __name__ == '__main__': + main() From 418fbe78c1739d0a42a306b1931ab58840a7e4a1 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 12 Mar 2026 01:30:09 +0100 Subject: [PATCH 18/36] ghost replays --- lib/match_history.lua | 52 ++++- networking/action_handlers.lua | 3 +- replays/.gitkeep | 0 tools/log_to_ghost_replay.py | 203 ++++++++++-------- .../play_button/ghost_replay_picker.lua | 82 ++++--- 5 files changed, 215 insertions(+), 125 deletions(-) create mode 100644 replays/.gitkeep diff --git a/lib/match_history.lua b/lib/match_history.lua index 1e90ed3f..2cb5e839 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -16,6 +16,7 @@ MP.MATCH_RECORD = { ruleset = nil, gamemode = nil, deck = nil, + player_name = nil, nemesis_name = nil, ante_snapshots = {}, winner = nil, @@ -27,19 +28,21 @@ function MP.MATCH_RECORD.reset() 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, nemesis_name) +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 @@ -69,6 +72,7 @@ function MP.MATCH_RECORD.finalize(won) ruleset = MP.MATCH_RECORD.ruleset, gamemode = MP.MATCH_RECORD.gamemode, deck = MP.MATCH_RECORD.deck, + player_name = MP.MATCH_RECORD.player_name, nemesis_name = MP.MATCH_RECORD.nemesis_name, ante_snapshots = MP.MATCH_RECORD.ante_snapshots, winner = MP.MATCH_RECORD.winner, @@ -113,6 +117,51 @@ 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 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 + 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 + -- DEBUG: Generate a fake ghost replay for testing. Remove before release. function MP.GHOST.generate_test_replay() local config = SMODS.Mods["Multiplayer"].config @@ -123,6 +172,7 @@ function MP.GHOST.generate_test_replay() ruleset = "ruleset_mp_standard_ranked", gamemode = "gamemode_mp_attrition", deck = "Abandoned Deck", + player_name = MP.UI.get_username(), nemesis_name = "Zaino", stake = 1, winner = "nemesis", diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index a4382b43..7af84d97 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -237,9 +237,10 @@ local function action_start_game(seed, stake_str) G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false + local player_name = MP.UI.get_username() local nemesis = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host local nemesis_name = nemesis and nemesis.username or "Unknown" - MP.MATCH_RECORD.init(seed, MP.LOBBY.config.ruleset, MP.LOBBY.config.gamemode, MP.LOBBY.config.back, stake_str, nemesis_name) + 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() diff --git a/replays/.gitkeep b/replays/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py index a5f1b1fe..7989f16c 100644 --- a/tools/log_to_ghost_replay.py +++ b/tools/log_to_ghost_replay.py @@ -11,9 +11,9 @@ python3 tools/log_to_ghost_replay.py --json # output as JSON """ +import json import re import sys -import json import time from dataclasses import dataclass, field from typing import Optional @@ -36,6 +36,7 @@ class GameRecord: 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 @@ -53,9 +54,10 @@ class GameRecord: # --- Parsers for specific log message types --- + 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) + m = re.search(r"Client sent message: (\{.*\})\s*$", line) if m: try: return json.loads(m.group(1)) @@ -66,26 +68,26 @@ def parse_client_sent_json(line: str) -> Optional[dict]: 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) + 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): + for km in re.finditer(r"\((\w+):\s*([^)]*)\)", kv_str): key = km.group(1) val = km.group(2).strip() # Try to convert to number try: - if '.' in val: + if "." in val: val = float(val) else: val = int(val) except ValueError: # Keep as string, handle booleans - if val == 'true': + if val == "true": val = True - elif val == 'false': + elif val == "false": val = False pairs[key] = val return action, pairs @@ -94,7 +96,7 @@ def parse_client_got_kv(line: str) -> Optional[tuple]: def parse_lobby_options_json(line: str) -> Optional[dict]: """Extract lobby options from a lobbyOptions sent message.""" data = parse_client_sent_json(line) - if data and data.get('action') == 'lobbyOptions': + if data and data.get("action") == "lobbyOptions": return data return None @@ -104,35 +106,38 @@ def process_log(filepath: str) -> GameRecord: game = GameRecord() last_lobby_options = None - with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + with open(filepath, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() for line in lines: - if 'MULTIPLAYER' not in line: + if "MULTIPLAYER" not in line: continue # --- Sent messages (JSON) --- sent = parse_client_sent_json(line) if sent: - action = sent.get('action') + action = sent.get("action") + + if action == "username": + game.player_name = sent.get("username") - if action == 'lobbyOptions': + elif action == "lobbyOptions": last_lobby_options = sent - elif action == 'setAnte': - ante = sent.get('ante', 0) + 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') + elif action == "playHand": + score = sent.get("score", "0") if game.in_pvp: game.pvp_player_score = str(score) - elif action == 'setLocation': - loc = sent.get('location', '') - if 'bl_mp_nemesis' in loc: + elif action == "setLocation": + loc = sent.get("location", "") + if "bl_mp_nemesis" in loc: game.in_pvp = True continue @@ -143,44 +148,44 @@ def process_log(filepath: str) -> GameRecord: continue action, kv = parsed - if action == 'lobbyInfo': + if action == "lobbyInfo": # Determine host/guest status and nemesis name - 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': + 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": # 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.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 == "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: - game.pvp_enemy_score = str(kv['score']) + elif action == "enemyInfo": + if "lives" in kv: + game.enemy_lives = kv["lives"] + if "score" in kv: + game.pvp_enemy_score = str(kv["score"]) - elif action == 'enemyLocation': - loc = kv.get('location', '') - if 'bl_mp_nemesis' in str(loc): + 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' + elif action == "endPvP": + lost = kv.get("lost", False) + result = "loss" if lost else "win" snap = AnteSnapshot( ante=game.current_ante, @@ -197,15 +202,15 @@ def process_log(filepath: str) -> GameRecord: game.pvp_player_score = "0" game.pvp_enemy_score = "0" - elif action == 'winGame': - game.winner = 'player' + elif action == "winGame": + game.winner = "player" - elif action == 'loseGame': - game.winner = 'nemesis' + elif action == "loseGame": + game.winner = "nemesis" - elif action == 'stopGame': - if 'seed' in kv: - game.seed = str(kv['seed']) + elif action == "stopGame": + if "seed" in kv: + game.seed = str(kv["seed"]) # Also check sent messages for setLocation to detect PvP entry # (already handled above in the sent message section via enemyLocation) @@ -215,35 +220,41 @@ def process_log(filepath: str) -> GameRecord: def to_lua_table(game: GameRecord) -> str: """Convert a GameRecord to a Lua table string matching ghost_replays format.""" - indent = '\t' + indent = "\t" - lines = ['{'] - lines.append(f'{indent}["gamemode"] = "{game.gamemode or "gamemode_mp_attrition"}",') + 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}[{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_score"] = "{snap.player_score}",' + ) lines.append(f'{indent}{indent}{indent}["player_lives"] = {snap.player_lives},') - lines.append(f'{indent}{indent}}},') + lines.append(f"{indent}{indent}}},") - lines.append(f'{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('}') + lines.append("}") - return '\n'.join(lines) + return "\n".join(lines) def to_json(game: GameRecord) -> str: @@ -251,26 +262,28 @@ def to_json(game: GameRecord) -> str: snapshots = {} for ante, snap in sorted(game.ante_snapshots.items()): snapshots[str(ante)] = { - 'result': snap.result, - 'enemy_score': snap.enemy_score, - 'enemy_lives': snap.enemy_lives, - 'player_score': snap.player_score, - 'player_lives': snap.player_lives, + "result": snap.result, + "enemy_score": snap.enemy_score, + "enemy_lives": snap.enemy_lives, + "player_score": snap.player_score, + "player_lives": snap.player_lives, } 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, + "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 + obj["nemesis_name"] = game.nemesis_name return json.dumps(obj, indent=2) @@ -281,32 +294,36 @@ def main(): sys.exit(1) filepath = sys.argv[1] - output_format = 'lua' - if '--json' in sys.argv: - output_format = 'json' + output_format = "lua" + if "--json" in sys.argv: + output_format = "json" game = process_log(filepath) # Print summary - print(f'# Parsed game from log: {filepath}', file=sys.stderr) - print(f'# Seed: {game.seed}', 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'# 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) - print(f'# PvP snapshots: {len(game.ante_snapshots)} antes', file=sys.stderr) + print(f"# Parsed game from log: {filepath}", file=sys.stderr) + print(f"# Seed: {game.seed}", 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) + 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] - print(f'# Ante {ante}: {s.result} | player={s.player_score} ({s.player_lives}hp) vs enemy={s.enemy_score} ({s.enemy_lives}hp)', file=sys.stderr) + print( + f"# Ante {ante}: {s.result} | player={s.player_score} ({s.player_lives}hp) vs enemy={s.enemy_score} ({s.enemy_lives}hp)", + file=sys.stderr, + ) print(file=sys.stderr) - if output_format == 'json': + if output_format == "json": print(to_json(game)) else: print(to_lua_table(game)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 1de4cc08..d1b8afa3 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -13,11 +13,12 @@ function G.FUNCS.open_ghost_replay_picker(e) }) end +-- Stashed merged replay list so select_ghost_replay can index into it +local _picker_replays = {} + function G.FUNCS.select_ghost_replay(e) local idx = tonumber(e.config.id:match("ghost_replay_(%d+)")) - local config = SMODS.Mods["Multiplayer"].config - local replays = config.ghost_replays or {} - local replay = replays[idx] + local replay = _picker_replays[idx] if not replay then return end @@ -51,13 +52,54 @@ function G.FUNCS.generate_test_ghost_replay(e) }) 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 ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" + local deck_display = r.deck or "?" + local ante_display = tostring(r.final_ante or "?") + + local timestamp_display = "" + if r.timestamp then timestamp_display = os.date("%m/%d %H:%M", r.timestamp) end + + return string.format( + "%s | %s vs %s | %s | %s | Ante %s | %s", + result_text, + player_display, + nemesis_display, + ruleset_display, + deck_display, + ante_display, + timestamp_display + ) +end + function G.UIDEF.ghost_replay_picker() - local config = SMODS.Mods["Multiplayer"].config - local replays = config.ghost_replays or {} + -- Merge config replays + folder replays into one list, sorted newest-first + local config_replays = SMODS.Mods["Multiplayer"].config.ghost_replays or {} + local folder_replays = MP.GHOST.load_folder_replays() + + local all = {} + for _, r in ipairs(config_replays) do + r._source = r._source or "config" + all[#all + 1] = r + end + for _, r in ipairs(folder_replays) do + all[#all + 1] = r + end + + -- Sort newest-first + table.sort(all, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + -- Stash for select_ghost_replay to index into + _picker_replays = all local replay_nodes = {} - if #replays == 0 then + if #all == 0 then replay_nodes[#replay_nodes + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.2 }, @@ -73,28 +115,9 @@ function G.UIDEF.ghost_replay_picker() }, } else - -- Show replays newest-first - for i = #replays, 1, -1 do - local r = replays[i] - local result_text = (r.winner == "player") and "W" or "L" - - local nemesis_display = r.nemesis_name or "?" - local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" - local deck_display = r.deck or "?" - local ante_display = tostring(r.final_ante or "?") - - local timestamp_display = "" - if r.timestamp then timestamp_display = os.date("%m/%d %H:%M", r.timestamp) end - - local label = string.format( - "%s | vs %s | %s | %s | Ante %s | %s", - result_text, - nemesis_display, - ruleset_display, - deck_display, - ante_display, - timestamp_display - ) + for i, r in ipairs(all) do + local label = build_replay_label(r) + local btn_colour = r._source == "file" and G.C.BLUE or G.C.GREY replay_nodes[#replay_nodes + 1] = { n = G.UIT.R, @@ -107,10 +130,9 @@ function G.UIDEF.ghost_replay_picker() minw = 7, minh = 0.5, scale = 0.35, - colour = G.C.GREY, + colour = btn_colour, hover = true, shadow = true, - ghost_replay_idx = i, }), }, } From 2c8ae50cfc93cc3859405f81fd48a9ec5fd28629 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 12 Mar 2026 01:44:10 +0100 Subject: [PATCH 19/36] play from both sides --- lib/match_history.lua | 23 ++++++++++++++--- ui/game/blind_choice.lua | 2 +- ui/game/game_state.lua | 2 +- .../play_button/ghost_replay_picker.lua | 25 ++++++++++++++++++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 2cb5e839..76d9f1b2 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -94,23 +94,40 @@ end -- Loads a stored match record and provides enemy scores for PvP blinds -- so practice mode can simulate playing against a past opponent. -MP.GHOST = { active = false, replay = nil } +MP.GHOST = { active = false, replay = nil, flipped = false } function MP.GHOST.load(replay) MP.GHOST.active = true MP.GHOST.replay = replay + MP.GHOST.flipped = false end function MP.GHOST.clear() MP.GHOST.active = false MP.GHOST.replay = nil + MP.GHOST.flipped = false +end + +function MP.GHOST.flip() + MP.GHOST.flipped = not MP.GHOST.flipped end 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 snapshot and snapshot.enemy_score then return snapshot.enemy_score end - return nil + if not snapshot then return nil end + -- When flipped, the original player's score becomes the ghost target + local key = MP.GHOST.flipped and "player_score" or "enemy_score" + return snapshot[key] +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() diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua index 0644c89d..ed57ed41 100644 --- a/ui/game/blind_choice.lua +++ b/ui/game/blind_choice.lua @@ -137,7 +137,7 @@ function create_UIBox_blind_choice(type, run_info) 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.replay.nemesis_name or localize("k_ghost") + 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 diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 3cd5b909..ff519779 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -34,7 +34,7 @@ function Game:update_draw_to_hand(dt) func = function() local blind_name_string if MP.GHOST.is_active() then - blind_name_string = { { string = MP.GHOST.replay.nemesis_name or localize("k_ghost") } } + blind_name_string = { { string = MP.GHOST.get_nemesis_name() } } else blind_name_string = { { diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index d1b8afa3..80a21c8c 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -42,6 +42,14 @@ function G.FUNCS.clear_ghost_replay(e) reopen_practice_menu() end +function G.FUNCS.flip_ghost_perspective(e) + MP.GHOST.flip() + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + -- DEBUG: Generate a test ghost replay and refresh the picker function G.FUNCS.generate_test_ghost_replay(e) MP.GHOST.generate_test_replay() @@ -139,9 +147,24 @@ function G.UIDEF.ghost_replay_picker() end end - -- Clear ghost button if one is active + -- Clear ghost + flip perspective buttons if one is active local clear_nodes = {} if MP.GHOST.is_active() then + -- Show who you're playing as + local playing_as = MP.GHOST.flipped + and (MP.GHOST.replay.nemesis_name or "?") + or (MP.GHOST.replay.player_name or "?") + clear_nodes[#clear_nodes + 1] = UIBox_button({ + id = "flip_ghost_perspective", + button = "flip_ghost_perspective", + label = { "Playing as: " .. playing_as }, + minw = 3, + minh = 0.5, + scale = 0.35, + colour = G.C.GREEN, + hover = true, + shadow = true, + }) clear_nodes[#clear_nodes + 1] = UIBox_button({ id = "clear_ghost_replay", button = "clear_ghost_replay", From 710999fa13d8fb978e7becd181020501d34fd68e Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sat, 14 Mar 2026 02:25:18 +0100 Subject: [PATCH 20/36] logger tools --- tools/log_to_ghost_replay.html | 284 +++++++++++++++++++++++++++++++++ tools/log_to_ghost_replay.py | 41 +++-- 2 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 tools/log_to_ghost_replay.html 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 index 7989f16c..1fe7a8a2 100644 --- a/tools/log_to_ghost_replay.py +++ b/tools/log_to_ghost_replay.py @@ -1,17 +1,16 @@ #!/usr/bin/env python3 -"""Parse a Lovely log file and extract ghost replay data. +"""Parse a Lovely log file and generate a ghost replay JSON. -Produces a Lua table fragment matching the ghost_replays entry format -in Multiplayer.jkr, suitable for pasting into the config or feeding -into the ghost replay system. +Writes a .json file into replays/ that the ghost replay picker loads +automatically. Output defaults to JSON; use --lua for a Lua table. Usage: - python3 tools/log_to_ghost_replay.py - python3 tools/log_to_ghost_replay.py --lua # output as Lua table (default) - python3 tools/log_to_ghost_replay.py --json # output as JSON + 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 @@ -294,9 +293,9 @@ def main(): sys.exit(1) filepath = sys.argv[1] - output_format = "lua" - if "--json" in sys.argv: - output_format = "json" + output_format = "json" + if "--lua" in sys.argv: + output_format = "lua" game = process_log(filepath) @@ -319,10 +318,26 @@ def main(): ) print(file=sys.stderr) - if output_format == "json": - print(to_json(game)) - else: + if output_format == "lua": print(to_lua_table(game)) + else: + # Write JSON to replays/ folder + script_dir = os.path.dirname(os.path.abspath(__file__)) + replays_dir = os.path.join(script_dir, "..", "replays") + os.makedirs(replays_dir, exist_ok=True) + + # Build filename from game data + seed = game.seed or "unknown" + player = (game.player_name or "unknown").replace("~", "-") + nemesis = (game.nemesis_name or "unknown").replace("~", "-") + filename = f"{seed}_{player}_vs_{nemesis}.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__": From 4cae147d6c9053a0b126cf097dbf58bbefe315a1 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:09:39 +0100 Subject: [PATCH 21/36] Support split mp games etc --- tools/log_to_ghost_replay.py | 518 +++++++++++++++++++++++++++++------ 1 file changed, 433 insertions(+), 85 deletions(-) diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py index 1fe7a8a2..cfac3ebd 100644 --- a/tools/log_to_ghost_replay.py +++ b/tools/log_to_ghost_replay.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Parse a Lovely log file and generate a ghost replay JSON. +"""Parse a Lovely log file and generate ghost replay JSON(s). -Writes a .json file into replays/ that the ghost replay picker loads -automatically. Output defaults to JSON; use --lua for a Lua table. +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/ @@ -15,9 +16,24 @@ 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 @@ -26,6 +42,7 @@ class AnteSnapshot: player_lives: int = 4 enemy_lives: int = 4 result: Optional[str] = None # "win" or "loss" + hands: list = field(default_factory=list) # list[HandScore] @dataclass @@ -39,19 +56,53 @@ class GameRecord: 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 - # Track the latest scores during a PvP round + + # 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 -# --- Parsers for specific log message types --- + # 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]: @@ -65,6 +116,20 @@ def parse_client_sent_json(line: str) -> Optional[dict]: 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) @@ -76,14 +141,12 @@ def parse_client_got_kv(line: str) -> Optional[tuple]: for km in re.finditer(r"\((\w+):\s*([^)]*)\)", kv_str): key = km.group(1) val = km.group(2).strip() - # Try to convert to number try: if "." in val: val = float(val) else: val = int(val) except ValueError: - # Keep as string, handle booleans if val == "true": val = True elif val == "false": @@ -92,18 +155,50 @@ def parse_client_got_kv(line: str) -> Optional[tuple]: return action, pairs -def parse_lobby_options_json(line: str) -> Optional[dict]: - """Extract lobby options from a lobbyOptions sent message.""" - data = parse_client_sent_json(line) - if data and data.get("action") == "lobbyOptions": - return data - return None - - -def process_log(filepath: str) -> GameRecord: - """Process a log file and extract ghost replay data.""" +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() @@ -112,6 +207,21 @@ def process_log(filepath: str) -> GameRecord: 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: @@ -131,14 +241,64 @@ def process_log(filepath: str) -> GameRecord: 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) --- @@ -147,8 +307,11 @@ def process_log(filepath: str) -> GameRecord: continue action, kv = parsed - if action == "lobbyInfo": - # Determine host/guest status and nemesis name + 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: @@ -157,13 +320,18 @@ def process_log(filepath: str) -> GameRecord: 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.starting_lives = last_lobby_options.get( + "starting_lives", 4 + ) game.player_lives = game.starting_lives game.enemy_lives = game.starting_lives @@ -175,7 +343,18 @@ def process_log(filepath: str) -> GameRecord: if "lives" in kv: game.enemy_lives = kv["lives"] if "score" in kv: - game.pvp_enemy_score = str(kv["score"]) + 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", "") @@ -186,6 +365,26 @@ def process_log(filepath: str) -> GameRecord: 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, @@ -193,6 +392,7 @@ def process_log(filepath: str) -> GameRecord: player_lives=game.player_lives, enemy_lives=game.enemy_lives, result=result, + hands=cleaned_hands, ) game.ante_snapshots[game.current_ante] = snap @@ -200,6 +400,7 @@ def process_log(filepath: str) -> GameRecord: game.in_pvp = False game.pvp_player_score = "0" game.pvp_enemy_score = "0" + game.pvp_hands = [] elif action == "winGame": game.winner = "player" @@ -207,14 +408,131 @@ def process_log(filepath: str) -> GameRecord: 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 - # Also check sent messages for setLocation to detect PvP entry - # (already handled above in the sent message section via enemyLocation) + # 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 game + return json.dumps(obj, indent=2) def to_lua_table(game: GameRecord) -> str: @@ -232,18 +550,26 @@ def to_lua_table(game: GameRecord) -> str: 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}["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}{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}["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},') @@ -256,52 +582,17 @@ def to_lua_table(game: GameRecord) -> str: return "\n".join(lines) -def to_json(game: GameRecord) -> str: - """Convert a GameRecord to JSON.""" - snapshots = {} - for ante, snap in sorted(game.ante_snapshots.items()): - snapshots[str(ante)] = { - "result": snap.result, - "enemy_score": snap.enemy_score, - "enemy_lives": snap.enemy_lives, - "player_score": snap.player_score, - "player_lives": snap.player_lives, - } - - 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 - - return json.dumps(obj, indent=2) - - -def main(): - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- - filepath = sys.argv[1] - output_format = "json" - if "--lua" in sys.argv: - output_format = "lua" - game = process_log(filepath) - - # Print summary - print(f"# Parsed game from log: {filepath}", file=sys.stderr) +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) @@ -309,35 +600,92 @@ def main(): 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) - print(f"# PvP snapshots: {len(game.ante_snapshots)} antes", 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} | player={s.player_score} ({s.player_lives}hp) vs enemy={s.enemy_score} ({s.enemy_lives}hp)", + 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": - print(to_lua_table(game)) + for game in games: + print(to_lua_table(game)) else: - # Write JSON to replays/ folder script_dir = os.path.dirname(os.path.abspath(__file__)) replays_dir = os.path.join(script_dir, "..", "replays") os.makedirs(replays_dir, exist_ok=True) - # Build filename from game data - seed = game.seed or "unknown" - player = (game.player_name or "unknown").replace("~", "-") - nemesis = (game.nemesis_name or "unknown").replace("~", "-") - filename = f"{seed}_{player}_vs_{nemesis}.json" - out_path = os.path.join(replays_dir, filename) + 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") + with open(out_path, "w") as f: + f.write(to_json(game)) + f.write("\n") - print(f"Wrote {out_path}", file=sys.stderr) + print(f"Wrote {out_path}", file=sys.stderr) if __name__ == "__main__": From 05af5b8fcd685d2b1e6b0bfbc2fd04aa7e9906e5 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:10:34 +0100 Subject: [PATCH 22/36] compact json --- tools/log_to_ghost_replay.py | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tools/log_to_ghost_replay.py b/tools/log_to_ghost_replay.py index cfac3ebd..01305ce3 100644 --- a/tools/log_to_ghost_replay.py +++ b/tools/log_to_ghost_replay.py @@ -532,7 +532,47 @@ def to_json(game: GameRecord) -> str: if game.cards_used: obj["cards_used"] = game.cards_used - return json.dumps(obj, indent=2) + 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: From da1822a11fd5c676e5e26e5c6f70a9090bfa1481 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:21:35 +0100 Subject: [PATCH 23/36] HUD to pick replay magical --- .../play_button/ghost_replay_picker.lua | 538 ++++++++++++++---- 1 file changed, 424 insertions(+), 114 deletions(-) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 80a21c8c..67e27bc4 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -1,5 +1,6 @@ -- Ghost Replay Picker UI -- Shown in practice mode to select a past match replay for ghost PvP +-- Two-column layout: replay list (left) + stats detail panel (right) local function reopen_practice_menu() G.FUNCS.overlay_menu({ @@ -7,82 +8,357 @@ local function reopen_practice_menu() }) 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 select_ghost_replay can index into it +-- Stashed merged replay list so callbacks can index into it local _picker_replays = {} +-- Currently previewed replay (shown in right panel) +local _preview_idx = nil -function G.FUNCS.select_ghost_replay(e) +function G.FUNCS.preview_ghost_replay(e) local idx = tonumber(e.config.id:match("ghost_replay_(%d+)")) - local replay = _picker_replays[idx] + _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 MP.GHOST.load(replay) - -- Set ruleset from the replay if replay.ruleset then MP.SP.ruleset = replay.ruleset local ruleset_name = replay.ruleset:gsub("^ruleset_mp_", "") MP.LoadReworks(ruleset_name) end - -- Set gamemode from the replay if replay.gamemode then MP.LOBBY.config.gamemode = replay.gamemode end + _preview_idx = nil 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 reopen_practice_menu() end function G.FUNCS.flip_ghost_perspective(e) MP.GHOST.flip() - G.FUNCS.exit_overlay_menu() - G.FUNCS.overlay_menu({ - definition = G.UIDEF.ghost_replay_picker(), - }) + refresh_picker() end -- DEBUG: Generate a test ghost replay and refresh the picker function G.FUNCS.generate_test_ghost_replay(e) MP.GHOST.generate_test_replay() - -- Refresh the picker to show the new replay - G.FUNCS.exit_overlay_menu() - G.FUNCS.overlay_menu({ - definition = G.UIDEF.ghost_replay_picker(), - }) + refresh_picker() +end + +G.FUNCS.ghost_picker_back = function(e) + _preview_idx = nil + reopen_practice_menu() end +------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------- + 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 ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" - local deck_display = r.deck or "?" local ante_display = tostring(r.final_ante or "?") local timestamp_display = "" - if r.timestamp then timestamp_display = os.date("%m/%d %H:%M", r.timestamp) end + if r.timestamp then timestamp_display = os.date("%m/%d", r.timestamp) end return string.format( - "%s | %s vs %s | %s | %s | Ante %s | %s", + "%s %s v %s A%s %s", result_text, player_display, nemesis_display, - ruleset_display, - deck_display, ante_display, timestamp_display ) 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 joker_list_text(jokers) + if not jokers or #jokers == 0 then return "None" end + local names = {} + for _, j in ipairs(jokers) do + local key = j.key or j + -- Try to get localized name + local name = key + if G.P_CENTERS and G.P_CENTERS[key] then + local loc = G.P_CENTERS[key].loc_txt + if loc and loc.name then name = loc.name end + end + -- Clean up key prefix as fallback display + name = name:gsub("^j_mp_", ""):gsub("^j_", ""):gsub("_", " ") + names[#names + 1] = name + end + return table.concat(names, ", ") +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 replay", + scale = 0.35, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + end + + local nodes = {} + + -- Match header + local result_str = (r.winner == "player") and "VICTORY" or "DEFEAT" + local result_colour = (r.winner == "player") and G.C.GREEN or G.C.RED + nodes[#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 } }, + }, + } + + -- Players + local player_display = r.player_name or "?" + local nemesis_display = r.nemesis_name or "?" + nodes[#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 } }, + }, + } + + -- Match info + nodes[#nodes + 1] = section_header("Match Info") + 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 "?" + nodes[#nodes + 1] = text_row("Ruleset:", ruleset_display) + nodes[#nodes + 1] = text_row("Gamemode:", gamemode_display) + nodes[#nodes + 1] = text_row("Deck:", deck_display) + if r.seed then nodes[#nodes + 1] = text_row("Seed:", r.seed) end + if r.stake then nodes[#nodes + 1] = text_row("Stake:", tostring(r.stake)) end + nodes[#nodes + 1] = text_row("Final Ante:", tostring(r.final_ante or "?")) + if r.duration then nodes[#nodes + 1] = text_row("Duration:", r.duration) end + if r.timestamp then + nodes[#nodes + 1] = text_row("Date:", os.date("%Y-%m-%d %H:%M", r.timestamp)) + end + + -- Ante breakdown + if r.ante_snapshots then + nodes[#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 + nodes[#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 + + -- Jokers + if r.player_jokers then + nodes[#nodes + 1] = section_header("Your Jokers") + local jtext = joker_list_text(r.player_jokers) + nodes[#nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02, maxw = 5.5 }, + nodes = { + { n = G.UIT.T, config = { text = jtext, scale = 0.26, colour = G.C.WHITE } }, + }, + } + end + if r.nemesis_jokers then + nodes[#nodes + 1] = section_header("Opponent Jokers") + local jtext = joker_list_text(r.nemesis_jokers) + nodes[#nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02, maxw = 5.5 }, + nodes = { + { n = G.UIT.T, config = { text = jtext, scale = 0.26, colour = G.C.WHITE } }, + }, + } + end + + -- Player stats + if r.player_stats then + nodes[#nodes + 1] = section_header("Your Stats") + if r.player_stats.reroll_count then + nodes[#nodes + 1] = text_row("Rerolls:", tostring(r.player_stats.reroll_count), 0.28) + end + if r.player_stats.reroll_cost_total then + nodes[#nodes + 1] = text_row("Reroll $:", tostring(r.player_stats.reroll_cost_total), 0.28) + end + if r.player_stats.vouchers then + nodes[#nodes + 1] = text_row("Vouchers:", r.player_stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) + end + end + + if r.nemesis_stats then + nodes[#nodes + 1] = section_header("Opponent Stats") + if r.nemesis_stats.reroll_count then + nodes[#nodes + 1] = text_row("Rerolls:", tostring(r.nemesis_stats.reroll_count), 0.28) + end + if r.nemesis_stats.reroll_cost_total then + nodes[#nodes + 1] = text_row("Reroll $:", tostring(r.nemesis_stats.reroll_cost_total), 0.28) + end + if r.nemesis_stats.vouchers then + nodes[#nodes + 1] = text_row("Vouchers:", r.nemesis_stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) + end + end + + -- Shop spending + if r.shop_spending then + nodes[#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 + nodes[#nodes + 1] = text_row("Total:", "$" .. tostring(total), 0.28) + nodes[#nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02, maxw = 5.5 }, + nodes = { + { n = G.UIT.T, config = { text = table.concat(parts, " "), scale = 0.24, colour = G.C.UI.TEXT_INACTIVE } }, + }, + } + end + + -- 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 + nodes[#nodes + 1] = text_row("Failed Rounds:", table.concat(fr_parts, ", "), 0.28, G.C.UI.TEXT_INACTIVE, G.C.RED) + end + + -- Load as ghost button + nodes[#nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.08 }, + nodes = { + UIBox_button({ + id = "load_previewed_ghost", + button = "load_previewed_ghost", + label = { "Load as Ghost" }, + minw = 4, + minh = 0.6, + scale = 0.35, + colour = G.C.GREEN, + hover = true, + shadow = true, + }), + }, + } + + return { + n = G.UIT.C, + config = { align = "tm", padding = 0.15, minw = 6, r = 0.1, colour = G.C.L_BLACK }, + nodes = nodes, + } +end + +------------------------------------------------------------------------------- +-- Main picker UI +------------------------------------------------------------------------------- + function G.UIDEF.ghost_replay_picker() -- Merge config replays + folder replays into one list, sorted newest-first local config_replays = SMODS.Mods["Multiplayer"].config.ghost_replays or {} @@ -102,9 +378,10 @@ function G.UIDEF.ghost_replay_picker() return (a.timestamp or 0) > (b.timestamp or 0) end) - -- Stash for select_ghost_replay to index into + -- Stash for callbacks to index into _picker_replays = all + -- Left column: replay list local replay_nodes = {} if #all == 0 then @@ -116,7 +393,7 @@ function G.UIDEF.ghost_replay_picker() n = G.UIT.T, config = { text = localize("k_no_ghost_replays"), - scale = 0.4, + scale = 0.35, colour = G.C.UI.TEXT_INACTIVE, }, }, @@ -125,19 +402,27 @@ function G.UIDEF.ghost_replay_picker() else for i, r in ipairs(all) do local label = build_replay_label(r) - local btn_colour = r._source == "file" and G.C.BLUE or G.C.GREY + 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.05 }, + config = { align = "cm", padding = 0.03 }, nodes = { UIBox_button({ id = "ghost_replay_" .. i, - button = "select_ghost_replay", + button = "preview_ghost_replay", label = { label }, - minw = 7, - minh = 0.5, - scale = 0.35, + minw = 5.5, + minh = 0.45, + scale = 0.3, colour = btn_colour, hover = true, shadow = true, @@ -147,113 +432,138 @@ function G.UIDEF.ghost_replay_picker() end end - -- Clear ghost + flip perspective buttons if one is active - local clear_nodes = {} + -- Control buttons below the list + local control_nodes = {} + if MP.GHOST.is_active() then - -- Show who you're playing as local playing_as = MP.GHOST.flipped and (MP.GHOST.replay.nemesis_name or "?") or (MP.GHOST.replay.player_name or "?") - clear_nodes[#clear_nodes + 1] = UIBox_button({ - id = "flip_ghost_perspective", - button = "flip_ghost_perspective", - label = { "Playing as: " .. playing_as }, - minw = 3, - minh = 0.5, - scale = 0.35, - colour = G.C.GREEN, - hover = true, - shadow = true, - }) - clear_nodes[#clear_nodes + 1] = UIBox_button({ - id = "clear_ghost_replay", - button = "clear_ghost_replay", - label = { "Clear Ghost" }, - minw = 3, - minh = 0.5, - scale = 0.35, - colour = G.C.RED, - hover = true, - shadow = true, - }) + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "flip_ghost_perspective", + button = "flip_ghost_perspective", + label = { "As: " .. playing_as }, + minw = 2.5, + minh = 0.45, + scale = 0.3, + colour = G.C.GREEN, + hover = true, + shadow = true, + }), + UIBox_button({ + id = "clear_ghost_replay", + button = "clear_ghost_replay", + label = { "Clear" }, + minw = 2, + minh = 0.45, + scale = 0.3, + colour = G.C.RED, + hover = true, + shadow = true, + }), + }, + } end - -- DEBUG: Generate test replay button - local debug_nodes = {} - debug_nodes[#debug_nodes + 1] = UIBox_button({ - id = "generate_test_ghost", - button = "generate_test_ghost_replay", - label = { "DEBUG: Generate Test Replay" }, - minw = 5, - minh = 0.5, - scale = 0.3, - colour = G.C.PURPLE, - hover = true, - shadow = true, - }) + -- DEBUG button + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "generate_test_ghost", + button = "generate_test_ghost_replay", + label = { "DEBUG: Gen Test" }, + minw = 4, + minh = 0.4, + scale = 0.25, + colour = G.C.PURPLE, + hover = true, + shadow = true, + }), + }, + } - -- Back button to return to practice menu - local back_nodes = {} - back_nodes[#back_nodes + 1] = UIBox_button({ - id = "ghost_picker_back", - button = "ghost_picker_back", - label = { localize("b_back") }, - minw = 3, - minh = 0.6, - scale = 0.4, - colour = G.C.ORANGE, - hover = true, - shadow = true, - }) + -- 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 = 6, minw = 8 }, + config = { align = "cm", colour = G.C.CLEAR, minh = 7, minw = 13 }, nodes = { { - n = G.UIT.C, - config = { align = "cm", padding = 0.2, r = 0.1, colour = G.C.BLACK, minw = 8 }, + n = G.UIT.R, + config = { align = "cm", padding = 0.15 }, nodes = { { - n = G.UIT.R, - config = { align = "cm", padding = 0.1 }, + n = G.UIT.C, + config = { align = "tm", padding = 0.15, r = 0.1, colour = G.C.BLACK }, nodes = { { - n = G.UIT.T, - config = { - text = localize("k_ghost_replays"), - scale = 0.5, - colour = G.C.WHITE, - }, + n = G.UIT.R, + config = { align = "tm", padding = 0.05 }, + nodes = { left_col, right_col }, }, }, }, - { - n = G.UIT.R, - config = { align = "cm", padding = 0.05, maxh = 4 }, - nodes = replay_nodes, - }, - { - n = G.UIT.R, - config = { align = "cm", padding = 0.1 }, - nodes = clear_nodes, - }, - { - n = G.UIT.R, - config = { align = "cm", padding = 0.05 }, - nodes = debug_nodes, - }, - { - n = G.UIT.R, - config = { align = "cm", padding = 0.1 }, - nodes = back_nodes, - }, }, }, }, } end - -G.FUNCS.ghost_picker_back = function(e) - reopen_practice_menu() -end From ca6449c1b65c93bead434cef24b0ed824bc5beec Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:26:07 +0100 Subject: [PATCH 24/36] render jokers in ghost replay picker --- .../play_button/ghost_replay_picker.lua | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 67e27bc4..b3857f50 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -136,22 +136,42 @@ local function format_score(s) return tostring(n) end -local function joker_list_text(jokers) - if not jokers or #jokers == 0 then return "None" end - local names = {} +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 - -- Try to get localized name - local name = key - if G.P_CENTERS and G.P_CENTERS[key] then - local loc = G.P_CENTERS[key].loc_txt - if loc and loc.name then name = loc.name end + 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 - -- Clean up key prefix as fallback display - name = name:gsub("^j_mp_", ""):gsub("^j_", ""):gsub("_", " ") - names[#names + 1] = name end - return table.concat(names, ", ") + + return { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { n = G.UIT.O, config = { object = card_area } }, + }, + } end ------------------------------------------------------------------------------- @@ -247,28 +267,16 @@ local function build_stats_panel(r) end end - -- Jokers + -- Jokers (rendered as actual cards) if r.player_jokers then nodes[#nodes + 1] = section_header("Your Jokers") - local jtext = joker_list_text(r.player_jokers) - nodes[#nodes + 1] = { - n = G.UIT.R, - config = { align = "cl", padding = 0.02, maxw = 5.5 }, - nodes = { - { n = G.UIT.T, config = { text = jtext, scale = 0.26, colour = G.C.WHITE } }, - }, - } + local joker_area = build_joker_card_area(r.player_jokers, 5.5) + if joker_area then nodes[#nodes + 1] = joker_area end end if r.nemesis_jokers then nodes[#nodes + 1] = section_header("Opponent Jokers") - local jtext = joker_list_text(r.nemesis_jokers) - nodes[#nodes + 1] = { - n = G.UIT.R, - config = { align = "cl", padding = 0.02, maxw = 5.5 }, - nodes = { - { n = G.UIT.T, config = { text = jtext, scale = 0.26, colour = G.C.WHITE } }, - }, - } + local joker_area = build_joker_card_area(r.nemesis_jokers, 5.5) + if joker_area then nodes[#nodes + 1] = joker_area end end -- Player stats From 7346df69478dbf926c842db9b8ca93d4566a5b25 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:30:17 +0100 Subject: [PATCH 25/36] two column track drifting --- .../play_button/ghost_replay_picker.lua | 145 ++++++++++-------- 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index b3857f50..8ae03a78 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -196,12 +196,12 @@ local function build_stats_panel(r) } end - local nodes = {} + -- Header row (spans both columns) + local header_nodes = {} - -- Match header local result_str = (r.winner == "player") and "VICTORY" or "DEFEAT" local result_colour = (r.winner == "player") and G.C.GREEN or G.C.RED - nodes[#nodes + 1] = { + header_nodes[#header_nodes + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.04 }, nodes = { @@ -209,10 +209,9 @@ local function build_stats_panel(r) }, } - -- Players local player_display = r.player_name or "?" local nemesis_display = r.nemesis_name or "?" - nodes[#nodes + 1] = { + header_nodes[#header_nodes + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.02 }, nodes = { @@ -220,25 +219,27 @@ local function build_stats_panel(r) }, } - -- Match info - nodes[#nodes + 1] = section_header("Match Info") + -- Left inner column: match info + ante breakdown + stats + local left_nodes = {} + + left_nodes[#left_nodes + 1] = section_header("Match Info") 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 "?" - nodes[#nodes + 1] = text_row("Ruleset:", ruleset_display) - nodes[#nodes + 1] = text_row("Gamemode:", gamemode_display) - nodes[#nodes + 1] = text_row("Deck:", deck_display) - if r.seed then nodes[#nodes + 1] = text_row("Seed:", r.seed) end - if r.stake then nodes[#nodes + 1] = text_row("Stake:", tostring(r.stake)) end - nodes[#nodes + 1] = text_row("Final Ante:", tostring(r.final_ante or "?")) - if r.duration then nodes[#nodes + 1] = text_row("Duration:", r.duration) end + 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 - nodes[#nodes + 1] = text_row("Date:", os.date("%Y-%m-%d %H:%M", r.timestamp)) + 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 - nodes[#nodes + 1] = section_header("Ante Breakdown") + 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) @@ -254,7 +255,7 @@ local function build_stats_panel(r) if snap.player_lives and snap.enemy_lives then lives_str = string.format(" [%d-%d]", snap.player_lives, snap.enemy_lives) end - nodes[#nodes + 1] = { + left_nodes[#left_nodes + 1] = { n = G.UIT.R, config = { align = "cl", padding = 0.01 }, nodes = { @@ -267,48 +268,48 @@ local function build_stats_panel(r) end end - -- Jokers (rendered as actual cards) - if r.player_jokers then - nodes[#nodes + 1] = section_header("Your Jokers") - local joker_area = build_joker_card_area(r.player_jokers, 5.5) - if joker_area then nodes[#nodes + 1] = joker_area end - end - if r.nemesis_jokers then - nodes[#nodes + 1] = section_header("Opponent Jokers") - local joker_area = build_joker_card_area(r.nemesis_jokers, 5.5) - if joker_area then nodes[#nodes + 1] = joker_area end - end - - -- Player stats - if r.player_stats then - nodes[#nodes + 1] = section_header("Your Stats") - if r.player_stats.reroll_count then - nodes[#nodes + 1] = text_row("Rerolls:", tostring(r.player_stats.reroll_count), 0.28) + -- 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 r.player_stats.reroll_cost_total then - nodes[#nodes + 1] = text_row("Reroll $:", tostring(r.player_stats.reroll_cost_total), 0.28) + if stats.reroll_cost_total then + nodes[#nodes + 1] = text_row("Reroll $:", tostring(stats.reroll_cost_total), 0.28) end - if r.player_stats.vouchers then - nodes[#nodes + 1] = text_row("Vouchers:", r.player_stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) + if stats.vouchers then + nodes[#nodes + 1] = text_row("Vouchers:", stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) end end - if r.nemesis_stats then - nodes[#nodes + 1] = section_header("Opponent Stats") - if r.nemesis_stats.reroll_count then - nodes[#nodes + 1] = text_row("Rerolls:", tostring(r.nemesis_stats.reroll_count), 0.28) - end - if r.nemesis_stats.reroll_cost_total then - nodes[#nodes + 1] = text_row("Reroll $:", tostring(r.nemesis_stats.reroll_cost_total), 0.28) - end - if r.nemesis_stats.vouchers then - nodes[#nodes + 1] = text_row("Vouchers:", r.nemesis_stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) - 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 - nodes[#nodes + 1] = section_header("Shop Spending") + right_nodes[#right_nodes + 1] = section_header("Shop Spending") local total = 0 local antes = {} for k, v in pairs(r.shop_spending) do @@ -320,25 +321,36 @@ local function build_stats_panel(r) 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 - nodes[#nodes + 1] = text_row("Total:", "$" .. tostring(total), 0.28) - nodes[#nodes + 1] = { + 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 = 5.5 }, + 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 - -- 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 - nodes[#nodes + 1] = text_row("Failed Rounds:", table.concat(fr_parts, ", "), 0.28, G.C.UI.TEXT_INACTIVE, G.C.RED) - 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, + }, + }, + } - -- Load as ghost button - nodes[#nodes + 1] = { + -- Load as ghost button (spans full width) + local load_button = { n = G.UIT.R, config = { align = "cm", padding = 0.08 }, nodes = { @@ -358,8 +370,15 @@ local function build_stats_panel(r) return { n = G.UIT.C, - config = { align = "tm", padding = 0.15, minw = 6, r = 0.1, colour = G.C.L_BLACK }, - nodes = nodes, + 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 From 20e924ce5b631786223720f1f40fd8189e2057e5 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:46:48 +0100 Subject: [PATCH 26/36] bump copy --- localization/en-us.lua | 2 +- ui/main_menu/play_button/ghost_replay_picker.lua | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index 1ff2a414..a7af9656 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1098,7 +1098,7 @@ return { "Enemy", "location", }, - k_ghost_replays = "Ghost Replays", + k_ghost_replays = "Match Replays", k_no_ghost_replays = "No replays yet", k_ghost = "Ghost", k_hide_mp_content = "Hide Multiplayer content*", diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 8ae03a78..b6d7d527 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -1,6 +1,6 @@ --- Ghost Replay Picker UI +-- Match Replay Picker UI -- Shown in practice mode to select a past match replay for ghost PvP --- Two-column layout: replay list (left) + stats detail panel (right) +-- Two-column layout: replay list (left) + match details panel (right) local function reopen_practice_menu() G.FUNCS.overlay_menu({ @@ -187,7 +187,7 @@ local function build_stats_panel(r) { n = G.UIT.T, config = { - text = "Select a replay", + text = "Select a match", scale = 0.35, colour = G.C.UI.TEXT_INACTIVE, }, @@ -357,7 +357,7 @@ local function build_stats_panel(r) UIBox_button({ id = "load_previewed_ghost", button = "load_previewed_ghost", - label = { "Load as Ghost" }, + label = { "Play Match" }, minw = 4, minh = 0.6, scale = 0.35, @@ -473,7 +473,7 @@ function G.UIDEF.ghost_replay_picker() UIBox_button({ id = "flip_ghost_perspective", button = "flip_ghost_perspective", - label = { "As: " .. playing_as }, + label = { "Playing as: " .. playing_as }, minw = 2.5, minh = 0.45, scale = 0.3, @@ -484,7 +484,7 @@ function G.UIDEF.ghost_replay_picker() UIBox_button({ id = "clear_ghost_replay", button = "clear_ghost_replay", - label = { "Clear" }, + label = { "Clear Replay" }, minw = 2, minh = 0.45, scale = 0.3, From f0609bf7d900a968595b8abbc00fe6aab7366474 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 01:53:18 +0100 Subject: [PATCH 27/36] lua log parser port --- lib/log_parser.lua | 436 ++++++++++++++++++++++++++++++++++++++++++ lib/match_history.lua | 19 ++ 2 files changed, 455 insertions(+) create mode 100644 lib/log_parser.lua 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 index 76d9f1b2..b3d2fc7f 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -141,6 +141,7 @@ end 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 @@ -168,6 +169,24 @@ function MP.GHOST.load_folder_replays() 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 + 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 .. (idx > 1 and ("#" .. idx) or "") + table.insert(results, replay) + end + end + else + sendWarnMessage("Failed to parse log: " .. item.name, "MULTIPLAYER") + end + end end end From 87ad33b0b9d4331f05ef0f144e30eca6acddc907 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Tue, 17 Mar 2026 02:17:03 +0100 Subject: [PATCH 28/36] lose lives on pvp --- ui/game/game_state.lua | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index ff519779..3dc48b6f 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -295,7 +295,21 @@ function Game:update_new_round(dt) -- 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 - if not ghost then + 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) From 0ce97966ed58155347d93f7dc3412aaabbe99d88 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:22:05 +0100 Subject: [PATCH 29/36] show multiple games per logfile --- lib/match_history.lua | 5 ++- .../play_button/ghost_replay_picker.lua | 39 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index b3d2fc7f..24ad0517 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -175,11 +175,14 @@ function MP.GHOST.load_folder_replays() 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 .. (idx > 1 and ("#" .. idx) or "") + replay._filename = item.name + replay._game_index = idx + replay._game_count = total table.insert(results, replay) end end diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index b6d7d527..35c5e5d3 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -90,13 +90,19 @@ local function build_replay_label(r) 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 %s v %s A%s %s%s", result_text, player_display, nemesis_display, ante_display, - timestamp_display + timestamp_display, + game_tag ) end @@ -223,6 +229,13 @@ local function build_stats_panel(r) 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 "?" @@ -427,7 +440,29 @@ function G.UIDEF.ghost_replay_picker() }, } 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 From 973784cfba5db2b579148e85f0f9138a0c593cf8 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:24:36 +0100 Subject: [PATCH 30/36] remove "save match history" code path; remove debug button --- lib/match_history.lua | 82 ------------- .../play_button/ghost_replay_picker.lua | 114 +++++++++--------- 2 files changed, 54 insertions(+), 142 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 24ad0517..46598d3b 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -63,31 +63,6 @@ function MP.MATCH_RECORD.finalize(won) 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 - - local config = SMODS.Mods["Multiplayer"].config - config.ghost_replays = config.ghost_replays or {} - - local entry = { - seed = MP.MATCH_RECORD.seed, - ruleset = MP.MATCH_RECORD.ruleset, - gamemode = MP.MATCH_RECORD.gamemode, - deck = MP.MATCH_RECORD.deck, - player_name = MP.MATCH_RECORD.player_name, - nemesis_name = MP.MATCH_RECORD.nemesis_name, - ante_snapshots = MP.MATCH_RECORD.ante_snapshots, - winner = MP.MATCH_RECORD.winner, - final_ante = MP.MATCH_RECORD.final_ante, - timestamp = os.time(), - } - - table.insert(config.ghost_replays, entry) - - -- Keep only last 20 replays - while #config.ghost_replays > 20 do - table.remove(config.ghost_replays, 1) - end - - SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) end -- Ghost Replay playback state @@ -200,60 +175,3 @@ function MP.GHOST.load_folder_replays() return results end - --- DEBUG: Generate a fake ghost replay for testing. Remove before release. -function MP.GHOST.generate_test_replay() - local config = SMODS.Mods["Multiplayer"].config - config.ghost_replays = config.ghost_replays or {} - - local fake = { - seed = "ABCDE", - ruleset = "ruleset_mp_standard_ranked", - gamemode = "gamemode_mp_attrition", - deck = "Abandoned Deck", - player_name = MP.UI.get_username(), - nemesis_name = "Zaino", - stake = 1, - winner = "nemesis", - final_ante = 6, - timestamp = os.time(), - ante_snapshots = { - [1] = { enemy_score = "0", player_score = "0", player_lives = 4, enemy_lives = 4, result = "win" }, - [2] = { enemy_score = "8000", player_score = "5000", player_lives = 4, enemy_lives = 4, result = "win" }, - [3] = { enemy_score = "45000", player_score = "30000", player_lives = 4, enemy_lives = 3, result = "loss" }, - [4] = { enemy_score = "200000", player_score = "150000", player_lives = 3, enemy_lives = 3, result = "win" }, - [5] = { - enemy_score = "1200000", - player_score = "800000", - player_lives = 3, - enemy_lives = 2, - result = "loss", - }, - [6] = { - enemy_score = "5000000", - player_score = "3000000", - player_lives = 2, - enemy_lives = 2, - result = "loss", - }, - [7] = { - enemy_score = "25000000", - player_score = "15000000", - player_lives = 1, - enemy_lives = 2, - result = "loss", - }, - [8] = { - enemy_score = "100000000", - player_score = "50000000", - player_lives = 1, - enemy_lives = 2, - result = "loss", - }, - }, - } - - table.insert(config.ghost_replays, fake) - SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) - sendDebugMessage("Test ghost replay generated", "MULTIPLAYER") -end diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 35c5e5d3..e08bc6f6 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -66,12 +66,6 @@ function G.FUNCS.flip_ghost_perspective(e) refresh_picker() end --- DEBUG: Generate a test ghost replay and refresh the picker -function G.FUNCS.generate_test_ghost_replay(e) - MP.GHOST.generate_test_replay() - refresh_picker() -end - G.FUNCS.ghost_picker_back = function(e) _preview_idx = nil reopen_practice_menu() @@ -160,7 +154,8 @@ local function build_joker_card_area(jokers, width) local center = G.P_CENTERS[key] if center then local card = Card( - 0, 0, + 0, + 0, G.CARD_W * card_size, G.CARD_H * card_size, nil, @@ -221,7 +216,10 @@ local function build_stats_panel(r) 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 } }, + { + n = G.UIT.T, + config = { text = player_display .. " vs " .. nemesis_display, scale = 0.35, colour = G.C.WHITE }, + }, }, } @@ -246,15 +244,15 @@ local function build_stats_panel(r) 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 + 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 + for k in pairs(r.ante_snapshots) do + antes[#antes + 1] = tonumber(k) + end table.sort(antes) for _, ante_num in ipairs(antes) do @@ -272,9 +270,23 @@ local function build_stats_panel(r) 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 = 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 } }, + { + 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 @@ -285,14 +297,13 @@ local function build_stats_panel(r) 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_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) + nodes[#nodes + 1] = + text_row("Vouchers:", stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) end end @@ -302,8 +313,11 @@ local function build_stats_panel(r) -- 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) + 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 @@ -339,7 +353,10 @@ local function build_stats_panel(r) 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 } }, + { + n = G.UIT.T, + config = { text = table.concat(parts, " "), scale = 0.24, colour = G.C.UI.TEXT_INACTIVE }, + }, }, } end @@ -386,7 +403,11 @@ local function build_stats_panel(r) 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 } } }, + { + 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 @@ -400,18 +421,8 @@ end ------------------------------------------------------------------------------- function G.UIDEF.ghost_replay_picker() - -- Merge config replays + folder replays into one list, sorted newest-first - local config_replays = SMODS.Mods["Multiplayer"].config.ghost_replays or {} - local folder_replays = MP.GHOST.load_folder_replays() - - local all = {} - for _, r in ipairs(config_replays) do - r._source = r._source or "config" - all[#all + 1] = r - end - for _, r in ipairs(folder_replays) do - all[#all + 1] = r - end + -- 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) @@ -451,11 +462,14 @@ function G.UIDEF.ghost_replay_picker() 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, - } }, + { + n = G.UIT.T, + config = { + text = display_name .. " (" .. r._game_count .. " games)", + scale = 0.25, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, }, } end @@ -498,8 +512,7 @@ function G.UIDEF.ghost_replay_picker() local control_nodes = {} if MP.GHOST.is_active() then - local playing_as = MP.GHOST.flipped - and (MP.GHOST.replay.nemesis_name or "?") + local playing_as = MP.GHOST.flipped and (MP.GHOST.replay.nemesis_name or "?") or (MP.GHOST.replay.player_name or "?") control_nodes[#control_nodes + 1] = { n = G.UIT.R, @@ -531,25 +544,6 @@ function G.UIDEF.ghost_replay_picker() } end - -- DEBUG button - control_nodes[#control_nodes + 1] = { - n = G.UIT.R, - config = { align = "cm", padding = 0.03 }, - nodes = { - UIBox_button({ - id = "generate_test_ghost", - button = "generate_test_ghost_replay", - label = { "DEBUG: Gen Test" }, - minw = 4, - minh = 0.4, - scale = 0.25, - colour = G.C.PURPLE, - hover = true, - shadow = true, - }), - }, - } - -- Back button control_nodes[#control_nodes + 1] = { n = G.UIT.R, From a1de88367eefd8c881643ac8dcd029eba67e8bbb Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:25:44 +0100 Subject: [PATCH 31/36] display message if no replays --- ui/main_menu/play_button/ghost_replay_picker.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index e08bc6f6..534e9105 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -450,6 +450,20 @@ function G.UIDEF.ghost_replay_picker() }, }, } + 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 From 3db06dce151a471d5e2b5c14d28f3059cd99a436 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:30:44 +0100 Subject: [PATCH 32/36] gitignore --- .gitignore | 2 + .../play_button/ghost_replay_picker.lua | 50 ++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) 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/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 534e9105..23422e78 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -25,9 +25,14 @@ end 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 @@ -37,6 +42,7 @@ function G.FUNCS.load_previewed_ghost(e) if not replay then return end MP.GHOST.load(replay) + MP.GHOST.flipped = _preview_flipped if replay.ruleset then MP.SP.ruleset = replay.ruleset @@ -47,6 +53,7 @@ function G.FUNCS.load_previewed_ghost(e) if replay.gamemode then MP.LOBBY.config.gamemode = replay.gamemode end _preview_idx = nil + _preview_flipped = false reopen_practice_menu() end @@ -58,16 +65,22 @@ 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) - MP.GHOST.flip() + 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 @@ -379,17 +392,31 @@ local function build_stats_panel(r) }, } - -- Load as ghost button (spans full width) + -- 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 load_button = { n = G.UIT.R, config = { align = "cm", padding = 0.08 }, nodes = { + 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, + }), UIBox_button({ id = "load_previewed_ghost", button = "load_previewed_ghost", label = { "Play Match" }, - minw = 4, - minh = 0.6, + minw = 3.5, + minh = 0.5, scale = 0.35, colour = G.C.GREEN, hover = true, @@ -526,28 +553,15 @@ function G.UIDEF.ghost_replay_picker() local control_nodes = {} if MP.GHOST.is_active() then - local playing_as = MP.GHOST.flipped and (MP.GHOST.replay.nemesis_name or "?") - or (MP.GHOST.replay.player_name or "?") control_nodes[#control_nodes + 1] = { n = G.UIT.R, config = { align = "cm", padding = 0.03 }, nodes = { - UIBox_button({ - id = "flip_ghost_perspective", - button = "flip_ghost_perspective", - label = { "Playing as: " .. playing_as }, - minw = 2.5, - minh = 0.45, - scale = 0.3, - colour = G.C.GREEN, - hover = true, - shadow = true, - }), UIBox_button({ id = "clear_ghost_replay", button = "clear_ghost_replay", label = { "Clear Replay" }, - minw = 2, + minw = 3, minh = 0.45, scale = 0.3, colour = G.C.RED, From 241b78cdc3b7177be715a988973d8a18df04a8ac Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:39:15 +0100 Subject: [PATCH 33/36] fix double ante increment --- lovely/end_round.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lovely/end_round.toml b/lovely/end_round.toml index 44bca84d..d4625c08 100644 --- a/lovely/end_round.toml +++ b/lovely/end_round.toml @@ -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 ''' From d3b7a06c129278cb1e96259b53c4985ebd488df8 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Wed, 18 Mar 2026 19:40:37 +0100 Subject: [PATCH 34/36] prevent leak into main game --- networking-old/action_handlers.lua | 4 +++ networking/action_handlers.lua | 4 +++ .../play_button/play_button_callbacks.lua | 34 +++++++++++-------- 3 files changed, 27 insertions(+), 15 deletions(-) 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 7af84d97..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) diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 680aeaed..56c6505c 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -56,22 +56,23 @@ function G.FUNCS.start_practice_run(e) 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 + 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") + 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 @@ -149,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 From 96f292af136cb62f718c7632180317fd4f232134 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 19 Mar 2026 13:13:18 +0100 Subject: [PATCH 35/36] Add hand-by-hand score playback --- lib/match_history.lua | 107 +++++++++++++++++- rulesets/_rulesets.lua | 4 +- ui/game/game_state.lua | 72 +++++++++++- ui/game/round.lua | 7 +- .../play_button/ghost_replay_picker.lua | 2 - 5 files changed, 171 insertions(+), 21 deletions(-) diff --git a/lib/match_history.lua b/lib/match_history.lua index 46598d3b..6239464f 100644 --- a/lib/match_history.lua +++ b/lib/match_history.lua @@ -65,37 +65,134 @@ function MP.MATCH_RECORD.finalize(won) MP.MATCH_RECORD.final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1 end --- Ghost Replay playback state --- Loads a stored match record and provides enemy scores for PvP blinds --- so practice mode can simulate playing against a past opponent. +MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil } -MP.GHOST = { active = false, replay = nil, flipped = false } +-- 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 - -- When flipped, the original player's score becomes the ghost target 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 diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 778046ae..13d84f4e 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -61,8 +61,8 @@ function MP.get_active_gamemode() return MP.LOBBY.config.gamemode elseif MP.is_practice_mode() then -- Ghost replay stores the gamemode directly - if MP.GHOST.is_active() and MP.LOBBY.config.gamemode then - return MP.LOBBY.config.gamemode + 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 diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index 3dc48b6f..8ee52df0 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -217,13 +217,20 @@ function Game:update_hand_played(dt) if not ghost then MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) end - -- For now, never advance to next round + if G.GAME.current_round.hands_left < 1 then if ghost then - -- Auto-resolve PvP round locally - local enemy_score = MP.GAME.enemy.score.coeffiocient * (10 ^ MP.GAME.enemy.score.exponent) - local beat_ghost = to_big(G.GAME.chips) >= to_big(enemy_score) - if beat_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 @@ -232,7 +239,6 @@ function Game:update_hand_played(dt) return true end else - -- Mirror action_player_info: comeback bonus + no gold on loss if MP.LOBBY.config.gold_on_life_loss then MP.GAME.comeback_bonus_given = false MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1 @@ -264,6 +270,60 @@ function Game:update_hand_played(dt) 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 diff --git a/ui/game/round.lua b/ui/game/round.lua index 623ea96f..d86abf53 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -50,13 +50,8 @@ function reset_blinds() G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss end - -- Inject ghost enemy score for PvP blinds if MP.GHOST.is_active() then - local ghost_score_str = MP.GHOST.get_enemy_score(G.GAME.round_resets.ante) - if ghost_score_str then - MP.GAME.enemy.score = MP.INSANE_INT.from_string(ghost_score_str) - MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score) - end + MP.GHOST.init_playback(G.GAME.round_resets.ante) end end diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 23422e78..897edd82 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -50,8 +50,6 @@ function G.FUNCS.load_previewed_ghost(e) MP.LoadReworks(ruleset_name) end - if replay.gamemode then MP.LOBBY.config.gamemode = replay.gamemode end - _preview_idx = nil _preview_flipped = false reopen_practice_menu() From 5723d0372479d370149a8095d6782b6e517d18fe Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Fri, 20 Mar 2026 03:17:57 +0100 Subject: [PATCH 36/36] Block playback for replays with unsupported rulesets Old replays (e.g. pre-rename "ranked") reference rulesets no longer in MP.Rulesets. Show stats normally but replace Play Match button with a red error message so LoadReworks doesn't crash on a missing ruleset. --- .../play_button/ghost_replay_picker.lua | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 897edd82..5522e2cd 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -40,6 +40,7 @@ end function G.FUNCS.load_previewed_ghost(e) local replay = _picker_replays[_preview_idx] if not replay then return end + if not is_replay_ruleset_supported(replay) then return end MP.GHOST.load(replay) MP.GHOST.flipped = _preview_flipped @@ -86,6 +87,11 @@ 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 "?" @@ -394,33 +400,54 @@ local function build_stats_panel(r) 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 = { - 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, - }), - 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, - }), - }, + nodes = action_nodes, } return {