diff --git a/.claude/plans/practice-mode-review.md b/.claude/plans/practice-mode-review.md new file mode 100644 index 00000000..8c020295 --- /dev/null +++ b/.claude/plans/practice-mode-review.md @@ -0,0 +1,105 @@ +# Practice Mode Review Pass + +## Progress + +- [x] **Step 1** — Remove `MP.MATCH_RECORD` code path *(commit 0243980)* +- [x] **Step 2** — Move `MP.is_mp_or_ghost()` out of UI file *(commit 0607874)* +- [x] **Step 3** — Rename `M` in `log_parser.lua` to `LOG_PARSER` *(commit b677a0e)* +- [x] **Step 4** — Extract JSON loading to local function in `match_history.lua` *(commit e8d976b)* +- [x] **Step 5** — Move data helpers from `ghost_replay_picker.lua` to `lib/match_history.lua` *(commit 2ca08f8)* +- [ ] **Step 6** — Move practice mode logic to its own `lib/` file +- [ ] **Step 7** — Update stale comments + +--- + +## Step 1 — Remove `MP.MATCH_RECORD` code path +**Comment #5** (match_history.lua:49-58) + +Snapshot/recording machinery is dead code — written but never read. Replays come from log files. + +- Remove `MP.MATCH_RECORD` table, `reset()`, `init()`, `snapshot_ante()`, `finalize()` +- Remove callers in `networking/action_handlers.lua` (init, snapshot_ante, 2x finalize) +- Remove callers in `ui/game/game_state.lua` (5x finalize) +- Update file header comment + +**Files:** `lib/match_history.lua`, `networking/action_handlers.lua`, `ui/game/game_state.lua` + +--- + +## Step 2 — Move `MP.is_mp_or_ghost()` out of UI file +**Comment #7** (game_state.lua:4-6) + +`MP.is_mp_or_ghost()` is a utility predicate — shouldn't live in a UI file that monkey-patches Game methods. + +- Move the function definition to `lib/match_history.lua` (where `MP.GHOST` lives) +- Verify load order: `match_history.lua` must be loaded before `game_state.lua` +- Grep all callers to confirm nothing breaks + +**Files:** `ui/game/game_state.lua`, `lib/match_history.lua` + +--- + +## Step 3 — Rename `M` in `log_parser.lua` to MP-namespace +**Comment #1** (log_parser.lua:4) + +The log parser uses `local M = {}` as its module table. Should use something in the MP namespace. + +- Propose a few name options to the user before changing +- Replace `M` throughout the file once agreed +- Check callers (`MP.load_mp_file("lib/log_parser.lua")` returns the module — callers may not care about the internal name, but verify) + +**Files:** `lib/log_parser.lua` + +--- + +## Step 4 — Extract JSON loading to local function in `match_history.lua` +**Comment #4** (match_history.lua:214-266) + +The `.log` path should be the main code path. The `.json` path is mostly for verifying the Lua log parser produces the same output as the Python tool. + +- Extract the `.json` loading branch into a `local function load_json_replay(filepath, filename)` +- Add a comment explaining it's for verification against the Python tool +- Keep `.log` path as the primary inline code + +**Files:** `lib/match_history.lua` + +--- + +## Step 5 — Move data-loading logic from picker to `lib/match_history.lua` +**Comment #8** (ghost_replay_picker.lua:1-3) + +Some non-UI logic in the picker should live in the lib layer. + +- Identify functions in `ghost_replay_picker.lua` that are data-processing (not UI) +- Move them to `lib/match_history.lua` +- Picker keeps only UI layout and event handlers + +**Files:** `ui/main_menu/play_button/ghost_replay_picker.lua`, `lib/match_history.lua` + +--- + +## Step 6 — Move practice mode logic to its own `lib/` file +**Comment #6** (play_button_callbacks.lua:2-6) + +`MP.SP`, `MP.is_practice_mode()`, and setup/start logic should live in `lib/practice_mode.lua`, not a UI callbacks file. Also clarify the naming situation (`MP.SP`, `MP.GHOST`, `MP.SP.practice`, `MP.is_practice_mode()`). + +- Create `lib/practice_mode.lua` +- Move `MP.SP` table definition + `MP.is_practice_mode()` there +- Move `G.FUNCS.setup_practice_mode()` and `G.FUNCS.start_practice_run()` there +- Move `G.FUNCS.toggle_unlimited_slots()` there +- Wire up in `core.lua` load order +- Consider renaming / documenting the SP/GHOST/practice relationship + +**Files:** `lib/practice_mode.lua` (new), `ui/main_menu/play_button/play_button_callbacks.lua`, `core.lua` + +--- + +## Step 7 — Update stale comments +**Comments #2 and #3** (match_history.lua:1-12, match_history.lua:209-212) + +After all structural changes, update comments to reflect reality. + +- Rewrite file header for `match_history.lua` (no longer about "data capture" / persistence) +- Update the `load_folder_replays` comment (no longer just JSON; log files are primary) + +**Files:** `lib/match_history.lua` diff --git a/.gitignore b/.gitignore index ae5a6e0e..688cd47a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .vs .DS_Store .env +replays/* +!replays/.gitkeep diff --git a/config.lua b/config.lua index 3a036cae..50922d1d 100644 --- a/config.lua +++ b/config.lua @@ -13,4 +13,5 @@ return { ["preview"] = {}, ["joker_stats"] = {}, ["match_history"] = {}, + ["ghost_replays"] = {}, } diff --git a/core.lua b/core.lua index 8391e4a9..665890e8 100644 --- a/core.lua +++ b/core.lua @@ -97,7 +97,12 @@ MP.SMODS_VERSION = "1.0.0~BETA-1503a" MP.REQUIRED_LOVELY_VERSION = "0.9" function MP.should_use_the_order() - return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code + if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code then + return true + elseif MP.is_practice_mode() then -- should actually check the ruleset but okay for now + return true + end + return false end function MP.is_major_league_ruleset() diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua new file mode 100644 index 00000000..53b87ea0 --- /dev/null +++ b/lib/ghost_replay.lua @@ -0,0 +1,365 @@ +-- Ghost Replay: load and play back ghost replays from log files. + +function MP.is_mp_or_ghost() + return MP.LOBBY.code or MP.GHOST.is_active() +end + +MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil } + +-- Per-ante playback state +MP.GHOST._hands = {} +MP.GHOST._hand_idx = 0 +MP.GHOST._advancing = false + +function MP.GHOST.load(replay) + MP.GHOST.active = true + MP.GHOST.replay = replay + MP.GHOST.flipped = false + MP.GHOST.gamemode = replay and replay.gamemode or nil + MP.GHOST._hands = {} + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false +end + +function MP.GHOST.clear() + MP.GHOST.active = false + MP.GHOST.replay = nil + MP.GHOST.flipped = false + MP.GHOST.gamemode = nil + MP.GHOST._hands = {} + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false +end + +function MP.GHOST.flip() + MP.GHOST.flipped = not MP.GHOST.flipped +end + +function MP.GHOST.get_enemy_hands(ante) + if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return {} end + local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)] + if not snapshot or not snapshot.hands then return {} end + local enemy_side = MP.GHOST.flipped and "player" or "enemy" + local out = {} + for _, h in ipairs(snapshot.hands) do + if h.side == enemy_side then + out[#out + 1] = h + end + end + return out +end + +function MP.GHOST.init_playback(ante) + local hands = MP.GHOST.get_enemy_hands(ante) + MP.GHOST._hands = hands + MP.GHOST._hand_idx = 0 + MP.GHOST._advancing = false + if #hands > 0 then + MP.GHOST._hand_idx = 1 + local score = MP.INSANE_INT.from_string(hands[1].score) + MP.GAME.enemy.score = score + MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score) + MP.GAME.enemy.hands = hands[1].hands_left or 0 + return true + end + return false +end + +function MP.GHOST.advance_hand() + if MP.GHOST._hand_idx >= #MP.GHOST._hands then return false end + MP.GHOST._hand_idx = MP.GHOST._hand_idx + 1 + local entry = MP.GHOST._hands[MP.GHOST._hand_idx] + local score = MP.INSANE_INT.from_string(entry.score) + + G.E_MANAGER:add_event(Event({ + blockable = false, blocking = false, + trigger = "ease", delay = 0.5, + ref_table = MP.GAME.enemy.score, + ref_value = "e_count", + ease_to = score.e_count, + func = function(t) return math.floor(t) end, + })) + G.E_MANAGER:add_event(Event({ + blockable = false, blocking = false, + trigger = "ease", delay = 0.5, + ref_table = MP.GAME.enemy.score, + ref_value = "coeffiocient", + ease_to = score.coeffiocient, + func = function(t) return math.floor(t) end, + })) + G.E_MANAGER:add_event(Event({ + blockable = false, blocking = false, + trigger = "ease", delay = 0.5, + ref_table = MP.GAME.enemy.score, + ref_value = "exponent", + ease_to = score.exponent, + func = function(t) return math.floor(t) end, + })) + + MP.GAME.enemy.hands = entry.hands_left or 0 + if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end + return true +end + +function MP.GHOST.playback_exhausted() + return #MP.GHOST._hands == 0 or MP.GHOST._hand_idx >= #MP.GHOST._hands +end + +function MP.GHOST.has_hand_data() + return #MP.GHOST._hands > 0 +end + +-- Reads target from hands array directly, bypassing the eased score table. +function MP.GHOST.current_target_big() + if MP.GHOST._hand_idx < 1 or MP.GHOST._hand_idx > #MP.GHOST._hands then return to_big(0) end + local entry = MP.GHOST._hands[MP.GHOST._hand_idx] + local score = MP.INSANE_INT.from_string(entry.score) + return to_big(score.coeffiocient * (10 ^ score.exponent)) +end + +function MP.GHOST.get_nemesis_name() + if not MP.GHOST.replay then return nil end + if MP.GHOST.flipped then + return MP.GHOST.replay.player_name or localize("k_ghost") + else + return MP.GHOST.replay.nemesis_name or localize("k_ghost") + end +end + +-- Returns a UI string table for the PvP blind name. +-- Uses a static { string = ... } entry (ghost name is fixed for the run), +-- unlike live MP which uses { ref_table, ref_value } for reactive updates. +function MP.GHOST.get_blind_name_ui() + return { { string = MP.GHOST.get_nemesis_name() } } +end + +-- Resolve the end of a PvP round when the player has no hands left. +-- Returns "won", "game_over", or "continue". +function MP.GHOST.resolve_pvp_hands_exhausted(chips) + local beat_current = to_big(chips) >= MP.GHOST.current_target_big() + local all_exhausted = MP.GHOST.playback_exhausted() + + if beat_current and all_exhausted then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + return "won" + end + else + if MP.LOBBY.config.gold_on_life_loss then + MP.GAME.comeback_bonus_given = false + MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1 + end + MP.GAME.lives = MP.GAME.lives - 1 + MP.UI.ease_lives(-1) + if MP.LOBBY.config.no_gold_on_round_loss and G.GAME.blind and G.GAME.blind.dollars then + G.GAME.blind.dollars = 0 + end + if MP.GAME.lives <= 0 then + return "game_over" + end + end + MP.GAME.end_pvp = true + return "continue" +end + +-- Resolve mid-hand state when the player still has hands remaining. +-- Checks whether the player has already beaten all ghost hands; if so, takes +-- an enemy life. If the ghost has more hands, kicks off the advance animation. +-- Returns true if the PvP round ended (win or end_pvp set). +function MP.GHOST.resolve_pvp_mid_hand(chips) + if not MP.GHOST.has_hand_data() then return false end + + local beat_current = to_big(chips) >= MP.GHOST.current_target_big() + + if beat_current and MP.GHOST.playback_exhausted() then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + win_game() + return true + end + MP.GAME.end_pvp = true + return true + elseif beat_current and not MP.GHOST.playback_exhausted() and not MP.GHOST._advancing then + MP.GHOST._start_advance_sequence() + end + return false +end + +-- Animate advancing through remaining ghost hands until the player's score +-- no longer beats the ghost, or all ghost hands are exhausted. +function MP.GHOST._start_advance_sequence() + MP.GHOST._advancing = true + local function step() + MP.GHOST.advance_hand() + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.6, + func = function() + if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() then + step() + else + if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and MP.GHOST.playback_exhausted() then + MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1 + if MP.GAME.enemy.lives <= 0 then + MP.GAME.won = true + win_game() + MP.GHOST._advancing = false + return true + end + MP.GAME.end_pvp = true + end + MP.GHOST._advancing = false + end + return true + end, + })) + end + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "after", + delay = 0.5, + func = function() + step() + return true + end, + })) +end + +-- Handle life loss when the player fails a non-PvP round in ghost mode. +-- Returns "game_over" or nil. +function MP.GHOST.resolve_round_fail() + if MP.LOBBY.config.death_on_round_loss and G.GAME.current_round.hands_played > 0 then + MP.GAME.lives = MP.GAME.lives - 1 + MP.UI.ease_lives(-1) + if MP.LOBBY.config.no_gold_on_round_loss and G.GAME.blind and G.GAME.blind.dollars then + G.GAME.blind.dollars = 0 + end + if MP.GAME.lives <= 0 then + return "game_over" + end + end + return nil +end + +function MP.GHOST.is_active() + return MP.GHOST.active and MP.GHOST.replay ~= nil +end + +function MP.GHOST.is_ruleset_supported(replay) + if not replay or not replay.ruleset then return true end + return MP.Rulesets[replay.ruleset] ~= nil +end + +function MP.GHOST.format_score(s) + local n = tonumber(s) + if not n then return tostring(s) end + if n >= 1000000 then + return string.format("%.1fM", n / 1000000) + elseif n >= 1000 then + return string.format("%.1fK", n / 1000) + end + return tostring(n) +end + +function MP.GHOST.build_label(r) + local result_text = (r.winner == "player") and "W" or "L" + local player_display = r.player_name or "?" + local nemesis_display = r.nemesis_name or "?" + local ante_display = tostring(r.final_ante or "?") + + local timestamp_display = "" + if r.timestamp then timestamp_display = os.date("%m/%d", r.timestamp) end + + local game_tag = "" + if r._game_index and r._game_count and r._game_count > 1 then + game_tag = string.format(" [%d/%d]", r._game_index, r._game_count) + end + + return string.format( + "%s %s v %s A%s %s%s", + result_text, + player_display, + nemesis_display, + ante_display, + timestamp_display, + game_tag + ) +end + +-- Load ghost replays from .log and .json files in the replays/ folder. +-- Files are read once when the picker is opened; drop a Lovely log or +-- a .json file into replays/ and it shows up in the ghost replay picker. + +-- Load a .json replay file — useful for verifying log parser output or +-- loading replays exported from external tools. +local function load_json_replay(filepath, filename) + local json = require("json") + local content = NFS.read(filepath) + if not content then return nil end + + local ok, replay = pcall(json.decode, content) + if not ok or not replay or not replay.ante_snapshots then + sendWarnMessage("Failed to parse replay: " .. filename, "MULTIPLAYER") + return nil + end + + -- Convert string ante keys to numbers for consistency + local fixed = {} + for k, v in pairs(replay.ante_snapshots) do + fixed[tonumber(k) or k] = v + end + replay.ante_snapshots = fixed + replay._source = "file" + replay._filename = filename + return replay +end + +function MP.GHOST.load_folder_replays() + local log_parser = MP.load_mp_file("lib/log_parser.lua") + local replays_dir = MP.path .. "/replays" + local dir_info = NFS.getInfo(replays_dir) + if not dir_info or dir_info.type ~= "directory" then return {} end + + local items = NFS.getDirectoryItemsInfo(replays_dir) + local results = {} + + for _, item in ipairs(items) do + if item.type == "file" and item.name:match("%.log$") then + local filepath = replays_dir .. "/" .. item.name + local content = NFS.read(filepath) + if content and log_parser then + local ok, game_records = pcall(log_parser.process_log, content) + if ok and game_records then + local total = #game_records + for idx, game in ipairs(game_records) do + local ok2, replay = pcall(log_parser.to_replay, game) + if ok2 and replay and replay.ante_snapshots and next(replay.ante_snapshots) then + replay._source = "file" + replay._filename = item.name + replay._game_index = idx + replay._game_count = total + table.insert(results, replay) + end + end + else + sendWarnMessage("Failed to parse log: " .. item.name, "MULTIPLAYER") + end + end + elseif item.type == "file" and item.name:match("%.json$") then + local replay = load_json_replay(replays_dir .. "/" .. item.name, item.name) + if replay then table.insert(results, replay) end + end + end + + -- Sort by timestamp descending (newest first) + table.sort(results, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + return results +end diff --git a/lib/log_parser.lua b/lib/log_parser.lua new file mode 100644 index 00000000..c1921e5d --- /dev/null +++ b/lib/log_parser.lua @@ -0,0 +1,436 @@ +-- Log Parser: parse Lovely log files into ghost replay tables. +-- Lua port of tools/log_to_ghost_replay.py + +local LOG_PARSER = {} + +------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------- + +local function parse_timestamp(line) + return line:match("(%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d)") +end + +local function parse_sent_json(line) + local raw = line:match("Client sent message: ({.*})%s*$") + if not raw then return nil end + local json = require("json") + local ok, obj = pcall(json.decode, raw) + if ok and type(obj) == "table" then return obj end + return nil +end + +local function parse_sent_kv(line) + local raw = line:match("Client sent message: (action:%w+.-)%s*$") + if not raw then return nil end + local pairs_t = {} + for part in raw:gmatch("[^,]+") do + local k, v = part:match("^%s*(.-):%s*(.-)%s*$") + if k then pairs_t[k] = v end + end + return pairs_t +end + +local function parse_got_kv(line) + local action, kv_str = line:match("Client got (%w+) message:%s*(.-)%s*$") + if not action then return nil end + local pairs_t = {} + for key, val in kv_str:gmatch("%((%w+):%s*([^)]*)%)") do + val = val:match("^%s*(.-)%s*$") + if val == "true" then + pairs_t[key] = true + elseif val == "false" then + pairs_t[key] = false + elseif val:match("^%-?%d+%.%d+$") then + pairs_t[key] = tonumber(val) + elseif val:match("^%-?%d+$") then + pairs_t[key] = tonumber(val) + else + pairs_t[key] = val + end + end + return action, pairs_t +end + +local function parse_joker_list_full(raw) + local jokers = {} + for entry in raw:gmatch("[^;]+") do + entry = entry:match("^%s*(.-)%s*$") + if entry ~= "" then + local parts = {} + for p in entry:gmatch("[^%-]+") do parts[#parts + 1] = p end + local joker = { key = parts[1] } + if parts[2] and parts[2] ~= "none" then joker.edition = parts[2] end + if parts[3] and parts[3] ~= "none" then joker.sticker1 = parts[3] end + if parts[4] and parts[4] ~= "none" then joker.sticker2 = parts[4] end + jokers[#jokers + 1] = joker + end + end + return jokers +end + +------------------------------------------------------------------------------- +-- Game record (fresh state) +------------------------------------------------------------------------------- + +local function new_game() + return { + seed = nil, + ruleset = nil, + gamemode = nil, + deck = nil, + stake = nil, + player_name = nil, + nemesis_name = nil, + starting_lives = 4, + is_host = nil, + lobby_code = nil, + ante_snapshots = {}, + winner = nil, + final_ante = 1, + current_ante = 0, + player_lives = 4, + enemy_lives = 4, + -- PvP round tracking (transient) + pvp_player_score = "0", + pvp_enemy_score = "0", + pvp_hands = {}, + in_pvp = false, + -- End-game data + player_jokers = {}, + nemesis_jokers = {}, + player_stats = {}, + nemesis_stats = {}, + -- Per-ante shop spending + shop_spending = {}, + -- Non-PvP round failures + failed_rounds = {}, + -- Timing + game_start_ts = nil, + game_end_ts = nil, + -- Card activity + cards_bought = {}, + cards_sold = {}, + cards_used = {}, + } +end + +------------------------------------------------------------------------------- +-- Duration helper +------------------------------------------------------------------------------- + +local function ts_to_epoch(ts_str) + local y, mo, d, h, mi, s = ts_str:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)") + if not y then return nil end + return os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d), + hour = tonumber(h), min = tonumber(mi), sec = tonumber(s) }) +end + +local function format_duration(start_ts, end_ts) + local t0 = ts_to_epoch(start_ts) + local t1 = ts_to_epoch(end_ts) + if not t0 or not t1 then return nil end + local secs = t1 - t0 + if secs < 0 then return nil end + local mins = math.floor(secs / 60) + local s = secs % 60 + return string.format("%dm%02ds", mins, s) +end + +------------------------------------------------------------------------------- +-- Core parser +------------------------------------------------------------------------------- + +function LOG_PARSER.process_log(content) + local games = {} + local game = new_game() + local last_lobby_options = nil + local in_game = false + + for line in content:gmatch("[^\r\n]+") do + if line:find("MULTIPLAYER", 1, true) then + local ts = parse_timestamp(line) + + -- Direct log messages (not Client sent/got) + if line:find("Sending end game jokers:", 1, true) then + local raw = line:match("Sending end game jokers:%s*(.-)%s*$") + if raw then game.player_jokers = parse_joker_list_full(raw) end + goto continue + end + + if line:find("Received end game jokers:", 1, true) then + local raw = line:match("Received end game jokers:%s*(.-)%s*$") + if raw then game.nemesis_jokers = parse_joker_list_full(raw) end + goto continue + end + + -- Sent messages (JSON) + local sent = parse_sent_json(line) + if sent then + local action = sent.action + + if action == "username" then + game.player_name = sent.username + + elseif action == "lobbyOptions" then + last_lobby_options = sent + + elseif action == "setAnte" then + local ante = sent.ante or 0 + game.current_ante = ante + if ante > game.final_ante then game.final_ante = ante end + + elseif action == "playHand" then + local score = tostring(sent.score or "0") + local hands_left = sent.handsLeft or 0 + if game.in_pvp then + game.pvp_player_score = score + game.pvp_hands[#game.pvp_hands + 1] = { + score = score, + hands_left = hands_left, + side = "player", + } + end + + elseif action == "setLocation" then + local loc = sent.location or "" + if loc:find("bl_mp_nemesis", 1, true) then + game.in_pvp = true + end + + elseif action == "failRound" then + game.failed_rounds[#game.failed_rounds + 1] = game.current_ante + + elseif action == "spentLastShop" then + local amount = sent.amount or 0 + game.shop_spending[game.current_ante] = + (game.shop_spending[game.current_ante] or 0) + amount + + elseif action == "nemesisEndGameStats" then + local stats = {} + for k, v in pairs(sent) do + if k ~= "action" then stats[k] = v end + end + game.player_stats = stats + + elseif action == "startGame" then + if ts then game.game_start_ts = game.game_start_ts or ts end + end + + goto continue + end + + -- Sent messages (key:value format — card activity) + local sent_kv = parse_sent_kv(line) + if sent_kv then + local action = sent_kv.action + if action == "boughtCardFromShop" then + game.cards_bought[#game.cards_bought + 1] = { + card = sent_kv.card or "", + cost = tonumber(sent_kv.cost) or 0, + ante = game.current_ante, + } + elseif action == "soldCard" then + game.cards_sold[#game.cards_sold + 1] = { + card = sent_kv.card or "", + ante = game.current_ante, + } + elseif action == "usedCard" then + game.cards_used[#game.cards_used + 1] = { + card = sent_kv.card or "", + ante = game.current_ante, + } + end + goto continue + end + + -- Received messages (key-value) + local action, kv = parse_got_kv(line) + if not action then goto continue end + + if action == "joinedLobby" then + if kv.code then game.lobby_code = tostring(kv.code) end + + elseif action == "lobbyInfo" then + if kv.isHost ~= nil then game.is_host = kv.isHost end + if game.is_host == true and kv.guest then + game.nemesis_name = tostring(kv.guest) + elseif game.is_host == false and kv.host then + game.nemesis_name = tostring(kv.host) + end + + elseif action == "startGame" then + in_game = true + if ts then game.game_start_ts = game.game_start_ts or ts end + if last_lobby_options then + game.ruleset = last_lobby_options.ruleset + game.gamemode = last_lobby_options.gamemode + game.deck = last_lobby_options.back or "Red Deck" + game.stake = last_lobby_options.stake or 1 + game.starting_lives = last_lobby_options.starting_lives or 4 + game.player_lives = game.starting_lives + game.enemy_lives = game.starting_lives + end + + elseif action == "playerInfo" then + if kv.lives then game.player_lives = kv.lives end + + elseif action == "enemyInfo" then + if kv.lives then game.enemy_lives = kv.lives end + if kv.score then + local score_str = tostring(kv.score) + if game.in_pvp then + game.pvp_enemy_score = score_str + game.pvp_hands[#game.pvp_hands + 1] = { + score = score_str, + hands_left = kv.handsLeft or 0, + side = "enemy", + } + end + end + + elseif action == "enemyLocation" then + local loc = kv.location or "" + if tostring(loc):find("bl_mp_nemesis", 1, true) then + game.in_pvp = true + end + + elseif action == "endPvP" then + local lost = kv.lost + local result = lost and "loss" or "win" + + -- Clean up hand progression + local cleaned = {} + local seen_final = {} + for _, h in ipairs(game.pvp_hands) do + if h.score == "0" and h.hands_left >= 4 then + -- skip initial zero-score entries + elseif #cleaned > 0 + and cleaned[#cleaned].score == h.score + and cleaned[#cleaned].side == h.side then + -- deduplicate consecutive same-score same-side + elseif seen_final[h.side] and h.score == seen_final[h.side] then + -- skip re-broadcast of final score + else + if h.hands_left == 0 then + seen_final[h.side] = h.score + end + cleaned[#cleaned + 1] = h + end + end + + game.ante_snapshots[game.current_ante] = { + ante = game.current_ante, + player_score = game.pvp_player_score, + enemy_score = game.pvp_enemy_score, + player_lives = game.player_lives, + enemy_lives = game.enemy_lives, + result = result, + hands = cleaned, + } + + -- Reset PvP tracking + game.in_pvp = false + game.pvp_player_score = "0" + game.pvp_enemy_score = "0" + game.pvp_hands = {} + + elseif action == "winGame" then + game.winner = "player" + + elseif action == "loseGame" then + game.winner = "nemesis" + + elseif action == "nemesisEndGameStats" then + local stats = {} + for k, v in pairs(kv) do + if k ~= "action" then stats[k] = v end + end + game.nemesis_stats = stats + + elseif action == "stopGame" then + if kv.seed then game.seed = tostring(kv.seed) end + if ts then game.game_end_ts = ts end + if in_game then + games[#games + 1] = game + end + in_game = false + local prev_name = game.player_name + game = new_game() + game.player_name = prev_name + last_lobby_options = nil + end + + ::continue:: + end + end + + -- Capture mid-game record if log ends without stopGame + if in_game and game.winner then + games[#games + 1] = game + end + + return games +end + +------------------------------------------------------------------------------- +-- Convert a parsed game record to a replay table (same shape as JSON replays) +------------------------------------------------------------------------------- + +function LOG_PARSER.to_replay(game) + local snapshots = {} + for ante, snap in pairs(game.ante_snapshots) do + local snap_t = { + player_score = snap.player_score, + enemy_score = snap.enemy_score, + player_lives = snap.player_lives, + enemy_lives = snap.enemy_lives, + result = snap.result, + } + if snap.hands and #snap.hands > 0 then + snap_t.hands = {} + for _, h in ipairs(snap.hands) do + snap_t.hands[#snap_t.hands + 1] = { + score = h.score, + hands_left = h.hands_left, + side = h.side, + } + end + end + snapshots[ante] = snap_t + end + + local replay = { + gamemode = game.gamemode or "gamemode_mp_attrition", + final_ante = game.final_ante, + ante_snapshots = snapshots, + winner = game.winner or "unknown", + timestamp = os.time(), + ruleset = game.ruleset or "ruleset_mp_blitz", + seed = game.seed or "UNKNOWN", + deck = game.deck or "Red Deck", + stake = game.stake or 1, + } + if game.player_name then replay.player_name = game.player_name end + if game.nemesis_name then replay.nemesis_name = game.nemesis_name end + if game.lobby_code then replay.lobby_code = game.lobby_code end + + if game.game_start_ts and game.game_end_ts then + local dur = format_duration(game.game_start_ts, game.game_end_ts) + if dur then replay.duration = dur end + end + + if #game.player_jokers > 0 then replay.player_jokers = game.player_jokers end + if #game.nemesis_jokers > 0 then replay.nemesis_jokers = game.nemesis_jokers end + if next(game.player_stats) then replay.player_stats = game.player_stats end + if next(game.nemesis_stats) then replay.nemesis_stats = game.nemesis_stats end + if next(game.shop_spending) then replay.shop_spending = game.shop_spending end + if #game.failed_rounds > 0 then replay.failed_rounds = game.failed_rounds end + if #game.cards_bought > 0 then replay.cards_bought = game.cards_bought end + if #game.cards_sold > 0 then replay.cards_sold = game.cards_sold end + if #game.cards_used > 0 then replay.cards_used = game.cards_used end + + return replay +end + +return LOG_PARSER diff --git a/lib/practice_mode.lua b/lib/practice_mode.lua new file mode 100644 index 00000000..0b950b5c --- /dev/null +++ b/lib/practice_mode.lua @@ -0,0 +1,56 @@ +-- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) +MP.SP = { ruleset = nil, practice = false, unlimited_slots = false, edition_cycling = false } + +function MP.is_practice_mode() + return MP.SP.practice == true +end + +function G.FUNCS.setup_practice_mode(e) + G.SETTINGS.paused = true + MP.LOBBY.config.ruleset = nil + MP.LOBBY.config.gamemode = nil + MP.SP.ruleset = nil + MP.SP.practice = true + MP.SP.unlimited_slots = false + MP.SP.edition_cycling = false + MP.GHOST.clear() + + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +function G.FUNCS.start_practice_run(e) + G.FUNCS.exit_overlay_menu() + if MP.GHOST.is_active() then + local r = MP.GHOST.replay + MP.reset_game_states() + local starting_lives = MP.LOBBY.config.starting_lives or 4 + MP.GAME.lives = starting_lives + MP.GAME.enemy.lives = starting_lives + local deck_key = MP.UTILS.get_deck_key_from_name(r.deck) + if deck_key then G.GAME.viewed_back = G.P_CENTERS[deck_key] end + G.FUNCS.start_run(e, { seed = r.seed, stake = r.stake or 1 }) + sendDebugMessage( + string.format( + "Practice run state: practice=%s, ghost=%s, ruleset=%s, gamemode=%s, deck_key=%s, lives=%s, enemy_lives=%s, seed=%s, stake=%s", + tostring(MP.is_practice_mode()), + tostring(MP.GHOST.is_active()), + tostring(MP.get_active_ruleset()), + tostring(MP.get_active_gamemode()), + tostring(deck_key), + tostring(MP.GAME.lives), + tostring(MP.GAME.enemy.lives), + tostring(G.GAME.pseudorandom and G.GAME.pseudorandom.seed or "?"), + tostring(G.GAME.stake or "?") + ), + "MULTIPLAYER" + ) + else + G.FUNCS.setup_run(e) + end +end + +function G.FUNCS.toggle_unlimited_slots(e) + MP.SP.unlimited_slots = not MP.SP.unlimited_slots +end diff --git a/localization/en-us.lua b/localization/en-us.lua index 8df7f5fc..3ecb0f23 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -901,6 +901,10 @@ return { dictionary = { b_singleplayer = "Singleplayer", b_sp_with_ruleset = "Practice Mode", + b_practice = "Practice", + k_practice_collection_hint = "Psst... click a card and it's yours. No questions asked!", + k_unlimited_slots = "Unlimited Slots", + k_edition_cycling = "Edition Cycling (Q)", b_join_lobby = "Join Lobby", b_join_lobby_clipboard = "Join From Clipboard", b_return_lobby = "Return to Lobby", @@ -1095,6 +1099,9 @@ return { "Enemy", "location", }, + k_ghost_replays = "Match Replays", + k_no_ghost_replays = "No replays yet", + k_ghost = "Ghost", k_hide_mp_content = "Hide Multiplayer content*", k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets", k_timer_sfx = "Timer Sound Effects", diff --git a/lovely/end_round.toml b/lovely/end_round.toml index ae13e938..9040be60 100644 --- a/lovely/end_round.toml +++ b/lovely/end_round.toml @@ -28,7 +28,7 @@ target = "functions/state_events.lua" pattern = '''-- context.end_of_round calculations''' position = 'before' payload = ''' -if MP.LOBBY.code then +if MP.is_mp_or_ghost() then game_over = false end ''' @@ -42,7 +42,7 @@ target = "functions/state_events.lua" pattern = '''if game_over then''' position = 'before' payload = ''' -if MP.LOBBY.code then +if MP.is_mp_or_ghost() then game_won = nil G.GAME.won = nil end @@ -149,7 +149,7 @@ function ease_ante(mod) ''' position = 'after' payload = ''' -if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then +if MP.is_mp_or_ghost() and not MP.LOBBY.config.disable_live_and_timer_hud then MP.suppress_next_event = true end ''' diff --git a/lovely/hud.toml b/lovely/hud.toml index 22fbe3f5..02f9cda8 100644 --- a/lovely/hud.toml +++ b/lovely/hud.toml @@ -8,7 +8,7 @@ priority = 2147483600 target = "functions/UI_definitions.lua" pattern = '''contents\.buttons = \{(?
[\s\S]*?)minh = 1\.75(?[\s\S]*?)minh = 1\.75''' position = 'at' -payload = '''contents.buttons = {$pre minh = MP.LOBBY.code and 1.2 or 1.75$between minh = MP.LOBBY.code and 1.2 or 1.75''' +payload = '''contents.buttons = {$pre minh = (MP.LOBBY.code or (MP.is_practice_mode and MP.is_practice_mode())) and 1.2 or 1.75$between minh = (MP.LOBBY.code or (MP.is_practice_mode and MP.is_practice_mode())) and 1.2 or 1.75''' times = 1 [[patches]] @@ -37,3 +37,20 @@ pattern = '''\{n=G\.UIT\.C, config=\{align = "cm", padding = 0\.05, minw = 1\.45 position = 'at' payload = '''MP.LOBBY.code and (not MP.LOBBY.config.disable_live_and_timer_hud) and MP.UI.timer_hud() or {n=G.UIT.C, config={align = "cm", padding = 0.05, minw = 1.45, minh = 1,''' times = 1 + +# Practice mode: add collection button to HUD (after lobby info button injection) +[[patches]] +[patches.pattern] +target = "functions/UI_definitions.lua" +pattern = '''{n=G.UIT.T, config={text = localize('b_options'), scale = scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}} + }}, + }}''' +position = 'after' +payload = ''', +(MP and MP.is_practice_mode and MP.is_practice_mode()) and {n=G.UIT.R, config={id = 'practice_collection_button', align = "cm", minh = 1.2, minw = 1.5, padding = 0.05, r = 0.1, hover = true, colour = G.C.SECONDARY_SET.Planet, button = "your_collection", shadow = true}, nodes={ + {n=G.UIT.R, config={align = "cm", padding = 0, maxw = 1.4}, nodes={ + {n=G.UIT.T, config={text = localize("k_collection"), scale = 0.9*scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}} + }} +}} or nil''' +match_indent = true +times = 1 diff --git a/lovely/practice.toml b/lovely/practice.toml new file mode 100644 index 00000000..06180042 --- /dev/null +++ b/lovely/practice.toml @@ -0,0 +1,87 @@ +[manifest] +version = "1.0.0" +dump_lua = true +priority = 2147483600 + +# Practice mode: give cards from collection via key "3" or left-click. +# Also: edition cycling. + +# Shared helper function for giving a hovered card to the player +[[patches]] +[patches.pattern] +target = "engine/controller.lua" +pattern = '''function Controller:key_press_update(key, dt)''' +position = 'before' +payload = ''' +function Controller:practice_give_card() + if not (MP and MP.is_practice_mode()) then return end + if MP.LOBBY.code then return end + if not (self.hovering.target and self.hovering.target:is(Card)) then return end + if not G.OVERLAY_MENU then return end + local _card = self.hovering.target + if _card.ability.set == 'Joker' and G.jokers then + if MP.SP.unlimited_slots or #G.jokers.cards < G.jokers.config.card_limit then + if MP.SP.unlimited_slots and #G.jokers.cards >= G.jokers.config.card_limit then + G.jokers.config.card_limit = G.jokers.config.card_limit + 1 + end + add_joker(_card.config.center.key) + _card:set_sprites(_card.config.center) + end + end + if _card.ability.consumeable and G.consumeables then + if MP.SP.unlimited_slots or #G.consumeables.cards < G.consumeables.config.card_limit then + if MP.SP.unlimited_slots and #G.consumeables.cards >= G.consumeables.config.card_limit then + G.consumeables.config.card_limit = G.consumeables.config.card_limit + 1 + end + add_joker(_card.config.center.key) + _card:set_sprites(_card.config.center) + end + end +end +''' +match_indent = true +times = 1 + +# Key "3" triggers give in practice mode +[[patches]] +[patches.pattern] +target = "engine/controller.lua" +pattern = '''if not _RELEASE_MODE then''' +position = 'before' +payload = ''' +if MP and MP.is_practice_mode() and not MP.LOBBY.code then + if key == "3" then + self:practice_give_card() + end + if key == "q" and MP.SP.edition_cycling then + if self.hovering.target and self.hovering.target:is(Card) then + local _card = self.hovering.target + if _card.ability.set == 'Joker' or _card.playing_card or _card.area then + local _edition = { + foil = not _card.edition, + holo = _card.edition and _card.edition.foil, + polychrome = _card.edition and _card.edition.holo, + negative = _card.edition and _card.edition.polychrome, + } + _card:set_edition(_edition, true, true) + end + end + end +end +''' +match_indent = true +times = 1 + +# Left-click on hovered card in collection triggers give in practice mode +[[patches]] +[patches.pattern] +target = "main.lua" +pattern = '''G.CONTROLLER:queue_L_cursor_press(x, y)''' +position = 'before' +payload = ''' + if MP and MP.is_practice_mode() then + G.CONTROLLER:practice_give_card() + end +''' +match_indent = true +times = 1 diff --git a/networking-old/action_handlers.lua b/networking-old/action_handlers.lua index 8961b54a..8f6f035f 100644 --- a/networking-old/action_handlers.lua +++ b/networking-old/action_handlers.lua @@ -114,6 +114,10 @@ end ---@param seed string ---@param stake_str string local function action_start_game(seed, stake_str) + -- Clear any stale practice/ghost state so it can't leak into real MP + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 3f52ad00..1656215a 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -228,6 +228,10 @@ end ---@param seed string ---@param stake_str string local function action_start_game(seed, stake_str) + -- Clear any stale practice/ghost state so it can't leak into real MP + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_game_states() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) @@ -236,6 +240,7 @@ local function action_start_game(seed, stake_str) end G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false + end local function begin_pvp_blind() @@ -340,6 +345,7 @@ local function action_end_pvp() MP.GAME.timer = MP.LOBBY.config.timer_base_seconds MP.GAME.timer_started = false MP.GAME.ready_blind = false + end ---@param lives number diff --git a/replays/.gitkeep b/replays/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index 4763a9c3..13d84f4e 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -41,7 +41,7 @@ function MP.is_ruleset_active(ruleset_name) local key = "ruleset_mp_" .. ruleset_name if MP.LOBBY.code then return MP.LOBBY.config.ruleset == key - elseif MP.SP and MP.SP.ruleset then + elseif MP.is_practice_mode() then return MP.SP.ruleset == key end return false @@ -50,22 +50,30 @@ end function MP.get_active_ruleset() if MP.LOBBY.code then return MP.LOBBY.config.ruleset - elseif MP.SP and MP.SP.ruleset then + elseif MP.is_practice_mode() then return MP.SP.ruleset end return nil end -function MP.ApplyBans() - local ruleset_key = nil - local gamemode = nil - - if MP.LOBBY.code and MP.LOBBY.config.ruleset then - ruleset_key = MP.LOBBY.config.ruleset - gamemode = MP.Gamemodes["gamemode_mp_" .. MP.LOBBY.type] - elseif MP.SP and MP.SP.ruleset then - ruleset_key = MP.SP.ruleset +function MP.get_active_gamemode() + if MP.LOBBY.code then + return MP.LOBBY.config.gamemode + elseif MP.is_practice_mode() then + -- Ghost replay stores the gamemode directly + if MP.GHOST.is_active() and MP.GHOST.gamemode then + return MP.GHOST.gamemode + end + local ruleset_key = MP.SP and MP.SP.ruleset + if ruleset_key and MP.Rulesets[ruleset_key] then return MP.Rulesets[ruleset_key].forced_gamemode end end + return nil +end + +function MP.ApplyBans() + local ruleset_key = MP.get_active_ruleset() + local gamemode_key = MP.get_active_gamemode() + local gamemode = gamemode_key and MP.Gamemodes[gamemode_key] or nil if ruleset_key then local ruleset = MP.Rulesets[ruleset_key] diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua index 1a9fe6d8..0bc7459e 100644 --- a/ui/game/blind_choice.lua +++ b/ui/game/blind_choice.lua @@ -1,7 +1,7 @@ local create_UIBox_blind_choice_ref = create_UIBox_blind_choice ---@diagnostic disable-next-line: lowercase-global function create_UIBox_blind_choice(type, run_info) - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then if not G.GAME.blind_on_deck then G.GAME.blind_on_deck = "Small" end if not run_info then G.GAME.round_resets.blind_states[G.GAME.blind_on_deck] = "Select" end @@ -134,10 +134,16 @@ function create_UIBox_blind_choice(type, run_info) or "", }, }) - local loc_name = ( - G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" - and (MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username) - ) or localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" }) + local loc_name + if G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" then + if MP.GHOST.is_active() then + loc_name = MP.GHOST.get_nemesis_name() + else + loc_name = MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username + end + else + loc_name = localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" }) + end local blind_col = get_blind_main_colour(type) @@ -213,8 +219,9 @@ function create_UIBox_blind_choice(type, run_info) hover = true, one_press = true, func = ( - G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" - or G.GAME.round_resets.pvp_blind_choices[type] + not MP.GHOST.is_active() + and (G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" + or G.GAME.round_resets.pvp_blind_choices[type]) ) and "pvp_ready_button" or nil, diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index 4df6cfb8..b9b617ab 100644 --- a/ui/game/blind_hud.lua +++ b/ui/game/blind_hud.lua @@ -1,5 +1,5 @@ function MP.UI.update_blind_HUD() - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then G.HUD_blind.alignment.offset.y = -10 G.E_MANAGER:add_event(Event({ trigger = "after", @@ -29,7 +29,7 @@ function MP.UI.update_blind_HUD() end function MP.UI.reset_blind_HUD() - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = { { ref_table = G.GAME.blind, ref_value = "loc_name" } } G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text() @@ -122,7 +122,7 @@ end local blind_defeat_ref = Blind.defeat function Blind:defeat(silent) blind_defeat_ref(self, silent) - if MP.LOBBY.code and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end + if MP.is_mp_or_ghost() and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end end local blind_disable_ref = Blind.disable diff --git a/ui/game/functions.lua b/ui/game/functions.lua index e813f7f2..0e551a4f 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -51,12 +51,14 @@ function G.FUNCS.select_blind(e) MP.GAME.end_pvp = false MP.GAME.prevent_eval = false select_blind_ref(e) - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then MP.GAME.ante_key = tostring(math.random()) - MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) - MP.ACTIONS.new_round() - MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name)) - if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end + if not MP.GHOST.is_active() then + MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) + MP.ACTIONS.new_round() + MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name)) + if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end + end end end diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index bc03dbd1..3d7e9054 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -3,7 +3,7 @@ local update_draw_to_hand_ref = Game.update_draw_to_hand function Game:update_draw_to_hand(dt) - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then if not G.STATE_COMPLETE and G.GAME.current_round.hands_played == 0 @@ -28,12 +28,20 @@ function Game:update_draw_to_hand(dt) delay = 0.45, blockable = false, func = function() - G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = { - { - ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host, - ref_value = "username", - }, - } + -- Ghost uses static { string = ... } (name is fixed for the run); + -- live MP uses { ref_table, ref_value } so the HUD reacts to name changes. + local blind_name_string + if MP.GHOST.is_active() then + blind_name_string = MP.GHOST.get_blind_name_ui() + else + blind_name_string = { + { + ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host, + ref_value = "username", + }, + } + end + G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = blind_name_string G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text() G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_in(0) return true @@ -69,8 +77,10 @@ function Game:update_draw_to_hand(dt) end G.E_MANAGER:add_event(Event({ func = function() - for i = 1, MP.GAME.asteroids do - MP.ACTIONS.asteroid() + if not MP.GHOST.is_active() then + for i = 1, MP.GAME.asteroids do + MP.ACTIONS.asteroid() + end end MP.GAME.asteroids = 0 return true @@ -182,7 +192,8 @@ local update_hand_played_ref = Game.update_hand_played ---@diagnostic disable-next-line: duplicate-set-field function Game:update_hand_played(dt) -- Ignore for singleplayer or regular blinds - if not MP.LOBBY.connected or not MP.LOBBY.code or not MP.is_pvp_boss() then + local ghost = MP.GHOST.is_active() + if (not ghost and (not MP.LOBBY.connected or not MP.LOBBY.code)) or not MP.is_pvp_boss() then update_hand_played_ref(self, dt) return end @@ -201,21 +212,42 @@ function Game:update_hand_played(dt) G.E_MANAGER:add_event(Event({ trigger = "immediate", func = function() - MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) - -- For now, never advance to next round + if not ghost then + MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left) + end + if G.GAME.current_round.hands_left < 1 then - attention_text({ - scale = 0.8, - text = localize("k_wait_enemy"), - hold = 5, - align = "cm", - offset = { x = 0, y = -1.5 }, - major = G.play, - }) + if ghost then + local result = MP.GHOST.resolve_pvp_hands_exhausted(G.GAME.chips) + if result == "won" then + win_game() + return true + elseif result == "game_over" then + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return true + end + else + attention_text({ + scale = 0.8, + text = localize("k_wait_enemy"), + hold = 5, + align = "cm", + offset = { x = 0, y = -1.5 }, + major = G.play, + }) + end if G.hand.cards[1] and G.STATE == G.STATES.HAND_PLAYED then eval_hand_and_jokers() G.FUNCS.draw_from_hand_to_discard() end + elseif ghost and MP.GHOST.has_hand_data() then + MP.GHOST.resolve_pvp_mid_hand(G.GAME.chips) + + if not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then + G.STATE_COMPLETE = false + G.STATE = G.STATES.DRAW_TO_HAND + end elseif not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then G.STATE_COMPLETE = false G.STATE = G.STATES.DRAW_TO_HAND @@ -242,19 +274,28 @@ function Game:update_new_round(dt) G.STATE = G.STATES.NEW_ROUND MP.GAME.end_pvp = false end - if MP.LOBBY.code and not G.STATE_COMPLETE then + if MP.is_mp_or_ghost() and not G.STATE_COMPLETE then + local ghost = MP.GHOST.is_active() -- Prevent player from losing if to_big(G.GAME.chips) < to_big(G.GAME.blind.chips) and not MP.is_pvp_boss() then G.GAME.blind.chips = -1 - MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival") - and (tonumber(MP.GAME.lives) == 1) -- In Survival Mode, if this is the last live, wait for the enemy. - MP.ACTIONS.fail_round(G.GAME.current_round.hands_played) + if ghost then + if MP.GHOST.resolve_round_fail() == "game_over" then + G.STATE = G.STATES.GAME_OVER + G.STATE_COMPLETE = false + return + end + else + MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival") + and (tonumber(MP.GAME.lives) == 1) + MP.ACTIONS.fail_round(G.GAME.current_round.hands_played) + end end -- Prevent player from winning G.GAME.win_ante = 999 - if MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then + if not ghost and MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then G.STATE_COMPLETE = true G.FUNCS.draw_from_hand_to_discard() attention_text({ @@ -283,14 +324,16 @@ function Game:update_selecting_hand(dt) and #G.hand.cards < 1 and #G.deck.cards < 1 and #G.play.cards < 1 - and MP.LOBBY.code + and MP.is_mp_or_ghost() then G.GAME.current_round.hands_left = 0 if not MP.is_pvp_boss() then G.STATE_COMPLETE = false G.STATE = G.STATES.NEW_ROUND else - MP.ACTIONS.play_hand(G.GAME.chips, 0) + if not MP.GHOST.is_active() then + MP.ACTIONS.play_hand(G.GAME.chips, 0) + end G.STATE_COMPLETE = false G.STATE = G.STATES.HAND_PLAYED end @@ -298,7 +341,7 @@ function Game:update_selecting_hand(dt) end update_selecting_hand_ref(self, dt) - if MP.GAME.end_pvp and MP.is_pvp_boss() then + if MP.GAME.end_pvp and MP.is_pvp_boss() and MP.is_mp_or_ghost() then G.hand:unhighlight_all() G.STATE_COMPLETE = false G.STATE = G.STATES.NEW_ROUND @@ -345,7 +388,8 @@ function Game:start_run(args) start_run_ref(self, args) - if not MP.LOBBY.connected or not MP.LOBBY.code or MP.LOBBY.config.disable_live_and_timer_hud then return end + local show_lives_hud = (MP.LOBBY.connected and MP.LOBBY.code) or MP.GHOST.is_active() + if not show_lives_hud or MP.LOBBY.config.disable_live_and_timer_hud then return end local scale = 0.4 local hud_ante = G.HUD:get_UIE_by_ID("hud_ante") @@ -370,7 +414,7 @@ end -- This prevents duplicate execution during certain cases. e.g. Full deck discard before playing any hands. function MP.handle_duplicate_end() - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then if MP.GAME.round_ended then if not MP.GAME.duplicate_end then MP.GAME.duplicate_end = true @@ -385,15 +429,16 @@ end -- This handles an edge case where a player plays no hands, and discards the only cards in their deck. -- Allows opponent to advance after playing anything, and eases a life from the person who discarded their deck. function MP.handle_deck_out() - if MP.LOBBY.code then + if MP.is_mp_or_ghost() then if G.GAME.current_round.hands_played == 0 and G.GAME.current_round.discards_used > 0 and MP.LOBBY.config.gamemode ~= "gamemode_mp_survival" then - if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end - - MP.ACTIONS.fail_round(1) + if not MP.GHOST.is_active() then + if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end + MP.ACTIONS.fail_round(1) + end end end end @@ -548,6 +593,29 @@ function MP.UI.jimbo_say(text) })) end +-- Practice mode: spawn Jimbo when opening collection to hint at card spawning (once per boot) +local practice_collection_jimbo = false +local practice_collection_hint_shown = false + +local your_collection_ref = G.FUNCS.your_collection +function G.FUNCS.your_collection(e) + your_collection_ref(e) + if MP.is_practice_mode() and not MP.LOBBY.code and not practice_collection_hint_shown then + practice_collection_hint_shown = true + practice_collection_jimbo = true + MP.UI.create_jimbo(2, localize("k_practice_collection_hint")) + end +end + +local exit_overlay_menu_ref_jimbo = G.FUNCS.exit_overlay_menu +function G.FUNCS:exit_overlay_menu() + if practice_collection_jimbo then + practice_collection_jimbo = false + MP.UI.remove_jimbo() + end + exit_overlay_menu_ref_jimbo(self) +end + function MP.UI.remove_jimbo() if not mp_jimbo then return end local jimbo = mp_jimbo diff --git a/ui/game/round.lua b/ui/game/round.lua index dbd5f5bf..fb619915 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -3,7 +3,7 @@ local ease_ante_ref = ease_ante function ease_ante(mod) - if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then + if MP.is_mp_or_ghost() and not MP.LOBBY.config.disable_live_and_timer_hud then -- Prevents easing multiple times at once if MP.GAME.antes_keyed[MP.GAME.ante_key] then return end @@ -15,7 +15,9 @@ function ease_ante(mod) end MP.GAME.antes_keyed[MP.GAME.ante_key] = true - MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod) + if not MP.GHOST.is_active() then + MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod) + end G.E_MANAGER:add_event(Event({ trigger = "immediate", func = function() @@ -30,7 +32,7 @@ end local ease_round_ref = ease_round function ease_round(mod) - if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end + if MP.is_mp_or_ghost() and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end ease_round_ref(mod) end @@ -38,13 +40,19 @@ local reset_blinds_ref = reset_blinds function reset_blinds() reset_blinds_ref() G.GAME.round_resets.pvp_blind_choices = {} - if MP.LOBBY.code then + + local gamemode_key = MP.get_active_gamemode() + if gamemode_key and MP.Gamemodes[gamemode_key] then local mp_small_choice, mp_big_choice, mp_boss_choice = - MP.Gamemodes[MP.LOBBY.config.gamemode]:get_blinds_by_ante(G.GAME.round_resets.ante) + MP.Gamemodes[gamemode_key]:get_blinds_by_ante(G.GAME.round_resets.ante) G.GAME.round_resets.blind_choices.Small = mp_small_choice or G.GAME.round_resets.blind_choices.Small G.GAME.round_resets.blind_choices.Big = mp_big_choice or G.GAME.round_resets.blind_choices.Big G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss end + + if MP.GHOST.is_active() then + MP.GHOST.init_playback(G.GAME.round_resets.ante) + end end -- necessary for showdown mode to ensure rounds progress properly, only affects nemesis blind to avoid possible incompatibilities (though i know many mods like to do this exact hook) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua new file mode 100644 index 00000000..19d5706e --- /dev/null +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -0,0 +1,639 @@ +-- Match Replay Picker UI +-- Shown in practice mode to select a past match replay for ghost PvP +-- Two-column layout: replay list (left) + match details panel (right) + +local function reopen_practice_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ruleset_selection_options("practice"), + }) +end + +local function refresh_picker() + G.FUNCS.exit_overlay_menu() + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +function G.FUNCS.open_ghost_replay_picker(e) + G.FUNCS.overlay_menu({ + definition = G.UIDEF.ghost_replay_picker(), + }) +end + +-- Stashed merged replay list so callbacks can index into it +local _picker_replays = {} +-- Currently previewed replay (shown in right panel) +local _preview_idx = nil +-- Perspective flip for the previewed replay (before loading) +local _preview_flipped = false + +function G.FUNCS.preview_ghost_replay(e) + local idx = tonumber(e.config.id:match("ghost_replay_(%d+)")) + if idx ~= _preview_idx then + _preview_flipped = false + end + _preview_idx = idx + refresh_picker() +end + +function G.FUNCS.load_previewed_ghost(e) + local replay = _picker_replays[_preview_idx] + if not replay then return end + if not MP.GHOST.is_ruleset_supported(replay) then return end + + MP.GHOST.load(replay) + MP.GHOST.flipped = _preview_flipped + + if replay.ruleset then + MP.SP.ruleset = replay.ruleset + local ruleset_name = replay.ruleset:gsub("^ruleset_mp_", "") + MP.LoadReworks(ruleset_name) + end + + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +-- Keep old name working for any external callers +function G.FUNCS.select_ghost_replay(e) + G.FUNCS.preview_ghost_replay(e) +end + +function G.FUNCS.clear_ghost_replay(e) + MP.GHOST.clear() + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +function G.FUNCS.flip_ghost_perspective(e) + if _preview_idx then + _preview_flipped = not _preview_flipped + else + MP.GHOST.flip() + end + refresh_picker() +end + +G.FUNCS.ghost_picker_back = function(e) + _preview_idx = nil + _preview_flipped = false + reopen_practice_menu() +end + +------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------- + + +local function text_row(label, value, scale, label_colour, value_colour) + scale = scale or 0.3 + label_colour = label_colour or G.C.UI.TEXT_INACTIVE + value_colour = value_colour or G.C.WHITE + return { + n = G.UIT.R, + config = { align = "cl", padding = 0.02 }, + nodes = { + { n = G.UIT.T, config = { text = label .. " ", scale = scale, colour = label_colour } }, + { n = G.UIT.T, config = { text = tostring(value), scale = scale, colour = value_colour } }, + }, + } +end + +local function section_header(title, scale) + scale = scale or 0.32 + return { + n = G.UIT.R, + config = { align = "cl", padding = 0.04 }, + nodes = { + { n = G.UIT.T, config = { text = title, scale = scale, colour = G.C.GOLD } }, + }, + } +end + + +local function build_joker_card_area(jokers, width) + if not jokers or #jokers == 0 then return nil end + + width = width or 5.5 + local card_size = math.max(0.3, 0.8 - 0.01 * #jokers) + local card_area = CardArea(0, 0, width, G.CARD_H * card_size, { + card_limit = nil, + type = "title_2", + view_deck = true, + highlight_limit = 0, + card_w = G.CARD_W * card_size, + }) + + for _, j in ipairs(jokers) do + local key = j.key or j + local center = G.P_CENTERS[key] + if center then + local card = Card( + 0, + 0, + G.CARD_W * card_size, + G.CARD_H * card_size, + nil, + center, + { bypass_discovery_center = true, bypass_discovery_ui = true } + ) + card_area:emplace(card) + end + end + + return { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { n = G.UIT.O, config = { object = card_area } }, + }, + } +end + +------------------------------------------------------------------------------- +-- Stats detail panel (right column) +------------------------------------------------------------------------------- + +local function build_stats_panel(r) + if not r then + return { + n = G.UIT.C, + config = { align = "cm", padding = 0.2, minw = 6, minh = 5 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Select a match", + scale = 0.35, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + end + + -- Header row (spans both columns) + local header_nodes = {} + + local result_str = (r.winner == "player") and "VICTORY" or "DEFEAT" + local result_colour = (r.winner == "player") and G.C.GREEN or G.C.RED + header_nodes[#header_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.04 }, + nodes = { + { n = G.UIT.T, config = { text = result_str, scale = 0.4, colour = result_colour } }, + }, + } + + local player_display = r.player_name or "?" + local nemesis_display = r.nemesis_name or "?" + header_nodes[#header_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { text = player_display .. " vs " .. nemesis_display, scale = 0.35, colour = G.C.WHITE }, + }, + }, + } + + -- Left inner column: match info + ante breakdown + stats + local left_nodes = {} + + left_nodes[#left_nodes + 1] = section_header("Match Info") + if r._filename then + local source_label = r._filename + if r._game_index and r._game_count and r._game_count > 1 then + source_label = source_label .. string.format(" (game %d of %d)", r._game_index, r._game_count) + end + left_nodes[#left_nodes + 1] = text_row("Source:", source_label, 0.25) + end + local ruleset_display = r.ruleset and r.ruleset:gsub("^ruleset_mp_", "") or "?" + local gamemode_display = r.gamemode and r.gamemode:gsub("^gamemode_mp_", "") or "?" + local deck_display = r.deck or "?" + left_nodes[#left_nodes + 1] = text_row("Ruleset:", ruleset_display) + left_nodes[#left_nodes + 1] = text_row("Gamemode:", gamemode_display) + left_nodes[#left_nodes + 1] = text_row("Deck:", deck_display) + if r.seed then left_nodes[#left_nodes + 1] = text_row("Seed:", r.seed) end + if r.stake then left_nodes[#left_nodes + 1] = text_row("Stake:", tostring(r.stake)) end + left_nodes[#left_nodes + 1] = text_row("Final Ante:", tostring(r.final_ante or "?")) + if r.duration then left_nodes[#left_nodes + 1] = text_row("Duration:", r.duration) end + if r.timestamp then left_nodes[#left_nodes + 1] = text_row("Date:", os.date("%Y-%m-%d %H:%M", r.timestamp)) end + + -- Ante breakdown + if r.ante_snapshots then + left_nodes[#left_nodes + 1] = section_header("Ante Breakdown") + local antes = {} + for k in pairs(r.ante_snapshots) do + antes[#antes + 1] = tonumber(k) + end + table.sort(antes) + + for _, ante_num in ipairs(antes) do + local snap = r.ante_snapshots[tostring(ante_num)] or r.ante_snapshots[ante_num] + if snap then + local result_icon = snap.result == "win" and "W" or "L" + local r_col = snap.result == "win" and G.C.GREEN or G.C.RED + local p_score = MP.GHOST.format_score(snap.player_score or 0) + local e_score = MP.GHOST.format_score(snap.enemy_score or 0) + local lives_str = "" + if snap.player_lives and snap.enemy_lives then + lives_str = string.format(" [%d-%d]", snap.player_lives, snap.enemy_lives) + end + left_nodes[#left_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.01 }, + nodes = { + { + n = G.UIT.T, + config = { + text = string.format("A%d ", ante_num), + scale = 0.28, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + { n = G.UIT.T, config = { text = result_icon, scale = 0.28, colour = r_col } }, + { + n = G.UIT.T, + config = { + text = string.format(" %s - %s%s", p_score, e_score, lives_str), + scale = 0.28, + colour = G.C.WHITE, + }, + }, + }, + } + end + end + end + + -- Player/opponent stats + local function add_stats(nodes, stats, label) + if not stats then return end + nodes[#nodes + 1] = section_header(label) + if stats.reroll_count then nodes[#nodes + 1] = text_row("Rerolls:", tostring(stats.reroll_count), 0.28) end + if stats.reroll_cost_total then + nodes[#nodes + 1] = text_row("Reroll $:", tostring(stats.reroll_cost_total), 0.28) + end + if stats.vouchers then + nodes[#nodes + 1] = + text_row("Vouchers:", stats.vouchers:gsub("v_", ""):gsub("-", ", "):gsub("_", " "), 0.28) + end + end + + add_stats(left_nodes, r.player_stats, "Your Stats") + add_stats(left_nodes, r.nemesis_stats, "Opponent Stats") + + -- Failed rounds + if r.failed_rounds and #r.failed_rounds > 0 then + local fr_parts = {} + for _, a in ipairs(r.failed_rounds) do + fr_parts[#fr_parts + 1] = "A" .. tostring(a) + end + left_nodes[#left_nodes + 1] = + text_row("Failed Rounds:", table.concat(fr_parts, ", "), 0.28, G.C.UI.TEXT_INACTIVE, G.C.RED) + end + + -- Right inner column: jokers + shop spending + local right_nodes = {} + + if r.player_jokers then + right_nodes[#right_nodes + 1] = section_header("Your Jokers") + local joker_area = build_joker_card_area(r.player_jokers, 4) + if joker_area then right_nodes[#right_nodes + 1] = joker_area end + end + if r.nemesis_jokers then + right_nodes[#right_nodes + 1] = section_header("Opponent Jokers") + local joker_area = build_joker_card_area(r.nemesis_jokers, 4) + if joker_area then right_nodes[#right_nodes + 1] = joker_area end + end + + -- Shop spending + if r.shop_spending then + right_nodes[#right_nodes + 1] = section_header("Shop Spending") + local total = 0 + local antes = {} + for k, v in pairs(r.shop_spending) do + antes[#antes + 1] = tonumber(k) + total = total + v + end + table.sort(antes) + local parts = {} + for _, a in ipairs(antes) do + parts[#parts + 1] = string.format("A%d:$%d", a, r.shop_spending[tostring(a)] or r.shop_spending[a]) + end + right_nodes[#right_nodes + 1] = text_row("Total:", "$" .. tostring(total), 0.28) + right_nodes[#right_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02, maxw = 4 }, + nodes = { + { + n = G.UIT.T, + config = { text = table.concat(parts, " "), scale = 0.24, colour = G.C.UI.TEXT_INACTIVE }, + }, + }, + } + end + + -- Assemble two-column body + local body_row = { + n = G.UIT.R, + config = { align = "tm", padding = 0.05 }, + nodes = { + { + n = G.UIT.C, + config = { align = "tm", padding = 0.08, minw = 4 }, + nodes = left_nodes, + }, + { + n = G.UIT.C, + config = { align = "tm", padding = 0.08, minw = 4.5 }, + nodes = right_nodes, + }, + }, + } + + -- Playing-as flip button + Load button (spans full width) + local playing_as = _preview_flipped + and (r.nemesis_name or "?") + or (r.player_name or "?") + + local supported = MP.GHOST.is_ruleset_supported(r) + local action_nodes = {} + + if supported then + action_nodes[#action_nodes + 1] = UIBox_button({ + id = "flip_ghost_perspective", + button = "flip_ghost_perspective", + label = { "Playing as: " .. playing_as }, + minw = 3.5, + minh = 0.5, + scale = 0.3, + colour = G.C.BLUE, + hover = true, + shadow = true, + }) + action_nodes[#action_nodes + 1] = UIBox_button({ + id = "load_previewed_ghost", + button = "load_previewed_ghost", + label = { "Play Match" }, + minw = 3.5, + minh = 0.5, + scale = 0.35, + colour = G.C.GREEN, + hover = true, + shadow = true, + }) + else + action_nodes[#action_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.04 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Unsupported ruleset — cannot play this replay", + scale = 0.3, + colour = G.C.RED, + }, + }, + }, + } + end + + local load_button = { + n = G.UIT.R, + config = { align = "cm", padding = 0.08 }, + nodes = action_nodes, + } + + return { + n = G.UIT.C, + config = { align = "tm", padding = 0.1, minw = 9, r = 0.1, colour = G.C.L_BLACK }, + nodes = { + -- Header spanning both columns + { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { { n = G.UIT.C, config = { align = "cm" }, nodes = header_nodes } }, + }, + -- Two-column body + body_row, + -- Full-width button + load_button, + }, + } +end + +------------------------------------------------------------------------------- +-- Main picker UI +------------------------------------------------------------------------------- + +function G.UIDEF.ghost_replay_picker() + -- Load replays from the replays/ folder, sorted newest-first + local all = MP.GHOST.load_folder_replays() + + -- Sort newest-first + table.sort(all, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + -- Stash for callbacks to index into + _picker_replays = all + + -- Left column: replay list + local replay_nodes = {} + + if #all == 0 then + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.2 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_no_ghost_replays"), + scale = 0.35, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { + text = "Place .log or .json files in the replays/ folder", + scale = 0.28, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + else + local last_filename = nil + for i, r in ipairs(all) do + -- Show filename header when entering a new log file group with multiple games + if r._filename and r._game_count and r._game_count > 1 then + if r._filename ~= last_filename then + last_filename = r._filename + local display_name = r._filename:gsub("%.log$", "") + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cl", padding = 0.02 }, + nodes = { + { + n = G.UIT.T, + config = { + text = display_name .. " (" .. r._game_count .. " games)", + scale = 0.25, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, + } + end + else + last_filename = nil + end + + local label = MP.GHOST.build_label(r) + local is_selected = (_preview_idx == i) + local btn_colour + if is_selected then + btn_colour = G.C.WHITE + elseif r._source == "file" then + btn_colour = G.C.BLUE + else + btn_colour = G.C.GREY + end + + replay_nodes[#replay_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "ghost_replay_" .. i, + button = "preview_ghost_replay", + label = { label }, + minw = 5.5, + minh = 0.45, + scale = 0.3, + colour = btn_colour, + hover = true, + shadow = true, + }), + }, + } + end + end + + -- Control buttons below the list + local control_nodes = {} + + if MP.GHOST.is_active() then + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + UIBox_button({ + id = "clear_ghost_replay", + button = "clear_ghost_replay", + label = { "Clear Replay" }, + minw = 3, + minh = 0.45, + scale = 0.3, + colour = G.C.RED, + hover = true, + shadow = true, + }), + }, + } + end + + -- Back button + control_nodes[#control_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + UIBox_button({ + id = "ghost_picker_back", + button = "ghost_picker_back", + label = { localize("b_back") }, + minw = 3, + minh = 0.5, + scale = 0.35, + colour = G.C.ORANGE, + hover = true, + shadow = true, + }), + }, + } + + -- Left column + local left_col = { + n = G.UIT.C, + config = { align = "tm", padding = 0.1, minw = 6, r = 0.1, colour = G.C.L_BLACK }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.06 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_ghost_replays"), + scale = 0.45, + colour = G.C.WHITE, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05, maxh = 5 }, + nodes = replay_nodes, + }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = control_nodes, + }, + }, + } + + -- Right column: stats detail + local preview_replay = _preview_idx and _picker_replays[_preview_idx] or nil + local right_col = build_stats_panel(preview_replay) + + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR, minh = 7, minw = 13 }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.15 }, + nodes = { + { + n = G.UIT.C, + config = { align = "tm", padding = 0.15, r = 0.1, colour = G.C.BLACK }, + nodes = { + { + n = G.UIT.R, + config = { align = "tm", padding = 0.05 }, + nodes = { left_col, right_col }, + }, + }, + }, + }, + }, + }, + } +end diff --git a/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua index 71de6c20..3c34fd54 100644 --- a/ui/main_menu/play_button/play_button.lua +++ b/ui/main_menu/play_button/play_button.lua @@ -49,7 +49,7 @@ function G.UIDEF.override_main_menu_play_button() UIBox_button({ label = { localize("b_sp_with_ruleset") }, colour = G.C.ORANGE, - button = "setup_run_singleplayer", + button = "setup_practice_mode", minw = 5, }), MP.LOBBY.connected and UIBox_button({ diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 6e1a7baf..db319dc9 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -1,11 +1,10 @@ --- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer) -MP.SP = { ruleset = nil } - function G.FUNCS.setup_run_singleplayer(e) G.SETTINGS.paused = true MP.LOBBY.config.ruleset = nil MP.LOBBY.config.gamemode = nil MP.SP.ruleset = nil + MP.SP.practice = false + MP.GHOST.clear() G.FUNCS.overlay_menu({ definition = G.UIDEF.ruleset_selection_options("sp"), @@ -21,6 +20,8 @@ function G.FUNCS.start_vanilla_sp(e) MP.LOBBY.config.ruleset = nil MP.LOBBY.config.gamemode = nil MP.SP.ruleset = nil + MP.SP.practice = false + MP.GHOST.clear() G.FUNCS.setup_run(e) end @@ -92,6 +93,9 @@ end function G.FUNCS.start_lobby(e) G.SETTINGS.paused = false + MP.SP.practice = false + MP.GHOST.clear() + MP.reset_lobby_config(true) MP.LOBBY.config.multiplayer_jokers = MP.Rulesets[MP.LOBBY.config.ruleset].multiplayer_content diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index d6146eb0..77b298e5 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -2,11 +2,16 @@ function G.UIDEF.ruleset_selection_options(mode) mode = mode or "mp" MP.LOBBY.fetched_weekly = "smallworld" -- temp - -- SP defaults to vanilla, MP defaults to ranked - local default_ruleset = "standard_ranked" + -- If ghost is active, preserve the replay's ruleset instead of resetting to default + local default_ruleset + if mode == "practice" and MP.GHOST.is_active() and MP.SP.ruleset then + default_ruleset = MP.SP.ruleset:gsub("^ruleset_mp_", "") + else + default_ruleset = "standard_ranked" + end local default_button = default_ruleset .. "_ruleset_button" - if mode == "sp" then + if mode == "sp" or mode == "practice" then MP.SP.ruleset = "ruleset_mp_" .. default_ruleset else MP.LOBBY.config.ruleset = "ruleset_mp_" .. default_ruleset @@ -76,7 +81,7 @@ function G.FUNCS.change_ruleset_selection(e) end, default_button, function(ruleset_name) - if mode == "sp" then + if mode == "sp" or mode == "practice" then MP.SP.ruleset = "ruleset_mp_" .. ruleset_name else MP.LOBBY.config.ruleset = "ruleset_mp_" .. ruleset_name @@ -99,7 +104,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) local ruleset_disabled = ruleset.is_disabled() - -- Different button config for SP vs MP + -- Different button config for SP vs MP vs Practice local button_config if mode == "sp" then button_config = { @@ -108,6 +113,13 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) label = { localize("b_play_cap") }, colour = G.C.GREEN, } + elseif mode == "practice" then + button_config = { + id = "start_practice_button", + button = "start_practice_run", + label = { localize("b_play_cap") }, + colour = G.C.GREEN, + } else button_config = { id = "select_gamemode_button", @@ -117,6 +129,84 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) } end + local content_nodes = { + { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } }, + }, + }, + } + + if mode == "practice" then + local practice_toggles = { + { id = "unlimited_slots_toggle", label = "k_unlimited_slots", ref_value = "unlimited_slots" }, + { id = "edition_cycling_toggle", label = "k_edition_cycling", ref_value = "edition_cycling" }, + } + local toggle_nodes = {} + for _, t in ipairs(practice_toggles) do + toggle_nodes[#toggle_nodes + 1] = create_toggle({ + id = t.id, + label = localize(t.label), + ref_table = MP.SP, + ref_value = t.ref_value, + }) + end + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = toggle_nodes, + } + + -- Ghost replay picker button + local ghost_label = localize("k_ghost_replays") + if MP.GHOST.is_active() then + ghost_label = ghost_label .. " (Active)" + end + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.05 }, + nodes = { + UIBox_button({ + id = "ghost_replay_button", + button = "open_ghost_replay_picker", + label = { ghost_label }, + minw = 4, + minh = 0.6, + scale = 0.4, + colour = MP.GHOST.is_active() and G.C.GREEN or G.C.BLUE, + hover = true, + shadow = true, + }), + }, + } + end + + content_nodes[#content_nodes + 1] = { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + MP.UI.Disableable_Button({ + id = button_config.id, + button = button_config.button, + align = "cm", + padding = 0.05, + r = 0.1, + minw = 8, + minh = 0.8, + colour = button_config.colour, + hover = true, + shadow = true, + label = button_config.label, + scale = 0.5, + enabled_ref_table = { val = not ruleset_disabled }, + enabled_ref_value = "val", + disabled_text = { ruleset_disabled }, + }), + }, + } + return { n = G.UIT.ROOT, config = { align = "tm", minh = 8, maxh = 8, minw = 11, maxw = 11, colour = G.C.CLEAR }, @@ -124,38 +214,7 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) { n = G.UIT.C, config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } }, - }, - }, - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - MP.UI.Disableable_Button({ - id = button_config.id, - button = button_config.button, - align = "cm", - padding = 0.05, - r = 0.1, - minw = 8, - minh = 0.8, - colour = button_config.colour, - hover = true, - shadow = true, - label = button_config.label, - scale = 0.5, - enabled_ref_table = { val = not ruleset_disabled }, - enabled_ref_value = "val", - disabled_text = { ruleset_disabled }, - }), - }, - }, - }, + nodes = content_nodes, }, }, }