From e1abcac3dd22fbf6fc045202fba5675998107340 Mon Sep 17 00:00:00 2001 From: Fantom-Balatro Date: Mon, 8 Jun 2026 22:03:39 -0600 Subject: [PATCH 01/24] 0.4.1 (#465) * chore(release): bump version to 0.4.1~DEV * fixes (#462) * Update standard.lua WSOB Ruleset lets go --------- Co-authored-by: Stephen Kirk Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> --- Multiplayer.json | 2 +- layers/standard.lua | 12 +----------- ui/game/timer.lua | 31 ++++++++++++++++++------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/Multiplayer.json b/Multiplayer.json index 97a872bdc..14bfb40ef 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.0", + "version": "0.4.1~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/layers/standard.lua b/layers/standard.lua index 7c03164ed..c3d9d7bfb 100644 --- a/layers/standard.lua +++ b/layers/standard.lua @@ -1,27 +1,17 @@ MP.Layer("standard", { - multiplayer_content = true, + multiplayer_content = false, standard = true, banned_silent = { "j_hanging_chad", - "j_ticket", - "j_selzer", - "j_turtle_bean", "j_bloodstone", - "c_ouija", }, banned_consumables = { "c_justice", }, reworked_jokers = { "j_mp_hanging_chad", - "j_mp_ticket", - "j_mp_seltzer", - "j_mp_turtle_bean", "j_mp_bloodstone", }, - reworked_consumables = { - "c_mp_ouija_standard", - }, reworked_enhancements = { "m_glass", }, diff --git a/ui/game/timer.lua b/ui/game/timer.lua index e3b4c6590..8665cc76d 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -3,6 +3,7 @@ function MP.UI.cam_timer_opponent() if not MP.LOBBY.config.timer then return false end if MP.GAME.pvp_countdown_in_progress then return false end + if MP.GAME.timer <= 0 then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.score) then return true end @@ -194,7 +195,7 @@ SMODS.Gradient({ -- When you "timering" opponent, timer stops and you cannot see is button pressed -- So we need switch to real timer to make it flush - local time_value = (MP.GAME.timer_started and G.TIMERS.REAL or -(MP.GAME.timer or 0)) + local time_value = MP.GAME.timer_started and G.TIMERS.REAL or -(MP.GAME.timer or 0) local timer = (time_value / speedup) % self.cycle local start_index = math.ceil(timer * #self.colours / self.cycle) if start_index == 0 then start_index = 1 end @@ -225,7 +226,8 @@ SMODS.Gradient({ if #self.colours < 2 or not MP.speedlatro_timer then return end local speedup = MP.current_ruleset().timer_speedup_multiplier or 1 - local timer = MP.GAME.ready_blind and 0 or ((-(MP.speedlatro_timer.real or 0) / speedup) % self.cycle) + 0.5 + local timer_value = MP.GAME.ready_blind and 0 or -(MP.speedlatro_timer.real or 0) + local timer = (timer_value / speedup) % self.cycle local start_index = math.ceil(timer * #self.colours / self.cycle) if start_index == 0 then start_index = 1 end local end_index = start_index == #self.colours and 1 or start_index + 1 @@ -363,17 +365,20 @@ function Game:update(dt) if MP.GAME.timer == 0 then MP.GAME.timer_consumed = true - if is_pvp_timer then - -- PvP timer: end PvP immediately as a loss - MP.ACTIONS.fail_pvp_timer() - else - -- Old, No-animations, Pressure timers: lose a live - if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then - MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 - else - MP.ACTIONS.fail_timer() - end - end + -- PvP timer: end PvP immediately as a loss (only when timered by opponent) + if is_pvp_timer then + if MP.GAME.nemesis_timer_started then + MP.ACTIONS.fail_pvp_timer() + end + -- Old, No-animations: lose a live when timered by opponent + -- Pressure timers: lose a live regardless + elseif (is_pressure_timer or MP.GAME.nemesis_timer_started) then + if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then + MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 + else + MP.ACTIONS.fail_timer() + end + end end end From 719cfed4d3e454122f97cd262088eec9eb581543 Mon Sep 17 00:00:00 2001 From: Fantom-Balatro Date: Mon, 8 Jun 2026 22:04:37 -0600 Subject: [PATCH 02/24] remove ~DEV for release --- Multiplayer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Multiplayer.json b/Multiplayer.json index 14bfb40ef..a90756ac1 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.1~DEV", + "version": "0.4.1", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", From 42a1c189aa913327d54b141f9ee93d4ef0e398de Mon Sep 17 00:00:00 2001 From: Fantom-Balatro Date: Tue, 9 Jun 2026 11:10:21 -0600 Subject: [PATCH 03/24] Revert ruleset changes (0.4.2) (#466) * Update standard.lua * Update Multiplayer.json --- Multiplayer.json | 2 +- layers/standard.lua | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Multiplayer.json b/Multiplayer.json index a90756ac1..09d60caf6 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.1", + "version": "0.4.2", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/layers/standard.lua b/layers/standard.lua index c3d9d7bfb..7c03164ed 100644 --- a/layers/standard.lua +++ b/layers/standard.lua @@ -1,17 +1,27 @@ MP.Layer("standard", { - multiplayer_content = false, + multiplayer_content = true, standard = true, banned_silent = { "j_hanging_chad", + "j_ticket", + "j_selzer", + "j_turtle_bean", "j_bloodstone", + "c_ouija", }, banned_consumables = { "c_justice", }, reworked_jokers = { "j_mp_hanging_chad", + "j_mp_ticket", + "j_mp_seltzer", + "j_mp_turtle_bean", "j_mp_bloodstone", }, + reworked_consumables = { + "c_mp_ouija_standard", + }, reworked_enhancements = { "m_glass", }, From 611c5a7aafe1b26a58a72dfadb3c69b811e07924 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 26 Jun 2026 19:50:27 +0100 Subject: [PATCH 04/24] Casjb/full replay (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(replay): dual-stream deterministic action log (MP.RLOG) Add a fully-recreatable action logging system. Every state-affecting player action emits two aligned streams from one record() call: - Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with no card names, so it is indiscriminate and replays across mods. Written to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod folder, so version hotswaps don't disturb it). - Human stream: mirrored into the carbon file; the player-facing Lovely log is left byte-for-byte unchanged so the website parser is undisturbed. Both streams are hashed at game end (joker_hash) and submitted to the server via the new submitLogHashes action for cheap tamper detection. Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard, pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid). Instrumentation extends existing overrides; reorder uses a Preview- independent debounced CardArea:update detector. This is the logging system only; the replay runner and server-side hash storage are follow-ups. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua (both pass on Lua 5.4). ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * refactor(replay): emit both streams into the Lovely log, drop .carbon file Per design change: keep the carbon (replay) and human-readable streams in the same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar. - Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines / CHK trailer); the human stream keeps the existing "Client sent message:" prefix. - record() is now the sole emitter of both lines, so the instrumented overrides (buy/sell/reroll/use) no longer log the human line themselves. - Remove the .carbon file writer, path derivation, flush(), and the per-round flush call. Both stream buffers are still hashed at end_run and re-derive from the log by prefix. - Rework the tests to capture sendTraceMessage output (no file) and update docs. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4. ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * feat(replay): ship the full carbon log to the server, not just its hash end_run now accumulates the full carbon block (manifest + action lines + END + CHK) and sends it as a `log` field on submitLogHashes, so the server can store a complete viewable/replayable record of every game without the player handing over their log. Hashing is unchanged (still over the action/human buffers). ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * feat(serialization): guard str_decode_and_unpack against zip-bomb payloads Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers / receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client. A legitimate saved joker / nemesis deck is a few KB; both callers already bail cleanly on the nil return. Defense-in-depth โ€” the relay also caps message size. Adds tests/test_serialization_guard.lua. ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * chore(mod): bump version to 0.4.3 for the replay-log pre-release build ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * fix(replay): crash-proof area_enum (table index is nil on shop buy) area_enum built a lookup table keyed by the CardArea globals, but several of them are nil depending on game state (G.pack_cards only exists while a booster is open, G.shop_* only in the shop). A table literal with a nil key throws 'table index is nil', so any buy/sell/use while one of those areas was absent crashed the game โ€” reproducibly on the first shop purchase. Compare the area against each global directly instead (a live area vs a nil global is just false), which is crash-safe and returns the same enum. Commit message generated with Claude Code * feat(replay): stream carbon lines live to the server in batches While a game plays, MP.RLOG now batches its carbon lines and flushes them to the server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id generated in begin_run. So a crashed/abandoned game still leaves a partial record server-side; on a clean end, submit_log_hashes carries the same game id so the server swaps the live stream for the complete hashed package. Flushing falls back to the line-count trigger when love.timer is unavailable (tests), and no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua. Commit message generated with Claude Code --- Multiplayer.json | 2 +- compatibility/Preview/CorePreview.lua | 10 +- lib/card_utils.lua | 81 +++++++++ lib/replay_log.lua | 228 ++++++++++++++++++++++++++ lib/serialization.lua | 12 ++ networking/action_handlers.lua | 68 +++++++- overrides/game.lua | 120 ++++++++++++-- tests/readme.md | 35 ++++ tests/test_rlog_checksum.lua | 70 ++++++++ tests/test_rlog_roundtrip.lua | 120 ++++++++++++++ tests/test_rlog_stream.lua | 97 +++++++++++ tests/test_serialization_guard.lua | 84 ++++++++++ ui/game/functions.lua | 10 ++ ui/game/timer.lua | 5 + 14 files changed, 922 insertions(+), 20 deletions(-) create mode 100644 lib/replay_log.lua create mode 100644 tests/test_rlog_checksum.lua create mode 100644 tests/test_rlog_roundtrip.lua create mode 100644 tests/test_rlog_stream.lua create mode 100644 tests/test_serialization_guard.lua diff --git a/Multiplayer.json b/Multiplayer.json index 09d60caf6..933a36261 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.2", + "version": "0.4.3", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/compatibility/Preview/CorePreview.lua b/compatibility/Preview/CorePreview.lua index ee84cffee..0a2fa08e6 100644 --- a/compatibility/Preview/CorePreview.lua +++ b/compatibility/Preview/CorePreview.lua @@ -130,9 +130,17 @@ end local orig_discard = G.FUNCS.discard_cards_from_highlighted function G.FUNCS.discard_cards_from_highlighted(e, is_hook_blind) + -- Carbon: capture which hand slots are discarded BEFORE orig_discard consumes + -- them. is_hook_blind means a programmatic discard (blind effect), not a click. + local discarded = (not is_hook_blind) and MP.UTILS.highlighted_hand_indices() or nil orig_discard(e, is_hook_blind) - if not is_hook_blind then FN.PRE.stop_current_coroutine() end + if not is_hook_blind then + if discarded and #discarded > 0 then + MP.RLOG.record("discard", { discarded }, "action:discard,cards:" .. table.concat(discarded, ".")) + end + FN.PRE.stop_current_coroutine() + end end -- USER INTERFACE ADVICE: diff --git a/lib/card_utils.lua b/lib/card_utils.lua index dbb3fe3b0..0adcaf883 100644 --- a/lib/card_utils.lua +++ b/lib/card_utils.lua @@ -45,6 +45,87 @@ function MP.UTILS.joker_to_string(card) return joker_string end +-- Stable area enum for the carbon replay stream. The int identifies WHICH +-- CardArea a positional index refers to, independent of card identity/name. +MP.UTILS.AREA = { + shop_jokers = 1, + shop_booster = 2, + shop_vouchers = 3, + jokers = 4, + consumeables = 5, + hand = 6, + pack_cards = 7, +} + +-- Map a live CardArea object to its stable AREA enum int (or nil if unknown). +function MP.UTILS.area_enum(area) + if not area or not G then return nil end + -- Compare directly instead of building a lookup table keyed by the CardArea + -- globals: several of them are nil depending on game state (G.pack_cards + -- only exists while a booster is open, G.shop_* only in the shop), and a + -- table literal with a nil key throws "table index is nil". Comparing a + -- live area against a nil global is simply false, so this is crash-safe. + if area == G.shop_jokers then return MP.UTILS.AREA.shop_jokers end + if area == G.shop_booster then return MP.UTILS.AREA.shop_booster end + if area == G.shop_vouchers then return MP.UTILS.AREA.shop_vouchers end + if area == G.jokers then return MP.UTILS.AREA.jokers end + if area == G.consumeables then return MP.UTILS.AREA.consumeables end + if area == G.hand then return MP.UTILS.AREA.hand end + if area == G.pack_cards then return MP.UTILS.AREA.pack_cards end + return nil +end + +-- 1-based index of a card within its CardArea's card list. `area` defaults to +-- card.area. Returns nil if the card is not found. This positional index is the +-- deterministic reference used by the carbon stream (never card.sort_id, which +-- is a per-run counter that won't match across a re-simulation). +function MP.UTILS.index_in_area(card, area) + area = area or (card and card.area) + if not card or not area or not area.cards then return nil end + for i = 1, #area.cards do + if area.cards[i] == card then return i end + end + return nil +end + +-- 1-based G.hand indices of the currently highlighted cards, ascending. Shared +-- by play/discard/consumable-target instrumentation so every hand reference in +-- the carbon stream is a deterministic positional index list. +function MP.UTILS.highlighted_hand_indices() + local out = {} + if not (G and G.hand and G.hand.highlighted) then return out end + for _, c in ipairs(G.hand.highlighted) do + local i = MP.UTILS.index_in_area(c, G.hand) + if i then out[#out + 1] = i end + end + table.sort(out) + return out +end + +-- Given the previous order (a list of card sort_ids) and the current cards, +-- return the new order expressed as a list of the cards' PREVIOUS 1-based +-- indices -- i.e. the permutation a replay applies to reproduce the reorder. +-- Returns nil if it is not a pure reorder (the card set changed) or if nothing +-- moved. Referencing previous indices (not sort_id) keeps the carbon stream +-- positional and replayable. +function MP.UTILS.reorder_permutation(old_ids, cards) + if not old_ids or not cards or #cards == 0 or #old_ids ~= #cards then return nil end + local pos = {} + for i = 1, #old_ids do + pos[old_ids[i]] = i + end + local perm = {} + local changed = false + for j = 1, #cards do + local oi = pos[cards[j].sort_id] + if not oi then return nil end -- a card is new/removed: not a pure reorder + perm[j] = oi + if oi ~= j then changed = true end + end + if not changed then return nil end + return perm +end + -- ??? seems to be dead code function MP.UTILS.get_joker(key) if not G.jokers or not G.jokers.cards then return nil end diff --git a/lib/replay_log.lua b/lib/replay_log.lua new file mode 100644 index 000000000..2f6ad6453 --- /dev/null +++ b/lib/replay_log.lua @@ -0,0 +1,228 @@ +-- Replay Log (MP.RLOG): dual-stream, deterministic, fully-recreatable action log. +-- +-- Two streams are emitted from the SAME instrumentation points so they stay +-- event-for-event aligned (shared monotonic sequence number). Both go into the +-- ordinary Lovely log, distinguished only by a line prefix so parsers know what +-- to read -- there is no separate file. +-- +-- 1. Carbon-copy (replay) stream -- prefix "MP_RLOG:". Positional, no names: +-- "MP_RLOG: 5 buy 1 2" means "buy shop area 1, slot 2". Indiscriminate, so +-- modded content is just "slot N" and replays across mods for free. This is +-- the only truly replayable stream. The block is framed by a MANIFEST +-- header and an END + CHK trailer (also under the MP_RLOG: prefix). +-- +-- 2. Human-readable stream -- prefix "Client sent message:" (the existing +-- format the website parser already reads). "Client sent message: action: +-- boughtCardFromShop,card:Blueprint,cost:4". +-- +-- record() is the single emitter for both lines, so the per-action overrides no +-- longer log the human line themselves -- they pass the payload to record(). +-- +-- At end_run both streams are hashed and the hashes are sent to the server, so +-- tamper-checking later is a cheap hash comparison instead of a line-by-line +-- diff. The carbon stream re-derives cleanly from the log by its prefix. NOTE: +-- MP.UTILS.joker_hash (Adler-style) catches casual edits but is NOT collision- +-- resistant -- a motivated forger could edit and fix the hash. The robust +-- defenses (server sees the lines live, or full re-simulation) are future work; +-- this hash is the intended cheap first pass. + +local RLOG = {} +MP.RLOG = RLOG + +RLOG.CARBON_PREFIX = "MP_RLOG:" -- positional / replay stream +RLOG.HUMAN_PREFIX = "Client sent message:" -- human-readable stream (website-compatible) + +-- Required manifest keys; begin_run warns if any are missing. +RLOG.REQUIRED_MANIFEST_KEYS = { "seed", "ruleset", "gamemode", "deck", "stake" } + +RLOG._seq = 0 +RLOG._carbon_buffer = {} -- the action "MP_RLOG: ..." lines, hashed at end +RLOG._carbon_full = {} -- the full carbon block (manifest + actions + END + CHK), sent to the server +RLOG._human_buffer = {} -- the "Client sent message: ..." lines, hashed at end +RLOG._run_active = false +RLOG._manifest = nil +RLOG._force_active = false -- test hook: bypass the lobby gate + +-- Live streaming: carbon lines are pushed to the server in batches as the game +-- plays, so a crashed/abandoned game still leaves a partial record server-side. +-- On a clean end the server swaps that partial for the full hashed package. +RLOG._game_id = nil -- per-game grouping key, generated in begin_run +RLOG._pending = {} -- carbon lines buffered since the last flush +RLOG._last_flush = 0 -- love.timer.getTime() of the last flush (0 if unavailable) +RLOG.STREAM_FLUSH_LINES = 25 -- flush once this many lines are buffered, or... +RLOG.STREAM_FLUSH_SECS = 2 -- ...this many seconds have passed since the last flush + +------------------------------------------------------------------------------- +-- Gate +------------------------------------------------------------------------------- + +-- Only real multiplayer games log. Ghost playback, practice, and the preview +-- simulation have no lobby code, so they never emit. +function RLOG.is_active() + if RLOG._force_active then return true end + if not (MP.LOBBY and MP.LOBBY.code) then return false end + if MP.GHOST and MP.GHOST.is_active and MP.GHOST.is_active() then return false end + return true +end + +------------------------------------------------------------------------------- +-- Internal helpers +------------------------------------------------------------------------------- + +-- Format an opcode's args into the positional arg string. +-- Each token is either a scalar -> "1" or a list -> dot-joined "1.3.5". +-- A bare scalar/string is treated as a single token. +local function fmt_args(args) + if args == nil then return "" end + if type(args) ~= "table" then return tostring(args) end + local parts = {} + for _, tok in ipairs(args) do + if type(tok) == "table" then + local sub = {} + for _, v in ipairs(tok) do + sub[#sub + 1] = tostring(v) + end + parts[#parts + 1] = table.concat(sub, ".") + else + parts[#parts + 1] = tostring(tok) + end + end + return table.concat(parts, " ") +end + +local function emit(msg) + sendTraceMessage(msg, "MULTIPLAYER") +end + +-- Per-game grouping key for the live stream. Lets the server group a game's +-- streamed lines and delete them once the final package lands. +local function new_game_id(manifest) + local lobby = (manifest and manifest.lobby_code) or "nolobby" + local who = (manifest and manifest.player) or "?" + return string.format("%s-%s-%d-%d", tostring(lobby), tostring(who), os.time(), math.random(100000, 999999)) +end + +-- Best-effort wall clock for flush pacing; 0 when love.timer is unavailable +-- (e.g. under the headless test harness), in which case flushing falls back to +-- the line-count trigger plus the end-of-run flush. +local function stream_now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + +-- Send any buffered carbon lines to the server as one batch. No-ops cleanly if +-- there's no transport yet (e.g. tests) -- the lines are still kept in the full +-- carbon block submitted at end_run. +function RLOG.flush() + if #RLOG._pending == 0 then return end + if not (RLOG._game_id and MP.ACTIONS and MP.ACTIONS.stream_log_lines) then return end + local batch = RLOG._pending + RLOG._pending = {} + RLOG._last_flush = stream_now() + MP.ACTIONS.stream_log_lines(RLOG._game_id, batch) +end + +-- Flush once the batch is big enough or enough time has elapsed since the last. +local function maybe_flush() + if #RLOG._pending >= RLOG.STREAM_FLUSH_LINES then + RLOG.flush() + elseif (stream_now() - RLOG._last_flush) >= RLOG.STREAM_FLUSH_SECS then + RLOG.flush() + end +end + +-- Emit a carbon-stream line: tee to the Lovely log, accumulate it into the full +-- block we ship to the server at end_run, AND queue it for live streaming. +local function emit_carbon(msg) + RLOG._carbon_full[#RLOG._carbon_full + 1] = msg + RLOG._pending[#RLOG._pending + 1] = msg + sendTraceMessage(msg, "MULTIPLAYER") + maybe_flush() +end + +------------------------------------------------------------------------------- +-- Public API +------------------------------------------------------------------------------- + +-- Record one state-affecting action. Emits the carbon (positional) line and, +-- when a human payload is provided, the mirrored human line -- both into the +-- Lovely log with the same sequence number. +-- opcode : string, e.g. "buy" +-- args : nil | scalar | list of tokens (scalar or sub-list); see fmt_args +-- human : nil | string payload in the existing "action:key,..." format +function RLOG.record(opcode, args, human) + if not RLOG.is_active() or not RLOG._run_active then return end + + RLOG._seq = RLOG._seq + 1 + local seq = RLOG._seq + + local argstr = fmt_args(args) + local cline = RLOG.CARBON_PREFIX .. " " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "") + RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = cline + emit_carbon(cline) + + if human ~= nil and human ~= "" then + local hline = RLOG.HUMAN_PREFIX .. " " .. human + RLOG._human_buffer[#RLOG._human_buffer + 1] = hline + emit(hline) + end +end + +-- Start a new game's block: reset counters/buffers and emit the manifest header. +function RLOG.begin_run(manifest) + manifest = manifest or {} + + for _, key in ipairs(RLOG.REQUIRED_MANIFEST_KEYS) do + if manifest[key] == nil then + sendWarnMessage("RLOG: manifest missing required key '" .. key .. "'", "MULTIPLAYER") + end + end + + RLOG._seq = 0 + RLOG._carbon_buffer = {} + RLOG._carbon_full = {} + RLOG._human_buffer = {} + RLOG._manifest = manifest + RLOG._run_active = true + + -- Open the live stream for this game: fresh id + empty batch buffer. + RLOG._game_id = new_game_id(manifest) + manifest.game_id = RLOG._game_id + RLOG._pending = {} + RLOG._last_flush = stream_now() + + local json = require("json") + emit_carbon(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) +end + +-- Close the current game's block: emit the END line, hash each stream, emit the +-- CHK trailer, and submit the hashes plus the full carbon block to the server. +function RLOG.end_run(outcome) + if not RLOG._run_active then return end + + local json = require("json") + emit_carbon(RLOG.CARBON_PREFIX .. " END " .. json.encode(outcome or {})) + + local carbon_str = table.concat(RLOG._carbon_buffer, "\n") + local human_str = table.concat(RLOG._human_buffer, "\n") + local carbon_hash = MP.UTILS.joker_hash(carbon_str) + local human_hash = MP.UTILS.joker_hash(human_str) + local bytes = #carbon_str + #human_str + + emit_carbon(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) + + -- Push any remaining streamed lines (incl. END + CHK) before the final + -- package, so an oversized/rejected package still leaves a complete stream. + RLOG.flush() + + if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then + -- The full carbon block (manifest + actions + END + CHK) so the server + -- keeps the complete viewable/replayable log, not just its hash. The + -- game_id lets the server drop this game's live stream in favour of it. + local carbon_log = table.concat(RLOG._carbon_full, "\n") + MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed, carbon_log, RLOG._game_id) + end + + RLOG._run_active = false + return carbon_hash, human_hash +end diff --git a/lib/serialization.lua b/lib/serialization.lua index 42856d384..ac5b628c4 100644 --- a/lib/serialization.lua +++ b/lib/serialization.lua @@ -44,8 +44,20 @@ function MP.UTILS.str_pack_and_encode(data) return str_encoded end +-- A legitimately serialized object (a saved joker, a nemesis deck) is at most a +-- few KB once gzipped and base64-encoded. Reject anything far larger BEFORE we +-- spend CPU decoding/decompressing it: this is the client-side defense against +-- zip-bomb payloads relayed through actions like magnetResponse / +-- receiveEndGameJokers / receiveNemesisDeck. The relay also caps message size, +-- so this is defense-in-depth. +MP.UTILS.MAX_ENCODED_BYTES = 32 * 1024 + function MP.UTILS.str_decode_and_unpack(str) local success, str_decoded, str_decompressed, str_unpacked + if type(str) ~= "string" then return nil, "expected string payload" end + if #str > MP.UTILS.MAX_ENCODED_BYTES then + return nil, string.format("payload too large (%d > %d bytes)", #str, MP.UTILS.MAX_ENCODED_BYTES) + end success, str_decoded = pcall(love.data.decode, "string", "base64", str) if not success then return nil, str_decoded end success, str_decompressed = pcall(love.data.decompress, "string", "gzip", str_decoded) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index c84ef6eba..679e9a204 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -271,6 +271,33 @@ local function action_start_game(p) if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then seed = MP.LOBBY.config.custom_seed end + + -- Open a new replay-log block for this game with everything needed to + -- reconstruct it deterministically later. Uses the resolved seed. + MP.RLOG.begin_run({ + seed = seed, + stake = stake, + deck = MP.LOBBY.config.back, + sleeve = MP.LOBBY.config.sleeve, + challenge = MP.LOBBY.config.challenge, + ruleset = MP.LOBBY.config.ruleset, + gamemode = MP.LOBBY.config.gamemode, + modifier_layers = MP.LOBBY.config.modifier_layers, + lobby_config = MP.LOBBY.config, + the_order_enabled = MP.should_use_the_order(), + different_seeds = MP.LOBBY.config.different_seeds, + mod_version = SMODS.Mods["Multiplayer"] and SMODS.Mods["Multiplayer"].version, + mod_hash = MP.MOD_STRING, + smods_version = MP.SMODS_VERSION, + lovely_version = MP.REQUIRED_LOVELY_VERSION, + lobby_code = MP.LOBBY.code, + is_host = MP.LOBBY.is_host, + player = MP.LOBBY.username, + opponent = (MP.LOBBY.is_host and MP.LOBBY.guest and MP.LOBBY.guest.username) + or (MP.LOBBY.host and MP.LOBBY.host.username), + start_ts = os.date("%Y-%m-%dT%H:%M:%S%z"), + }) + G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false end @@ -401,6 +428,7 @@ local function action_stop_game() MP.UI.update_connection_status() MP.reset_game_states() end + MP.RLOG.end_run({ result = "stop" }) MP.UTILS.emit_log_checksum() end @@ -445,6 +473,7 @@ local function action_win_game() MP.nemesis_deck_received = false MP.GAME.won = true MP.STATS.record_match(true) + MP.RLOG.end_run({ result = "win" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() win_game() @@ -458,6 +487,7 @@ local function action_lose_game() MP.STATS.record_match(false) G.STATE_COMPLETE = false G.STATE = G.STATES.GAME_OVER + MP.RLOG.end_run({ result = "loss" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() end @@ -527,6 +557,9 @@ end local function action_send_phantom(p) local key = p.key + -- Carbon: exogenous opponent effect. Keyed by content (not a board index), + -- logged in received order so a solo re-sim reproduces it faithfully. + MP.RLOG.record("net_phantom_add", key, "action:netPhantomAdd,key:" .. tostring(key)) local menu = G.OVERLAY_MENU -- we are spoofing a menu here, which disables duplicate protection G.OVERLAY_MENU = G.OVERLAY_MENU or true local new_card = create_card("Joker", MP.shared, false, nil, nil, nil, key) @@ -537,6 +570,7 @@ local function action_send_phantom(p) end local function action_remove_phantom(p) + MP.RLOG.record("net_phantom_remove", p.key, "action:netPhantomRemove,key:" .. tostring(p.key)) local card = MP.UTILS.get_phantom_joker(p.key) if card then card:remove_from_deck() @@ -610,10 +644,14 @@ local function action_version() MP.ACTIONS.version() end -local action_asteroid = action_asteroid +local action_asteroid_ref = action_asteroid or function() if MP.UI.show_asteroid_hand_level_up then MP.UI.show_asteroid_hand_level_up() end end +local function action_asteroid(p) + MP.RLOG.record("net_asteroid", nil, "action:netAsteroid") + return action_asteroid_ref(p) +end local function action_sold_joker() -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked @@ -631,6 +669,7 @@ end local function action_eat_pizza(p) local discards = p.whole -- rename to "discards" when possible + MP.RLOG.record("net_pizza", discards, "action:netPizza,discards:" .. tostring(discards)) MP.GAME.pizza_discards = MP.GAME.pizza_discards + discards G.GAME.round_resets.discards = G.GAME.round_resets.discards + discards ease_discard(discards) @@ -641,6 +680,7 @@ local function action_spent_last_shop(p) end local function action_magnet() + MP.RLOG.record("net_magnet", nil, "action:netMagnet") local card = nil for _, v in pairs(G.jokers.cards) do if not card or v.sell_cost > card.sell_cost then card = v end @@ -1275,6 +1315,32 @@ function MP.ACTIONS.sync_client() }) end +-- Live carbon-log stream: batches of "MP_RLOG: ..." lines pushed while the game +-- is in progress, keyed by game_id so the server can group them and drop them +-- once the final package below lands. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.stream_log_lines(game_id, lines) + Client.send({ + action = "streamLogLines", + gameId = game_id, + lines = lines, + }) +end + +-- End-of-game replay-log fingerprints. The server stores these (keyed by +-- lobby + seed + game) so a presented log can later be re-hashed and compared +-- without a line-by-line diff, and uses game_id to delete the live stream in +-- favour of this complete package. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.submit_log_hashes(carbon, human, seed, log, game_id) + Client.send({ + action = "submitLogHashes", + carbon = carbon, + human = human, + seed = seed, + log = log, + gameId = game_id, + }) +end + function MP.ACTIONS.modded(modId, modAction, params, target) local msg = { action = "moddedAction", diff --git a/overrides/game.lua b/overrides/game.lua index 47fbe7456..263793988 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -15,20 +15,23 @@ end local sell_card_ref = Card.sell_card function Card:sell_card() if self.ability and self.ability.name then - sendTraceMessage( - string.format("Client sent message: action:soldCard,card:%s", self.ability.name), - "MULTIPLAYER" - ) + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Sell is positional by area + slot, captured before the card leaves + -- its area. Area distinguishes selling a joker (4) from a consumable (5). + local human = string.format("action:soldCard,card:%s", self.ability.name) + local area = MP.UTILS.area_enum(self.area) + local idx = MP.UTILS.index_in_area(self) + if area and idx then MP.RLOG.record("sell", { area, idx }, human) end end return sell_card_ref(self) end local reroll_shop_ref = G.FUNCS.reroll_shop function G.FUNCS.reroll_shop(e) - sendTraceMessage( - string.format("Client sent message: action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost), - "MULTIPLAYER" - ) + -- Reroll has no positional target; the shop contents it produces are + -- deterministic from the seed, so the bare opcode is enough to replay. + -- record() emits both the carbon line and the human "Client sent message:". + MP.RLOG.record("reroll", nil, string.format("action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost)) -- Update reroll stats if in a multiplayer game if MP.LOBBY.code and MP.GAME.stats then @@ -43,21 +46,49 @@ local buy_from_shop_ref = G.FUNCS.buy_from_shop function G.FUNCS.buy_from_shop(e) local c1 = e.config.ref_table if c1 and c1:is(Card) then - sendTraceMessage( - string.format("Client sent message: action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost), - "MULTIPLAYER" - ) + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Buy is positional by shop area + slot, captured before the card + -- leaves the shop. Booster packs and vouchers get distinct opcodes since + -- they branch the game differently, but all reference an area + slot. + local human = string.format("action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost) + local area = MP.UTILS.area_enum(c1.area) + local idx = MP.UTILS.index_in_area(c1) + if area and idx then + local opcode = "buy" + local set = c1.ability and c1.ability.set + if set == "Booster" then + opcode = "open_pack" + elseif set == "Voucher" then + opcode = "voucher" + end + MP.RLOG.record(opcode, { area, idx }, human) + end end return buy_from_shop_ref(e) end local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) - if e.config and e.config.ref_table and e.config.ref_table.ability and e.config.ref_table.ability.name then - sendTraceMessage( - string.format("Client sent message: action:usedCard,card:%s", e.config.ref_table.ability.name), - "MULTIPLAYER" - ) + local card = e.config and e.config.ref_table + if card and card.ability and card.ability.name then + -- record() emits both the carbon line and the human "Client sent message:". + local human = string.format("action:usedCard,card:%s", card.ability.name) + -- Pack picks share this hook (a picked card lives in G.pack_cards) but get + -- their own opcode. Both reference a slot plus any highlighted hand targets + -- (e.g. a Tarot from an Arcana pack applied to selected cards). + if card.area == (G and G.pack_cards) then + local idx = MP.UTILS.index_in_area(card, G.pack_cards) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("pack_pick", (#targets > 0) and { idx, targets } or { idx }, human) + end + else + local idx = MP.UTILS.index_in_area(card) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("use", (#targets > 0) and { idx, targets } or { idx }, human) + end + end end return use_card_ref(e, mute, nosave) end @@ -71,3 +102,58 @@ G.FUNCS.evaluate_round = function() end evaluate_round_ref() end + +-- Carbon: skipping a booster pack. +if G.FUNCS.skip_booster then + local skip_booster_ref = G.FUNCS.skip_booster + function G.FUNCS.skip_booster(e) + MP.RLOG.record("pack_skip", 0, "action:skipPack") + return skip_booster_ref(e) + end +end + +-- Carbon: joker / hand reordering (drag-drop). There is no discrete base-game +-- callback for a reorder, so we diff each area's card order on update. This is +-- intentionally independent of the Preview integration (which has its own, +-- Preview-gated order tracker) so reorders are always logged. Detection is +-- debounced until no card in the area is mid-drag, so one drag emits one event, +-- and reorder_permutation only fires on a pure permutation (the card set is +-- unchanged) -- draws, plays and discards change the set and are ignored here. +local function rlog_reorder_area(cardarea) + if cardarea == G.jokers then return MP.UTILS.AREA.jokers end + if cardarea == G.hand then return MP.UTILS.AREA.hand end + return nil +end + +local function rlog_area_dragging(cardarea) + for _, c in ipairs(cardarea.cards) do + if c.states and c.states.drag and c.states.drag.is then return true end + end + return false +end + +local cardarea_update_ref = CardArea.update +function CardArea:update(dt) + cardarea_update_ref(self, dt) + + -- Cheap area check first (runs for every CardArea every frame); only the + -- joker/hand areas do any further work, and only during a live MP game. + local area_id = rlog_reorder_area(self) + if not area_id or not self.cards or #self.cards == 0 then return end + if not (MP.RLOG and MP.RLOG.is_active()) then return end + if rlog_area_dragging(self) then return end -- wait for the drag to settle + + local cur = {} + for i = 1, #self.cards do + cur[i] = self.cards[i].sort_id + end + local prev = self._rlog_order + self._rlog_order = cur + + if prev and #prev == #cur then + local perm = MP.UTILS.reorder_permutation(prev, self.cards) + if perm then + MP.RLOG.record("reorder", { area_id, perm }, "action:reorder,area:" .. area_id) + end + end +end diff --git a/tests/readme.md b/tests/readme.md index 9ccaebfc0..8ade6ff30 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -32,3 +32,38 @@ After `capture`, review the diff in `tests/ruleset_snapshot.lua` before committi - Function bodies (only whether a function is defined) - Runtime behavior (ApplyBans hook chains, smallworld cull logic, speedlatro timer) - Rework center definitions (`MP.ReworkCenter` calls) + +## Replay Log (MP.RLOG) + +`test_rlog_roundtrip.lua` and `test_rlog_checksum.lua` exercise the dual-stream +replay logger (`lib/replay_log.lua`). They stub the game globals, capture the +lines it emits to the Lovely log, and assert on them โ€” no files are written. + +Both streams live in the ordinary Lovely log, distinguished by prefix: +- **Carbon (positional/replay):** `MP_RLOG:` โ€” e.g. `MP_RLOG: 5 buy 1 2`, plus + `MP_RLOG: MANIFEST {...}` and the `MP_RLOG: CHK v1 carbon=โ€ฆ human=โ€ฆ bytes=โ€ฆ` + trailer. +- **Human-readable:** `Client sent message:` โ€” the existing website-parser + format, e.g. `Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4`. + +```bash +lua tests/test_rlog_roundtrip.lua # streams well-formed + hashes round-trip +lua tests/test_rlog_checksum.lua # editing one opcode changes the stored hash +``` + +`test_rlog_roundtrip.lua` asserts: `MP_RLOG: MANIFEST` header + `END`/`CHK` +trailer; carbon action lines with a gapless, monotonic sequence; positional args +including ordered index-lists (e.g. `play 1.3.5.7.8`, `use 1 2.4`) intact; a +paired `Client sent message:` line per action; and `CHK` per-stream hashes that +equal a recompute over the captured lines and match what `submit_log_hashes` +sends. + +`test_rlog_checksum.lua` confirms the `CHK` carbon hash equals a hash of the +carbon stream (re-extracted by prefix) and that tampering with one opcode +changes it. + +### Manual end-to-end check + +Play one real multiplayer match, then open the Lovely log. Filter the `MP_RLOG:` +(positional) and `Client sent message:` (human) lines and confirm they mirror +each action event-for-event. diff --git a/tests/test_rlog_checksum.lua b/tests/test_rlog_checksum.lua new file mode 100644 index 000000000..f6be16644 --- /dev/null +++ b/tests/test_rlog_checksum.lua @@ -0,0 +1,70 @@ +--[[ + Replay-log (MP.RLOG) tamper-detection test. + + Verifies the CHK trailer's carbon hash equals a hash over the carbon + (positional) stream as re-extracted from the log by its "MP_RLOG:" prefix, and + that editing a single opcode changes the hash -- i.e. the cheap end-of-game + fingerprint catches a tampered log. (joker_hash is not collision-resistant; + this guards against casual edits, not a determined forger who also fixes the + hash -- see lib/replay_log.lua.) + + Run from the repo root: + lua tests/test_rlog_checksum.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end +function sendWarnMessage() end + +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = {}, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +local carbon_hash = RLOG.end_run({ result = "stop" }) + +-- Re-extract the carbon (positional) stream from the log by prefix: action +-- lines are "MP_RLOG: ...", excluding MANIFEST/END/CHK. This is the domain. +local carbon_lines = {} +local chk_carbon +for _, l in ipairs(captured) do + if l:match("^MP_RLOG: %d") then + carbon_lines[#carbon_lines + 1] = l + end + chk_carbon = l:match("^MP_RLOG: CHK v1 carbon=(%x+)") or chk_carbon +end + +local original = table.concat(carbon_lines, "\n") +assert(chk_carbon == carbon_hash, "CHK trailer carbon hash != end_run return") +assert(MP.UTILS.joker_hash(original) == carbon_hash, "CHK carbon hash must equal hash of the carbon stream") + +-- Tamper: buying slot 1 instead becomes slot 2. The hash must change. +local tampered = original:gsub("1 buy 1 1", "1 buy 1 2", 1) +assert(tampered ~= original, "tamper precondition failed (pattern not found)") +assert(MP.UTILS.joker_hash(tampered) ~= carbon_hash, "tampered stream must not match the stored hash") + +print("test_rlog_checksum: OK") diff --git a/tests/test_rlog_roundtrip.lua b/tests/test_rlog_roundtrip.lua new file mode 100644 index 000000000..7525e718e --- /dev/null +++ b/tests/test_rlog_roundtrip.lua @@ -0,0 +1,120 @@ +--[[ + Replay-log (MP.RLOG) round-trip test. + + Drives lib/replay_log.lua with stubbed globals, captures the lines it emits to + the Lovely log, and asserts the dual stream is well-formed: a MANIFEST header + and END + CHK trailer under the "MP_RLOG:" carbon prefix; carbon action lines + with a gapless monotonic sequence; positional args (including ordered index- + lists) intact; a paired human "Client sent message:" line per action; and CHK + per-stream hashes that equal a recompute over the captured lines and match + what was submitted to the server. + + Run from the repo root: + lua tests/test_rlog_roundtrip.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end +function sendWarnMessage() end + +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + submit_log_hashes = function(c, h, seed, log) + submitted = { carbon = c, human = h, seed = seed, log = log } + end, + }, + UTILS = { + -- Real Adler-style hash from lib/crypto.lua so the domain matches prod. + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +-- โ”€โ”€โ”€ Drive a run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +RLOG.begin_run({ seed = "ABCD", ruleset = "r", gamemode = "g", deck = "Red Deck", stake = 1 }) +RLOG.record("select_blind", 0, "action:selectBlind,blind:bl_small") +RLOG.record("buy", { 1, 2 }, "action:boughtCardFromShop,card:Blueprint,cost:4") +RLOG.record("play", { { 1, 3, 5, 7, 8 } }, "action:play,cards:1.3.5.7.8") +RLOG.record("use", { 1, { 2, 4 } }, "action:usedCard,card:The Tower") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +local carbon_hash, human_hash = RLOG.end_run({ result = "win" }) + +-- โ”€โ”€โ”€ Parse the captured log lines โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +assert(captured[1]:match("^MP_RLOG: MANIFEST {"), "first line must be MANIFEST, got: " .. tostring(captured[1])) +assert(captured[#captured - 1]:match("^MP_RLOG: END {"), "penultimate must be END, got: " .. tostring(captured[#captured - 1])) +assert( + captured[#captured]:match("^MP_RLOG: CHK v1 carbon=%x+ human=%x+ bytes=%d+$"), + "bad CHK: " .. tostring(captured[#captured]) +) + +local A = {} -- seq -> arg string +local carbon_lines, human_lines = {}, {} -- in-order full lines (the hash domains) +local last_seq = 0 +for _, l in ipairs(captured) do + local s, rest = l:match("^MP_RLOG: (%d+) (.+)$") + if s then + s = tonumber(s) + A[s] = rest + carbon_lines[#carbon_lines + 1] = l + assert(s == last_seq + 1, "carbon sequence not gapless/monotonic at " .. s) + last_seq = s + end + local payload = l:match("^Client sent message: (.+)$") + if payload then + human_lines[#human_lines + 1] = l + end +end + +-- โ”€โ”€โ”€ Assertions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +assert(A[1] == "select_blind 0", "A1=" .. tostring(A[1])) +assert(A[2] == "buy 1 2", "A2=" .. tostring(A[2])) +assert(A[3] == "play 1.3.5.7.8", "A3=" .. tostring(A[3])) -- ordered index-list preserved +assert(A[4] == "use 1 2.4", "A4=" .. tostring(A[4])) -- target index-list preserved +assert(A[5] == "reroll", "A5=" .. tostring(A[5])) -- nil args -> bare opcode +assert(A[6] == nil, "unexpected extra carbon action line") + +assert(#human_lines == 5, "expected 5 human lines, got " .. #human_lines) +assert(human_lines[2] == "Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4", "H2=" .. human_lines[2]) + +-- Hash domains: CHK values must equal a recompute over the in-order carbon / +-- human lines (exactly what gets re-extracted from a log by prefix). +local chk_carbon, chk_human = captured[#captured]:match("carbon=(%x+) human=(%x+)") +assert(chk_carbon == MP.UTILS.joker_hash(table.concat(carbon_lines, "\n")), "carbon hash domain mismatch") +assert(chk_human == MP.UTILS.joker_hash(table.concat(human_lines, "\n")), "human hash domain mismatch") +assert(carbon_hash == chk_carbon and human_hash == chk_human, "end_run return != CHK trailer") + +-- The same hashes are what we send to the server, with the seed for keying. +assert(submitted, "hashes not submitted to server") +assert(submitted.carbon == carbon_hash and submitted.human == human_hash, "submitted hashes mismatch") +assert(submitted.seed == "ABCD", "seed not forwarded to server") + +-- The full carbon block (manifest + actions + END + CHK) is shipped to the server +-- so it keeps the whole viewable/replayable log, not just the hash. +assert(submitted.log, "carbon log not sent to server") +assert(submitted.log:match("MP_RLOG: MANIFEST {"), "sent log missing manifest line") +assert(submitted.log:match("MP_RLOG: 2 buy 1 2"), "sent log missing action lines") +assert(submitted.log:match("MP_RLOG: CHK v1 "), "sent log missing CHK trailer") + +print("test_rlog_roundtrip: OK") diff --git a/tests/test_rlog_stream.lua b/tests/test_rlog_stream.lua new file mode 100644 index 000000000..6b681d56d --- /dev/null +++ b/tests/test_rlog_stream.lua @@ -0,0 +1,97 @@ +--[[ + Replay-log (MP.RLOG) live-stream test. + + Verifies the in-game streaming layer: carbon lines are flushed to the server + in batches under a stable per-game id, every carbon line is streamed (manifest + + actions + END + CHK), and the final submit_log_hashes carries the same + game id (so the server can swap the live stream for the complete package). + + Uses a tiny flush threshold to force several batches. love.timer is absent, so + flushing falls back to the line-count trigger plus the end-of-run flush. + + Run from the repo root: + lua tests/test_rlog_stream.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local traced = {} +function sendTraceMessage(msg) + traced[#traced + 1] = msg +end +function sendWarnMessage() end + +local streamed = {} -- flat list of every streamed line, in order +local stream_game_ids = {} -- game id seen on each batch +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + stream_log_lines = function(game_id, lines) + stream_game_ids[#stream_game_ids + 1] = game_id + for _, l in ipairs(lines) do + streamed[#streamed + 1] = l + end + end, + submit_log_hashes = function(carbon, human, seed, log, game_id) + submitted = { carbon = carbon, human = human, seed = seed, log = log, game_id = game_id } + end, + }, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") +RLOG.STREAM_FLUSH_LINES = 2 -- force a flush every couple of lines +RLOG.STREAM_FLUSH_SECS = 1e9 -- disable the time-based trigger for the test + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +RLOG.end_run({ result = "stop" }) + +-- โ”€โ”€ Game id: present, stable across batches, matches the final submit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +assert(#stream_game_ids > 0, "no live batches were streamed") +local gid = stream_game_ids[1] +assert(type(gid) == "string" and #gid > 0, "game id missing/empty") +for _, g in ipairs(stream_game_ids) do + assert(g == gid, "game id changed between batches") +end +assert(submitted, "submit_log_hashes was not called") +assert(submitted.game_id == gid, "final submit did not carry the streamed game id") + +-- โ”€โ”€ Batching actually happened (threshold of 2 โ‡’ multiple flushes) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +assert(#stream_game_ids >= 2, "expected multiple batches with flush-lines=2") + +-- โ”€โ”€ Every carbon line was streamed, in order (manifest + actions + END + CHK) โ”€ +local carbon_traced = {} +for _, m in ipairs(traced) do + if m:match("^MP_RLOG:") then + carbon_traced[#carbon_traced + 1] = m + end +end +assert( + #streamed == #carbon_traced, + string.format("streamed %d lines but carbon block has %d", #streamed, #carbon_traced) +) +for i = 1, #carbon_traced do + assert(streamed[i] == carbon_traced[i], "streamed line mismatch at index " .. i) +end +assert(carbon_traced[1]:match("^MP_RLOG: MANIFEST "), "first streamed line must be the manifest") +assert(carbon_traced[#carbon_traced]:match("^MP_RLOG: CHK "), "last streamed line must be the CHK trailer") + +print("test_rlog_stream: OK") diff --git a/tests/test_serialization_guard.lua b/tests/test_serialization_guard.lua new file mode 100644 index 000000000..9c9d27943 --- /dev/null +++ b/tests/test_serialization_guard.lua @@ -0,0 +1,84 @@ +--[[ + Serialization zip-bomb guard test. + + Exercises MP.UTILS.str_decode_and_unpack's size guard (lib/serialization.lua): + an over-large encoded payload must be rejected BEFORE any base64/gzip work, a + non-string must be rejected, and a normal small payload must still round-trip. + + love.data and STR_PACK are stubbed with identity codecs so the test runs under + plain Lua (the guard logic is what we're covering, not love's real codecs). + + Run from the repo root: + lua tests/test_serialization_guard.lua +]] + +MP = { UTILS = {} } + +-- Track whether the heavy decode path was entered, so we can prove the guard +-- short-circuits before spending any CPU on an oversized payload. +local decode_calls = 0 + +love = { + data = { + decode = function(_container, _fmt, s) + decode_calls = decode_calls + 1 + return s + end, + decompress = function(_container, _fmt, s) + return s + end, + compress = function(_container, _fmt, s) + return s + end, + encode = function(_container, _fmt, s) + return s + end, + }, +} + +-- Minimal STR_PACK for a flat table of string/number values -> "return { ... }". +function STR_PACK(data) + local parts = {} + for k, v in pairs(data) do + local key = string.format("[%q]", k) + local val = type(v) == "number" and tostring(v) or string.format("%q", v) + parts[#parts + 1] = key .. "=" .. val + end + return "return {" .. table.concat(parts, ",") .. "}" +end + +dofile("lib/serialization.lua") + +local failures = 0 +local function check(name, cond) + if cond then + print("ok - " .. name) + else + failures = failures + 1 + print("FAIL - " .. name) + end +end + +-- โ”€โ”€โ”€ 1. Oversized payload is rejected before any decode work โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +decode_calls = 0 +local big = string.rep("A", MP.UTILS.MAX_ENCODED_BYTES + 1) +local res, err = MP.UTILS.str_decode_and_unpack(big) +check("oversized payload returns nil", res == nil) +check("oversized payload reports 'too large'", type(err) == "string" and err:find("too large") ~= nil) +check("oversized payload never reached love.data.decode", decode_calls == 0) + +-- โ”€โ”€โ”€ 2. Non-string payload is rejected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +local res2 = MP.UTILS.str_decode_and_unpack({ not_a = "string" }) +check("non-string payload returns nil", res2 == nil) + +-- โ”€โ”€โ”€ 3. A normal small payload still round-trips โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +local original = { name = "Blueprint", cost = 10 } +local encoded = MP.UTILS.str_pack_and_encode(original) +check("normal payload is under the cap", #encoded <= MP.UTILS.MAX_ENCODED_BYTES) +local decoded = MP.UTILS.str_decode_and_unpack(encoded) +check("normal payload round-trips", type(decoded) == "table" and decoded.name == "Blueprint" and decoded.cost == 10) + +if failures > 0 then + error(failures .. " check(s) failed") +end +print("\nAll serialization guard checks passed.") diff --git a/ui/game/functions.lua b/ui/game/functions.lua index 6c2bd2253..cd018ac69 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -15,6 +15,7 @@ function G.FUNCS.mp_toggle_ready(e) sendTraceMessage("Toggling Ready", "MULTIPLAYER") MP.GAME.ready_blind = not MP.GAME.ready_blind MP.GAME.ready_blind_text = MP.GAME.ready_blind and localize("b_unready") or localize("b_ready") + MP.RLOG.record("ready_blind", MP.GAME.ready_blind and 1 or 0) MP.GAME.pvp_reached = true @@ -62,6 +63,14 @@ function G.FUNCS.select_blind(e) if MP.is_mp_or_ghost() then MP.GAME.ante_key = tostring(math.random()) if not MP.GHOST.is_active() then + -- Carbon: log the freshly-rolled (non-deterministic) ante_key first so + -- a replay can restore it, then the blind selection itself. + MP.RLOG.record("set_ante_key", MP.GAME.ante_key) + MP.RLOG.record( + "select_blind", + 0, + string.format("action:selectBlind,blind:%s", tostring(e.config.ref_table.key or e.config.ref_table.name)) + ) 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)) @@ -87,6 +96,7 @@ G.FUNCS.skip_blind = function(e) end MP.ACTIONS.skip(G.GAME.skips) + MP.RLOG.record("skip_blind", 0, "action:skipBlind") --Update the furthest blind local temp_furthest_blind = 0 diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 8665cc76d..cc5bac32f 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -404,7 +404,12 @@ end local old_play = G.FUNCS.play_cards_from_highlighted function G.FUNCS.play_cards_from_highlighted(...) + -- Carbon: capture which hand slots are played BEFORE old_play consumes them. + local played = MP.UTILS.highlighted_hand_indices() old_play(...) + if #played > 0 then + MP.RLOG.record("play", { played }, "action:play,cards:" .. table.concat(played, ".")) + end if G.play and G.play.cards[1] then return end if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then if MP.is_pvp_boss() then From be18f2aca5802f6a7ca50864c8ac2bfda1406154 Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 09:45:22 -0600 Subject: [PATCH 05/24] 0.5.0 Merge (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): bump version to 0.4.1~DEV * fixes (#462) * Update standard.lua WSOB Ruleset lets go * Add WSOB as tournament ruleset; bump version (#470) * feat(rulesets): add WSOB tournament ruleset; restore standard layer The "WSOB Ruleset lets go" change had gutted the shared `standard` layer, which silently rebalanced every standard-composed ruleset (Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert standard.lua to its pre-WSOB form and move those settings into a dedicated WSOB ruleset instead. - rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned), gated on the ranked layer for version locking - objects/enhancements/mp_glass.lua: extend Glass rework to "wsob" - en-us.lua: k_wsob name + description - ruleset_selection.lua: wire WSOB into the Tournaments tab * refactor(ui): drop dead ruleset_buttons_data Orphaned since the tabbed-ruleset refactor (#419), which switched every consumer to `rulesets_tabs` but left this file-local defined and unused. Zero references; pure dead allocation. * chore(release): bump version to 0.4.3~DEV * feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471) * Extract challenge effects into toggleable mutator layers + Wraith rework Move the runtime effects from the eeeee, balancing_act (score instability), polymorph_spam, and shared_pockets challenges into layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each layer sets its flag from on_apply_bans at run start, so the effect can be toggled as a mutator on any ruleset; the challenges keep their rules.custom and now share one hook implementation (deduped). Surface each on the mutator wall. Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon joker + $5, vs vanilla's random Rare + money to $0), gated via reworked_consumables with the vanilla c_wraith banned_silent. Also a stylua normalization pass touched bacon/planet_tycoon. * fix broken wraith * Wire up custom ruleset editor Play button (Path 1) Custom tab now launches a run with a chosen base ruleset + the mutators wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/ Experimental) to base rulesets, sets the ruleset directly without routing through apply_default_modifiers (which would wipe MP.MODIFIERS), then LoadReworks + the normal start flow. Modifiers sync to the guest via the existing modifier_layers serialization. Custom card bans are gated behind a coming-soon pill (not yet threaded into ApplyBans or network sync). Removed the COMING SOON ribbon. * Remove coming-soon line from mutators wall Drop the 'coming soon ยท Reward Draft ยท Rubber Band ยท Score Tax' footer and the unused SOON table that fed it. Stub layers in mutator_stubs.lua are untouched. * Add No Red Seals mutator layer New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red assignments (RNG-neutral, so both clients no-op the same seal and stay synced; the slot just goes sealless). Wired into the HAZARDS column of the mutators wall alongside No Easy Money / No Uncommons. * Disable not-yet-working mutators + stub custom editor on lobby path Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and skipped by the Randomize / Blind Random rolls so they can never activate. Gate the custom ruleset editor to a coming-soon panel on the lobby (mp) path; sp/practice keep the full editor. * Disable Spartan and Pricey Packs mutators (not yet working) * Surface the three stub mutators on the wall as coming-soon cells Reward Draft, Rubber Band and Score Tax (the unwired layers from mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of being invisible. * Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips Disabled mutator cells now show their full description with a trailing "(Coming soon)" line instead of replacing the text outright. * Disable Cash Ceiling mutator (not yet working) * Strip LLM-flavored comment noise from mutator layers Drop the seam/contract/MP-safe explanatory essays and fake file:line citations; keep the human voice. Also removes the dormant, unreferenced sticker_shop layer. * Add credit badges to mutator wall tooltips Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from mod_badges.lua as a standalone builder, dropped into the cell tooltip via create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells opt in with a credits = { art/idea/code } field. Rotation skips the MULTIPLAYER header โ€” one credit shows static, multiple rotate. Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy Money, plus a few code credits. * Casjb/full replay (#473) * feat(replay): dual-stream deterministic action log (MP.RLOG) Add a fully-recreatable action logging system. Every state-affecting player action emits two aligned streams from one record() call: - Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with no card names, so it is indiscriminate and replays across mods. Written to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod folder, so version hotswaps don't disturb it). - Human stream: mirrored into the carbon file; the player-facing Lovely log is left byte-for-byte unchanged so the website parser is undisturbed. Both streams are hashed at game end (joker_hash) and submitted to the server via the new submitLogHashes action for cheap tamper detection. Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard, pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid). Instrumentation extends existing overrides; reorder uses a Preview- independent debounced CardArea:update detector. This is the logging system only; the replay runner and server-side hash storage are follow-ups. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua (both pass on Lua 5.4). ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * refactor(replay): emit both streams into the Lovely log, drop .carbon file Per design change: keep the carbon (replay) and human-readable streams in the same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar. - Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines / CHK trailer); the human stream keeps the existing "Client sent message:" prefix. - record() is now the sole emitter of both lines, so the instrumented overrides (buy/sell/reroll/use) no longer log the human line themselves. - Remove the .carbon file writer, path derivation, flush(), and the per-round flush call. Both stream buffers are still hashed at end_run and re-derive from the log by prefix. - Rework the tests to capture sendTraceMessage output (no file) and update docs. Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4. ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * feat(replay): ship the full carbon log to the server, not just its hash end_run now accumulates the full carbon block (manifest + action lines + END + CHK) and sends it as a `log` field on submitLogHashes, so the server can store a complete viewable/replayable record of every game without the player handing over their log. Hashing is unchanged (still over the action/human buffers). ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * feat(serialization): guard str_decode_and_unpack against zip-bomb payloads Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers / receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client. A legitimate saved joker / nemesis deck is a few KB; both callers already bail cleanly on the nil return. Defense-in-depth โ€” the relay also caps message size. Adds tests/test_serialization_guard.lua. ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * chore(mod): bump version to 0.4.3 for the replay-log pre-release build ๐Ÿค– Commit message generated by Claude Code (https://claude.com/claude-code) * fix(replay): crash-proof area_enum (table index is nil on shop buy) area_enum built a lookup table keyed by the CardArea globals, but several of them are nil depending on game state (G.pack_cards only exists while a booster is open, G.shop_* only in the shop). A table literal with a nil key throws 'table index is nil', so any buy/sell/use while one of those areas was absent crashed the game โ€” reproducibly on the first shop purchase. Compare the area against each global directly instead (a live area vs a nil global is just false), which is crash-safe and returns the same enum. Commit message generated with Claude Code * feat(replay): stream carbon lines live to the server in batches While a game plays, MP.RLOG now batches its carbon lines and flushes them to the server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id generated in begin_run. So a crashed/abandoned game still leaves a partial record server-side; on a clean end, submit_log_hashes carries the same game id so the server swaps the live stream for the complete hashed package. Flushing falls back to the line-count trigger when love.timer is unavailable (tests), and no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua. Commit message generated with Claude Code * Hide opponent score until first hand played (#475) * Show ??? for opponent score until first hand of a PvP blind Display the opponent's score as "???" in the blind HUD until the local player has played a hand in the current PvP blind, matching the server's withholding of enemyInfo. Reset the stored opponent score at the start of each blind so the first frame after playing shows 0 rather than the previous blind's stale score. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn * Add hide_score_until_played lobby option (default on for standard layer) Expose the opponent-score hiding as a host-toggleable lobby option. It defaults on only for standard-layer rulesets (start_lobby derives it from MP.current_ruleset().standard) and off everywhere else; the baseline config value is off. The blind HUD only masks the opponent score as "???" when the option is enabled. The toggle lives in the lobby options tab, so it is editable in non-forcing lobbies and locked at its default when a ruleset forces options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn * Mask opponent hands as "?" until the first enemy info each blind While the opponent's score is hidden, their hands count previously still showed its stored value. Track an info_received flag on MP.GAME.enemy (reset at blind start, set when an enemyInfo arrives) and drive a new hands_text field, shown in place of the raw count. When the hide-score option is on, the HUD shows "?" for opponent hands until the first enemy info of the blind; otherwise it mirrors the real count as before. Ghost replays mark info as received so replays are never masked. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn --------- Co-authored-by: Claude * Various fixes (#472) * Timer: prevent old timer tick when ready + more correct check when can timer opponent * TheOrder: fix SMODS seals desync * Networking: try-catch for server messages + resolve channel once * UI: fix ko-fi message display & crash * CardAreas: fix crash in Shared Pockets + make shared area move in place instantly * fix old timer not tick when you timer opponent * Update Phantom Edition Text (#464) The current phantom edition text is somewhat confusing, this looks to make it clear what this edition does. * feat: update vietnamese's localization (wip) (#468) * Season 7 changese --------- Co-authored-by: Stephen Kirk Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Co-authored-by: Fantom-Balatro Co-authored-by: Casper JB Co-authored-by: Claude Co-authored-by: EchoHeli Co-authored-by: Trแบงn Quแป‘c Lรขn <62930566+minotour4869@users.noreply.github.com> --- Multiplayer.json | 2 +- core.lua | 6 + layers/economy_mutators.lua | 20 +- layers/eeeee.lua | 23 + layers/esoteric_mutators.lua | 26 -- layers/glass_cannon.lua | 25 +- layers/mutator_stubs.lua | 18 +- layers/no_red_seals.lua | 21 + layers/polymorph_spam.lua | 145 ++++++ layers/score_instability.lua | 71 +++ layers/shared_pockets.lua | 77 ++++ layers/standard.lua | 5 + layers/wraith_rework.lua | 9 + lib/ghost_replay.lua | 2 + localization/en-us.lua | 23 +- localization/es_419.lua | 2 +- localization/es_ES.lua | 2 +- localization/fr.lua | 2 +- localization/it.lua | 1 - localization/mi.lua | 2 +- localization/vi.lua | 418 +++++++++++++++++- lovely/TheOrder.toml | 18 +- lovely/shared_area.toml | 3 +- networking/action_handlers.lua | 64 ++- objects/challenges/bacon.lua | 4 +- objects/challenges/balancing_act.lua | 66 +-- objects/challenges/eeeee.lua | 20 +- objects/challenges/planet_tycoon.lua | 2 +- objects/challenges/polymorph_spam.lua | 140 +----- objects/challenges/shared_pockets.lua | 57 +-- objects/consumables/wraith.lua | 31 ++ objects/enhancements/mp_glass.lua | 2 +- objects/jokers/standard/ticket.lua | 2 +- rulesets/ranked.lua | 2 +- rulesets/wsob.lua | 33 ++ ui/game/blind_hud.lua | 28 +- ui/game/game_end.lua | 4 +- ui/game/timer.lua | 2 + ui/lobby/_lobby_options/options_tab.lua | 5 + ui/main_menu/play_button/_mutators_wall.lua | 278 +++++++++--- .../play_button/custom_ruleset_editor.lua | 134 ++++-- .../play_button/play_button_callbacks.lua | 4 + .../play_button/ruleset_selection.lua | 31 +- 43 files changed, 1288 insertions(+), 542 deletions(-) create mode 100644 layers/eeeee.lua create mode 100644 layers/no_red_seals.lua create mode 100644 layers/polymorph_spam.lua create mode 100644 layers/score_instability.lua create mode 100644 layers/shared_pockets.lua create mode 100644 layers/wraith_rework.lua create mode 100644 objects/consumables/wraith.lua create mode 100644 rulesets/wsob.lua diff --git a/Multiplayer.json b/Multiplayer.json index 933a36261..cf81ad04a 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.3", + "version": "0.5.0", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/core.lua b/core.lua index ca644cacc..64b84a64e 100644 --- a/core.lua +++ b/core.lua @@ -197,6 +197,8 @@ function MP.reset_lobby_config(persist_ruleset_and_gamemode) forced_config = false, preview_disabled = false, legacy_smallworld = false, + -- Baseline off; start_lobby sets it on for standard-layer rulesets. + hide_score_until_played = false, } end MP.reset_lobby_config() @@ -217,6 +219,10 @@ function MP.reset_game_states() score = MP.INSANE_INT.empty(), score_text = "0", hands = 4, + hands_text = "4", + -- Whether an enemyInfo message has arrived this blind. Used to mask + -- the opponent's hands as "?" until we hear from them. + info_received = false, location = localize("loc_selecting"), skips = 0, lives = MP.LOBBY.config.starting_lives, diff --git a/layers/economy_mutators.lua b/layers/economy_mutators.lua index d3943a2d3..df7fe7b3b 100644 --- a/layers/economy_mutators.lua +++ b/layers/economy_mutators.lua @@ -1,33 +1,23 @@ --- Economy mutator layers (the "big bag" โ€” green knobs). --- --- Pure data. Each declares `game_modifiers` (copied onto G.GAME.modifiers at run --- start by the generic applier) and/or `banned_*` arrays (merged via --- current_ruleset). No runtime code lives here โ€” these are all engine-native --- modifier fields Balatro already reads, so they're deterministic across both --- clients (set once at run start). --- --- Contract with the applier: a layer's `game_modifiers = { = value }` is --- written to G.GAME.modifiers[]; `starting_params = { = value }` to --- G.GAME.starting_params[] (with round_resets propagation handled there). +-- TODO barely anything here is properly wired up now, just stubbed -- Shop prices climb +$1 per purchase, compounding across the run. -- (card.lua: `if G.GAME.modifiers.inflation then G.GAME.inflation = ... + 1`) + MP.Layer("inflation", { game_modifiers = { inflation = true }, }) --- No interest paid on held money. Pure economic pressure. +-- No interest paid on held money MP.Layer("no_interest", { game_modifiers = { no_interest = true }, }) --- Every discard costs $1. (state_events.lua: ease_dollars(-discard_cost) per discard) +-- Every discard costs $1 MP.Layer("discard_tax", { game_modifiers = { discard_cost = 1 }, }) --- Leftover discards pay out at end of round, like unused hands do. A positive --- knob to pair against the punishing ones. (default money_per_discard is 0) +-- Leftover discards pay out at end of round MP.Layer("frugal", { game_modifiers = { money_per_discard = 1 }, }) diff --git a/layers/eeeee.lua b/layers/eeeee.lua new file mode 100644 index 000000000..526d2554d --- /dev/null +++ b/layers/eeeee.lua @@ -0,0 +1,23 @@ +-- Eeeee: ~40% of RNG poll keys per ante return a fixed value, not the seed-derived one. +MP.Layer("eeeee", { + on_apply_bans = function() + G.GAME.modifiers.mp_eeeee = true + end, +}) + +local pseudoseed_ref = pseudoseed +function pseudoseed(key, predict_seed) + if G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_eeeee and not G._MP_UNSAVED_PRNG then + G.GAME.mp_eeeee = G.GAME.mp_eeeee or {} + local ante = G.GAME.round_resets.mp_real_ante or G.GAME.round_resets.ante + if not G.GAME.mp_eeeee[ante .. "_" .. key] then + math.randomseed(pseudohash((G.GAME.pseudorandom.seed or "") .. ante .. "mp_eeeee_" .. key)) + G.GAME.mp_eeeee[ante .. "_" .. key] = { + poll = math.random(), + val = math.random(), + } + end + if G.GAME.mp_eeeee[ante .. "_" .. key].poll < 0.4 then return G.GAME.mp_eeeee[ante .. "_" .. key].val end + end + return pseudoseed_ref(key, predict_seed) +end diff --git a/layers/esoteric_mutators.lua b/layers/esoteric_mutators.lua index 96f54c931..aed0d49ac 100644 --- a/layers/esoteric_mutators.lua +++ b/layers/esoteric_mutators.lua @@ -1,45 +1,19 @@ --- Esoteric mutator layers (green knobs โ€” the genuinely weird ones). --- --- All engine-native G.GAME.modifiers flags, so they're pure data and --- deterministic. See economy_mutators.lua for the applier contract. - --- Hand is dealt face-down. You play blind. (cardarea.lua / common_events.lua --- read modifiers.flipped_cards when drawing.) MP.Layer("flipped_cards", { game_modifiers = { flipped_cards = true }, }) --- Played cards contribute nothing โ€” you win purely on jokers and mult. --- (state_events.lua: modifiers.debuff_played_cards) MP.Layer("debuff_played_cards", { game_modifiers = { debuff_played_cards = true }, }) --- Every joker is eternal: can't be sold or destroyed. Your build locks in. --- (common_events.lua: modifiers.all_eternal) MP.Layer("all_eternal", { game_modifiers = { all_eternal = true }, }) --- The shop sells jokers carrying eternal / perishable / rental stickers โ€” --- roguelike risk on every purchase. (common_events.lua: enable_*_in_shop) -MP.Layer("sticker_shop", { - game_modifiers = { - enable_eternals_in_shop = true, - enable_perishables_in_shop = true, - enable_rentals_in_shop = true, - }, -}) - --- Scoring chips are capped at your current money. Hoarding cash literally raises --- your ceiling. Brutal in PvP โ€” opt-in only. (misc_functions.lua mod_chips: --- _chips = min(_chips, max(dollars, 0))) MP.Layer("chip_cap", { game_modifiers = { chips_dollar_cap = true }, }) --- Hand size shrinks by 1 for every $10 you hold. Rich = clumsy. --- (cardarea.lua: minus_hand_size_per_X_dollar) MP.Layer("shrinking_hand", { game_modifiers = { minus_hand_size_per_X_dollar = 10 }, }) diff --git a/layers/glass_cannon.lua b/layers/glass_cannon.lua index 16b23342a..49ac226d5 100644 --- a/layers/glass_cannon.lua +++ b/layers/glass_cannon.lua @@ -1,28 +1,17 @@ --- Glass Cannon (built, not stubbed). --- --- Fragile but devastating: far fewer hands per round, but a flat global multiplier --- on every hand's final score. Two halves: --- 1. data โ€” `starting_params.hands` (the applier sets this at run start) --- 2. hook โ€” a global xmult applied once per hand, gated on the layer --- --- The mult lever is deterministic (no RNG, same hand -> same score) and rides the --- existing score sync, so it's MP-safe. - --- Balance knobs (provisional โ€” tune freely). -local GLASS_CANNON_HANDS = 2 +-- TODO XMult works, but hand size change doesn't work +local GLASS_CANNON_HANDS = 2 -- doesn't work local GLASS_CANNON_XMULT = 4 MP.Layer("glass_cannon", { - starting_params = { hands = GLASS_CANNON_HANDS }, + starting_params = { hands = GLASS_CANNON_HANDS }, -- doesn't work }) --- final_scoring_step is the canonical once-per-hand seam: vanilla calls it after --- all jokers to let the deck rebalance chips/mult (it's where Plasma halves and --- recombines). We wrap it, let the real deck run, then scale mult on top when the --- layer is live. nu_mult can come back nil (most decks), in which case the engine --- falls back to the incoming mult โ€” so we base our scale on (nu_mult or args.mult). +-- final_scoring_step called after jokers to let the deck rebalance chips/mult +-- (it's where Plasma does its math) +-- so we wrap that burrito and then scale the mult on top local _back_trigger_effect = Back.trigger_effect function Back:trigger_effect(args) + -- magic. don't ask local nu_chip, nu_mult = _back_trigger_effect(self, args) if args and args.context == "final_scoring_step" and MP.is_layer_active("glass_cannon") then local base_mult = nu_mult or args.mult diff --git a/layers/mutator_stubs.lua b/layers/mutator_stubs.lua index 94c2356b3..2034ee0b7 100644 --- a/layers/mutator_stubs.lua +++ b/layers/mutator_stubs.lua @@ -1,22 +1,8 @@ --- Stubbed mutator layers (yellow knobs โ€” registered but inert). --- --- These need real hooks, not just data. They're registered so they exist in the --- layer system and can be composed/selected, but they currently do nothing. --- Each note records the intended behavior and the seam it'll hook. - --- PvP REWARD DRAFT โญ --- Beat a PvP blind -> pick 1 of N rewards (joker / tarot / $ / voucher). --- Local-only effect (opponent's deck isn't simulated locally), so MP-safe; seed --- the offered choices off (lobby seed + ante + player) for reproducible fairness. --- Seam: the endPvP / PvP-blind-win path in networking + ui/game. +-- PvP REWARD DRAFT โญ Beat a PvP blind -> pick 1 of N rewards (joker / tarot / $ / voucher). MP.Layer("pvp_reward_draft", {}) --- RUBBER-BAND --- Losing a life grants an escalating buff (comeback mechanic for casual lobbies). --- Seam: the life-loss path (action_set_lives / ease_lives). +-- RUBBER-BAND - losing a life grants an escalating buff MP.Layer("rubber_band", {}) -- SCORE TAX --- Every hand you play nudges your opponent's target up slightly. Pure pressure. --- Seam: rides the existing playHand / enemy-score sync. MP.Layer("score_tax", {}) diff --git a/layers/no_red_seals.lua b/layers/no_red_seals.lua new file mode 100644 index 000000000..72df46665 --- /dev/null +++ b/layers/no_red_seals.lua @@ -0,0 +1,21 @@ +-- No Red Seals: red seals never get applied โ€” pack polls, Certificate, copies, etc. +-- Deja Vu does nothing but apply a Red Seal, so ban it outright rather than leave a +-- dead spectral in the pool +MP.Layer("no_red_seals", { + banned_consumables = { "c_deja_vu" }, + on_apply_bans = function() + G.GAME.modifiers.mp_no_red_seals = true + end, +}) + +-- the "clean" way is to yank Red out of the seal pool. sure. except deja vu hardcodes +-- a red seal, certificate flings random ones, copied cards drag their seal along... and +-- i am not lovely patching every single one of those. no thank you. +-- returning instead of passing nil so it doesn't nuke a seal that was already sitting there +local set_seal_ref = Card.set_seal +function Card:set_seal(_seal, silent, immediate) + if _seal == "Red" and G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_no_red_seals then + return -- not today + end + return set_seal_ref(self, _seal, silent, immediate) +end diff --git a/layers/polymorph_spam.lua b/layers/polymorph_spam.lua new file mode 100644 index 000000000..6268404b2 --- /dev/null +++ b/layers/polymorph_spam.lua @@ -0,0 +1,145 @@ +-- Polymorph Spam: every blind, your jokers and consumables re-roll into other centers. +MP.Layer("polymorph_spam", { + on_apply_bans = function() + G.GAME.modifiers.mp_polymorph_spam = true + end, +}) + +local function get_area(card) + if not card then return end + if card.config.center.set == "Joker" then + return G.jokers + elseif card.config.center.consumeable then + return G.consumeables + end + return nil +end + +local function get_pos(card) + local area = get_area(card) + for i, v in ipairs(area.cards) do + if card == v then return i end + end + return nil +end + +local function included(key) + if G.GAME.banned_keys[key] then + return false + elseif G.P_CENTERS[key].mp_include and type(G.P_CENTERS[key].mp_include) == "function" then + return G.P_CENTERS[key]:mp_include() + end + return true +end + +-- i should have separated this into 2 functions but this works i suppose +local function get_transmutations_loc(card) + local done = false + local num = 0 + local area = get_area(card) + local limit = area.config.card_limit + local pos = get_pos(card) or nil + local ret = {} + while not done do + for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do + if included(v.key) then + if num > 0 then + ret[#ret + 1] = { + strings = { + localize({ type = "name_text", key = v.key, set = v.set }), + }, + control = { + C = (num - 1) == (limit - (pos or -1)) and "attention" or nil, + }, + } + if num == 1 then + done = true + break + end + end + if v == card.config.center then + num = limit + else + num = math.max(num - 1, 0) + end + end + end + end + return ret +end + +local function mass_polymorph(area) + for _, card in ipairs(area) do + local done = false + local swap = 0 + while not done do + for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do + if included(v.key) then + if swap == 1 then + card:set_ability(v) + card:set_cost() + done = true + break + end + if v == card.config.center then + swap = get_pos(card) + else + swap = math.max(swap - 1, 0) + end + end + end + end + end +end + +local calculate_context_ref = SMODS.calculate_context +function SMODS.calculate_context(context, return_table, no_resolve) + if G.GAME.modifiers.mp_polymorph_spam and context and type(context) == "table" and context.setting_blind then + mass_polymorph(G.jokers.cards) + mass_polymorph(G.consumeables.cards) + end + return calculate_context_ref(context, return_table, no_resolve) +end + +local set_ability_ref = Card.set_ability +function Card:set_ability(center, initial, delay_sprites) + local ret = set_ability_ref(self, center, initial, delay_sprites) + if G.GAME.modifiers.mp_polymorph_spam and G.OVERLAY_MENU then + if not included(center.key) then self.ability.perma_debuff = true end + end + return ret +end + +local transmute_card = nil -- global local :thinking: + +local generate_card_ui_ref = generate_card_ui +function generate_card_ui(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) + local ret = + generate_card_ui_ref(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) + if G.GAME.modifiers.mp_polymorph_spam then + if card and card.config.center then -- check for card and for tag + if get_area(card) and included(card.config.center.key) then + transmute_card = card -- whatever, surely won't break + generate_card_ui_ref({ key = "mp_transmutations", set = "Other" }, ret) -- don't need to assign this to ret because lua + end + end + end + return ret +end + +-- really inefficient and throws away a metric shit ton of tables +-- thanks to the advancements of my ancestors, i don't have to worry about it +local localize_ref = localize +function localize(args, misc_cat) + if args and type(args) == "table" and args.key and args.key == "mp_transmutations" then -- really safe get + local loc_target = G.localization.descriptions.Other.mp_transmutations.text_parsed + for i = 2, #loc_target do + table.remove(loc_target, 2) + end + local list = get_transmutations_loc(transmute_card) + for i = 1, #list do + loc_target[#loc_target + 1] = { list[i] } + end + end + return localize_ref(args, misc_cat) +end diff --git a/layers/score_instability.lua b/layers/score_instability.lua new file mode 100644 index 000000000..6fb5ce33e --- /dev/null +++ b/layers/score_instability.lua @@ -0,0 +1,71 @@ +-- Score Instability: after scoring, chips and mult are dragged toward each other. +MP.Layer("score_instability", { + on_apply_bans = function() + G.GAME.modifiers.mp_score_instability = true + end, +}) + +-- put hook here ig +local bte_ref = Back.trigger_effect +function Back:trigger_effect(args) + if G.GAME.modifiers.mp_score_instability and args.context == "final_scoring_step" then -- me when i copy plasma deck + local diff = args.chips - args.mult + if diff > 0 then + diff = math.min(diff, args.mult - 1) + elseif diff < 0 then + diff = math.max(diff, -args.chips) + end + args.chips = args.chips + diff + args.mult = args.mult - diff + update_hand_text({ delay = 0 }, { mult = args.mult, chips = args.chips }) + + G.E_MANAGER:add_event(Event({ + func = function() + local text = localize("k_destabilized") + play_sound("timpani", 0.5 / 1.5, 0.4) + play_sound("timpani", 0.5, 0.5) + play_sound("timpani", 0.5 * 1.5, 0.6) + play_sound("tarot1", 1.5) + ease_colour(G.C.UI_CHIPS, G.C.PERISHABLE) + ease_colour(G.C.UI_MULT, G.C.ETERNAL) + attention_text({ + scale = 1.4, + text = text, + hold = 2, + align = "cm", + offset = { x = 0, y = -2.7 }, + major = G.play, + }) + G.E_MANAGER:add_event(Event({ + trigger = "after", + blockable = false, + blocking = false, + delay = 4.3, + func = function() + ease_colour(G.C.UI_CHIPS, G.C.BLUE, 2) + ease_colour(G.C.UI_MULT, G.C.RED, 2) + return true + end, + })) + G.E_MANAGER:add_event(Event({ + trigger = "after", + blockable = false, + blocking = false, + no_delete = true, + delay = 6.3, + func = function() + G.C.UI_CHIPS[1], G.C.UI_CHIPS[2], G.C.UI_CHIPS[3], G.C.UI_CHIPS[4] = + G.C.BLUE[1], G.C.BLUE[2], G.C.BLUE[3], G.C.BLUE[4] + G.C.UI_MULT[1], G.C.UI_MULT[2], G.C.UI_MULT[3], G.C.UI_MULT[4] = + G.C.RED[1], G.C.RED[2], G.C.RED[3], G.C.RED[4] + return true + end, + })) + return true + end, + })) + delay(0.6) + return args.chips, args.mult + end + return bte_ref(self, args) +end diff --git a/layers/shared_pockets.lua b/layers/shared_pockets.lua new file mode 100644 index 000000000..0abd4972c --- /dev/null +++ b/layers/shared_pockets.lua @@ -0,0 +1,77 @@ +-- Shared Pockets: hand, jokers and consumables share one pool of slots. +function MP.setup_shared_pockets() + if G.GAME.mp_shared_pockets then return end + G.GAME.modifiers.mp_shared_pockets = true + -- we're gonna do some hardcoded jank on the backend because slots are weird + -- so handling this like a reasonable person is riskier + G.GAME.starting_params.joker_slots = (G.GAME.starting_params.joker_slots or 0) + 1e5 + G.GAME.starting_params.consumable_slots = (G.GAME.starting_params.consumable_slots or 0) + 1e5 + G.GAME.starting_params.hand_size = (G.GAME.starting_params.hand_size or 0) + 7 + G.GAME.mp_shared_pockets = { count = 0, slots = 15 } +end + +MP.Layer("shared_pockets", { + banned_jokers = { + "j_stencil", + }, + on_apply_bans = function() + MP.setup_shared_pockets() + end, +}) + +-- just copy what vanilla does +local cardarea_update_ref = CardArea.update +function CardArea:update(dt) + if self == G.hand then + if G.GAME.modifiers.mp_shared_pockets then + self.config.mp_last_size = self.config.mp_last_size or 0 + local slots = (G.jokers.config.card_count - (G.jokers.config.card_limit - 100005)) + + (G.consumeables.config.card_count - (G.consumeables.config.card_limit - 100002)) + if slots ~= self.config.last_poll_size then + self:change_size(self.config.mp_last_size - slots) + self.config.mp_last_size = slots + end + end + end + local ret = cardarea_update_ref(self, dt) + if G.GAME.modifiers.mp_shared_pockets then + G.GAME.mp_shared_pockets.count = G.hand.config.card_count + + G.jokers.config.card_count + + G.consumeables.config.card_count + G.GAME.mp_shared_pockets.limit = G.hand.config.card_limit + + G.jokers.config.card_count + + G.consumeables.config.card_count + end + return ret +end + +-- ok look this might be a giant hack BUT +-- yeah i have nothing to say +-- whatever +local uie_update_text_ref = UIElement.update_text +function UIElement:update_text() + if G.GAME.modifiers.mp_shared_pockets then + if self.config.ref_value == "card_count" then + if + self.config.ref_table == G.hand.config + or self.config.ref_table == G.jokers.config + or self.config.ref_table == G.consumeables.config + then + self.config.ref_table = G.GAME.mp_shared_pockets + self.config.ref_value = "count" + end + end + if self.config.ref_value == "total_slots" then + if + self.config.ref_table == G.hand.config.card_limits + or self.config.ref_table == G.jokers.config.card_limits + or self.config.ref_table == G.consumeables.config.card_limits + then + self.config.ref_table = G.GAME.mp_shared_pockets + self.config.ref_value = "limit" + end + end + end + local ret = uie_update_text_ref(self) + return ret +end diff --git a/layers/standard.lua b/layers/standard.lua index 7c03164ed..4e1d90a40 100644 --- a/layers/standard.lua +++ b/layers/standard.lua @@ -9,6 +9,11 @@ MP.Layer("standard", { "j_bloodstone", "c_ouija", }, + banned_jokers = { + "j_mp_speedrun", + "j_mp_conjoined_joker", + "j_mp_defensive_joker", + }, banned_consumables = { "c_justice", }, diff --git a/layers/wraith_rework.lua b/layers/wraith_rework.lua new file mode 100644 index 000000000..6fc8200a2 --- /dev/null +++ b/layers/wraith_rework.lua @@ -0,0 +1,9 @@ +-- Wraith Rework: swaps vanilla Wraith for c_mp_wraith (random Uncommon + $5). +MP.Layer("wraith_rework", { + reworked_consumables = { + "c_mp_wraith", + }, + banned_silent = { + "c_wraith", + }, +}) diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index 8274ab54e..e0a27539c 100644 --- a/lib/ghost_replay.lua +++ b/lib/ghost_replay.lua @@ -60,6 +60,7 @@ function MP.GHOST.init_playback(ante) 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 + MP.GAME.enemy.info_received = true return true end return false @@ -97,6 +98,7 @@ function MP.GHOST.advance_hand() })) MP.GAME.enemy.hands = entry.hands_left or 0 + MP.GAME.enemy.info_received = true if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end return true end diff --git a/localization/en-us.lua b/localization/en-us.lua index d0a7b8eb4..adc86f1ed 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -803,7 +803,8 @@ return { name = "Phantom", text = { "{C:attention}Eternal{} and {C:dark_edition}Negative{}", - "Created and destroyed by your {X:purple,C:white}Nemesis{}", + "Created when your {X:purple,C:white}Nemesis{} has this Joker", + "Only affects your {X:purple,C:white}Nemesis{} and is destroyed by them.", }, }, }, @@ -958,6 +959,14 @@ return { "{C:attention}-1{} hand size", }, }, + mp_sticker_balanced_c_mp_wraith = { + name = "Balanced", + text = { + "Creates an {C:green}Uncommon{} instead of a", + "{C:red}Rare{}, and gains {C:money}$5{} instead of", + "setting your money to {C:money}$0{}", + }, + }, mp_sticker_balanced_m_gold = { name = "Balanced", text = { @@ -1141,6 +1150,13 @@ return { "{C:red}-1{} hand, {C:red}-1{} discard, or {C:red}-1{} hand size", }, }, + c_mp_wraith = { + name = "Wraith", + text = { + "Creates a random {C:green}Uncommon{} Joker", + "Gain {C:money}$#1#{}", + }, + }, }, }, misc = { @@ -1198,6 +1214,7 @@ return { b_opts_player_diff_deck = "Players have different decks", b_opts_random_loadout = "Randomize deck & stake", b_opts_normal_bosses = "Enable Boss Blind effects", + b_opts_hide_score = "Hide opponent score until you play", b_opts_timer = "Enable Timer", b_opts_disable_preview = "Disable Score Preview", b_opts_the_order = "Enable The Order", @@ -1330,6 +1347,8 @@ return { k_majorleague_description = "Official Major League Balatro ruleset.\n\nVanilla cards with competitive settings:\n- 180 second timer\n- The Order disabled\n- First timeout forgiven\n- Attrition gamemode", k_minorleague = "Minor League", k_minorleague_description = "Official Minor League Balatro ruleset.\n\nVanilla cards with competitive settings:\n- 210 second timer\n- The Order enabled\n- First timeout forgiven\n- Attrition gamemode", + k_wsob = "WSOB", + k_wsob_description = "World Series of Balatro ruleset.\n\nNear-vanilla card pool with minimal balance changes:\n- Hanging Chad, Bloodstone, and Glass reworked\n- Justice banned\n- No Multiplayer-original content\n\n(See bans and reworks tabs for details)", k_standard_ranked = "Standard Ranked", k_standard_ranked_description = "The official competitive ruleset.\n\nStandard ruleset with locked settings:\n- Attrition gamemode\n- The Order enabled\n- Requires recommended Steamodded version", k_legacy_ranked = "Legacy Ranked", @@ -1401,7 +1420,7 @@ return { k_timer_sfx = "Timer Sound Effects", ml_mp_kofi_message = { "This mod and game server is", - "developed and maintained by ", + "developed and maintained by", "one person, if", "you like it consider", }, diff --git a/localization/es_419.lua b/localization/es_419.lua index da833e4b1..921f7aad6 100644 --- a/localization/es_419.lua +++ b/localization/es_419.lua @@ -841,7 +841,7 @@ return { }, ml_mp_kofi_message = { "Este mod y sus servidores son", - "desarrollados y mantenidos por ", + "desarrollados y mantenidos por", "una sola persona, si te gusta", "puedes considerar", }, diff --git a/localization/es_ES.lua b/localization/es_ES.lua index cf281b4a9..76fd47d0b 100644 --- a/localization/es_ES.lua +++ b/localization/es_ES.lua @@ -261,7 +261,7 @@ return { }, ml_mp_kofi_message = { "Este mod y servidor es", - "desarrollado y mantenido por ", + "desarrollado y mantenido por", "una persona, sรญ", "te gusta, considera", }, diff --git a/localization/fr.lua b/localization/fr.lua index 4844f1149..192d9a183 100644 --- a/localization/fr.lua +++ b/localization/fr.lua @@ -286,7 +286,7 @@ return { }, ml_mp_kofi_message = { "Ce mod et serveur de jeu est", - "dรฉveloppรฉ et maintenu par ", + "dรฉveloppรฉ et maintenu par", "une seule personne, si", "vous l'aimez, n'hรฉsitez pas ร ", }, diff --git a/localization/it.lua b/localization/it.lua index 6c77895ca..dc90d6616 100644 --- a/localization/it.lua +++ b/localization/it.lua @@ -646,7 +646,6 @@ return { "nemico", }, ml_mp_kofi_message = { - " ", " ", "Questa mod e server รจ sviluppata e mantenuta", "da una persona, se ti รจ piaciuta considera", diff --git a/localization/mi.lua b/localization/mi.lua index bf1ed58c1..b42cd9de6 100644 --- a/localization/mi.lua +++ b/localization/mi.lua @@ -243,7 +243,7 @@ return { }, ml_mp_kofi_message = { "Kลtahi anake te tฤngata e", - "whakahaere, e tuarฤ ana ", + "whakahaere, e tuarฤ ana", "i te Balatro Multiplayer.", "Mฤ“nฤ e pฤซrangi ana,", }, diff --git a/localization/vi.lua b/localization/vi.lua index 2e42c7787..6fa7362b2 100644 --- a/localization/vi.lua +++ b/localization/vi.lua @@ -1,5 +1,5 @@ --- Localization by @theambushingbush --- Bแบฃn dแป‹ch cแปงa HuyTheKiller, yรชu cแบงu cร i thรชm VietnameseBalatro +-- Localization by @theambushingbush and @minotour4869 +-- Bแบฃn dแป‹ch cแปงa HuyTheKiller vร  Minotour, yรชu cแบงu cร i thรชm VietnameseBalatro -- https://github.com/HuyTheKiller/VietnameseBalatro return { descriptions = { @@ -12,8 +12,122 @@ return { "miแป…n phรญ", }, }, + tag_mp_uncommon_release = { + name = "Nhรฃn รt Phแป• Biแบฟn", + text = { + "Shop cรณ mแป™t", + "{C:green}Joker รt Phแป• Biแบฟn", + }, + }, + tag_mp_rare_release = { + name = "Nhรฃn Hiแบฟm", + text = { + "Shop cรณ mแป™t", + "{C:red}Joker Hiแบฟm", + }, + }, + tag_mp_foil_release = { + name = "Nhรฃn รnh Kim", + text = { + "Joker bแบฃn chuแบฉn tiแบฟp theo", + "trแปŸ thร nh {C:dark_edition}รnh Kim", + }, + }, + tag_mp_holo_release = { + name = "Nhรฃn Lแบฅp Lรกnh", + text = { + "Joker bแบฃn chuแบฉn tiแบฟp theo", + "trแปŸ thร nh {C:dark_edition}Lแบฅp Lรกnh", + }, + }, + tag_mp_poly_release = { + name = "Nhรฃn ฤa Sแบฏc", + text = { + "Joker bแบฃn chuแบฉn tiแบฟp theo", + "trแปŸ thร nh {C:dark_edition}ฤa Sแบฏc", + }, + }, + tag_mp_negative_release = { + name = "Nhรฃn ร‚m Bแบฃn", + text = { + "Joker bแบฃn chuแบฉn tiแบฟp theo", + "trแปŸ thร nh {C:dark_edition}ร‚m Bแบฃn", + }, + }, }, Joker = { + j_mp_seltzer = { + name = "Khoรกng Cรณ Ga", + text = { + "Tรกi kรญch mแปi lรก bร i", + "ฤ‘รฃ chฦกi cho", + "{C:attention}#1#{} tay sแบฏp tแป›i", + }, + }, + j_mp_turtle_bean = { + name = "ฤแบญu ฤen", + text = { + "{C:attention}+#1#{} lรก giแปฏ trong tay,", + "giแบฃm ฤ‘i {C:red}#2#{} mแป—i vรกn" + }, + }, + j_mp_idol = { + name = "Thแบงn Tฦฐแปฃng", + text = { + "Mแป—i lรก {C:attention}#2# {V:1}#3#", + "ghi thรชm {X:mult,C:white} X#1# {} Nhรขn", + "khi ghi ฤ‘iแปƒm", + "{s:0.8}Lรก bร i thay ฤ‘แป•i sau mแป—i vรกn" + }, + }, + j_mp_idol_rare = { + name = "Thแบงn Tฦฐแปฃng", + text = { + "Mแป—i lรก {C:attention}#2# {V:1}#3#", + "ghi thรชm {X:mult,C:white} X#1# {} Nhรขn", + "khi ghi ฤ‘iแปƒm", + "{s:0.8}Lรก bร i thay ฤ‘แป•i sau mแป—i vรกn" + }, + }, + j_mp_ticket = { + name = "Tแบฅm Vรฉ Vร ng", + text = { + "Lรก {C:attention}Vร ng{} ฤ‘รฃ chฦกi nhแบญn", + "thรชm {C:money}$#1#{} khi ghi ฤ‘iแปƒm", + }, + }, + j_mp_ticket_experimental = { + name = "Tแบฅm Vรฉ Vร ng", + text = { + "Lรก {C:attention}Vร ng{} ฤ‘รฃ chฦกi nhแบญn", + "thรชm {C:money}$#1#{} khi ghi ฤ‘iแปƒm", + }, + }, + j_mp_baron = { + name = "Nam Tฦฐแป›c", + text = { + "Mแป—i lรก {C:attention}Giร {} giแปฏ", + "trong tay ghi thรชm", + "{X:mult,C:white} X#1# {} Nhรขn", + }, + }, + j_mp_mime = { + name = "Kแป‹ch Cรขm", + text = { + "Tรกi kรญch khแบฃ nฤƒng", + "cแปงa mแปi lรก bร i {C:attention}giแปฏ", + "{C:attention}trong tay" + }, + }, + j_mp_todo_list = { + name = "Danh Sรกch Cรดng Viแป‡c", + text = { + "Nhแบญn {C:money}$#1#{} nแบฟu {C:attention}tay poker", + "lร  {C:attention}#2#{},", + "tay poker thay ฤ‘แป•i", + "แปŸ cuแป‘i vรกn" + }, + }, j_broken = { name = "HแปŽNG", text = { @@ -348,6 +462,93 @@ return { -- "{C:inactive}(CURRENTLY {C:money}$7{C:inactive})", }, }, + j_mp_8ball_release = { + name = "Bi 8", + text = { + "Tแบกo ra mแป™t lรก {C:planet}Hร nh Tinh{}", + "nแบฟu tay bร i chแปฉa", + "{C:attention}#1#{} hoแบทc nhiแปu hฦกn lรก {C:attention}8{}", + "{C:inactive}(Phแบฃi cรณ รด trแป‘ng)", + }, + }, + j_mp_todo_list_release = { + name = "Danh Sรกch Cรดng Viแป‡c", + text = { + "Nhแบญn {C:money}$#1#{} nแบฟu {C:attention}tay poker{}", + "lร  {C:attention}#2#{},", + "tay poker thay ฤ‘แป•i", + "แปŸ cuแป‘i vรกn", + }, + }, + j_mp_swashbuckler_release = { + name = "Hแบฃo Hรกn", + text = { + "Thรชm tแป•ng giรก bรกn cแปงa cรกc", + "{C:attention}Jokers{} khรกc bรชn trรกi", + "lรก nร y vร o Nhรขn", + "{C:inactive}(Hiแป‡n tแบกi lร  {C:mult}+#1#{C:inactive} Nhรขn)", + }, + }, + j_mp_hanging_chad_release = { + name = "Phiแบฟu ฤแปฅc Lแป—", + text = { + "Tรกi kรญch hoแบกt lรก ghi ฤ‘iแปƒm", + "{C:attention}ฤ‘แบงu tiรชn{} trong tay bร i", + }, + }, + j_mp_madness_release = { + name = "ฤiรชn Loแบกn", + text = { + "Khi {C:attention}Blind{} ฤ‘ฦฐแปฃc chแปn,", + "thรชm {X:mult,C:white} X#1# {} Nhรขn", + "vร  {C:attention}phรก huแปท{} mแป™t Joker ngแบซu nhiรชn", + "{C:inactive}(Hiแป‡n tแบกi lร  {X:mult,C:white} X#2# {C:inactive} Nhรขn)" + } + }, + j_mp_vampire_release = { + name = "Ma Cร  Rแป“ng", + text = { + "Joker nร y thรชm {X:mult,C:white} X#1# {} Nhรขn", + "mแป—i {C:attention}lรก Cฦฐแปng Hoรก{} ฤ‘รฃ chฦกi ghi ฤ‘iแปƒm,", + "loแบกi bแป {C:attention}Cฦฐแปng Hoรก", + "{C:inactive}(Hiแป‡n tแบกi lร  {X:mult,C:white} X#2# {C:inactive} Nhรขn)" + } + }, + j_mp_midas_mask_release = { + name = "Mแบทt Nแบก Midas", + text = { + "Mแปi lรก {C:attention}mแบทt{} ฤ‘รฃ chฦกi", + "trแปŸ thร nh lรก {C:attention}Vร ng", + "khi ghi ฤ‘iแปƒm", + } + }, + j_mp_yorick_release = { + name = "Yorick", + text = { + "{X:mult,C:white} X#1# {} Nhรขn sau khi sแปญ dแปฅng", + "using {C:attention}#2#{} lฦฐแปฃt bแป bร i", + "{C:inactive}(Lฦฐแปฃt bแป cรฒn lแบกi: {C:attention}#3#{C:inactive})", + }, + }, + j_mp_flower_pot_release = { + name = "Bรฌnh Hoa", + text = { + "{X:mult,C:white} X#1# {} Nhรขn nแบฟu", + "tay poker chแปฉa mแป™t", + "lรก {C:diamonds}Rรด{}, lรก {C:clubs}Tรฉp{},", + "lรก {C:hearts}Cฦก{} vร  lรก {C:spades}Bรญch" + }, + }, + }, + Tarot = { + c_mp_magician_release = { + name = "Phรกp Sฦฐ", + text = { + "Cฦฐแปng hoรก {C:attention}#1#", + "lรก ฤ‘รฃ chแปn thร nh", + "{C:attention}#2#" + } + }, }, Planet = { c_mp_asteroid = { @@ -423,8 +624,8 @@ return { text = { "Bแบฏt ฤ‘แบงu trแบญn vแป›i {C:spectral,T:c_medium}Thแบงy ฤแป“ng", "vร  {C:attention,T:v_clearance_sale}Bรกn Hแบก Giรก", - "Sแป‘ dฦฐ tแป‘i ฤ‘a chแป‰", - "cรฒn lแบกi {C:money}$50", + "Sแป‘ dฦฐ tแป‘i ฤ‘a chแป‰ cรฒn lแบกi", + "{C:money}$50{} + {C:attention}tแป•ng tiแปn lรฃi tแป‘i ฤ‘a", }, }, b_mp_violet = { @@ -444,8 +645,95 @@ return { "แปŸ cuแป‘i {C:attention}shop", }, }, + b_mp_white = { + name = "Bแป™ Bร i Trแบฏng", + text = { + "Cรณ thแปƒ xem bแป™ bร i vร  Joker", + "hiแป‡n tแบกi cแปงa {X:purple,C:white}ฤแป‘i Thแปง'{}", + "{C:inactive}(Lร m mแป›i sau mแป—i blind ฤแป‘i ฤแบงu){}", + }, + }, }, Other = { + mp_sticker_balanced = { + name = "Cรขn Bแบฑng", + text = { + "Lรก bร i nร y ฤ‘รฃ ฤ‘ฦฐแปฃc tรกi cรขn bแบฑng", + }, + }, + mp_sticker_balanced_j_mp_hanging_chad = { + name = "Cรขn Bแบฑng", + text = { + "Tรกi kรญch hoแบกt {C:attention}2{} lรก bร i", + "thay vรฌ lรก ฤ‘แบงu tiรชn 2 lแบงn", + }, + }, + mp_sticker_balanced_j_mp_ticket = { + name = "Cรขn Bแบฑng", + text = { + "TrแปŸ thร nh {C:green}รt Phแป• Biแบฟn{}", + "Khรดng cรฒn yรชu cแบงu bร i {C:attention}Vร ng{}", + }, + }, + mp_sticker_balanced_j_mp_seltzer = { + name = "Cรขn Bแบฑng", + text = { + "Cรณ hiแป‡u lแปฑc trong {C:attention}8{} tay bร i", + "thay vรฌ {C:attention}10{}", + }, + }, + mp_sticker_balanced_j_mp_turtle_bean = { + name = "Cรขn Bแบฑng", + text = { + "{C:attention}+4{} lรก giแปฏ trong tay", + "thay vรฌ {C:attention}+5{}", + }, + }, + mp_sticker_balanced_j_mp_baron = { + name = "Cรขn Bแบฑng", + text = { + "TrแปŸ thร nh {C:green}รt Phแป• Biแบฟn{} ({C:money}$5{})", + "thay vรฌ {C:red}Hiแบฟm{} ({C:money}$8{})", + }, + }, + mp_sticker_balanced_j_mp_mime = { + name = "Cรขn Bแบฑng", + text = { + "TrแปŸ thร nh {C:red}Hiแบฟm{} ({C:money}$8{})", + "thay vรฌ {C:green}รt Phแป• Biแบฟn{} ({C:money}$5{})", + }, + }, + mp_sticker_balanced_j_mp_todo_list = { + name = "Cรขn Bแบฑng", + text = { + "Nhแบญn {C:money}$5{} thay vรฌ {C:money}$4{}", + "Tay poker ฤ‘ฦฐแปฃc chแปn tแปซ {C:attention}tแบฅt cแบฃ{} cรกc tay poker,", + "dรน ฤ‘ฦฐแปฃc khรกm phรก hay chฦฐa", + }, + }, + mp_sticker_balanced_j_mp_bloodstone = { + name = "Cรขn Bแบฑng", + text = { + "Khi {C:attention}ฤแป‘i ฤแบงu{}, xรกc suแบฅt {C:green}1 trรชn 2{}", + "ฤ‘แบฟn tแปซ mแป™t {C:attention}chuแป—i cแป‘ ฤ‘แป‹nh{}", + "ฤ‘ฦฐแปฃc tแบฅt cแบฃ ngฦฐแปi chฦกi sแปญ dแปฅng", + "vร  {C:attention}tรกi sแปญ dแปฅng{} cho mแปi tay bร i vรกn ฤ‘รณ", + }, + }, + mp_sticker_balanced_c_mp_ouija_standard = { + name = "Balanced", + text = { + "Phรก hแปงy {C:attention}3{} lรก bร i thay vรฌ", + "biแบฟn ฤ‘แป•i mแปi lรก vร  loแบกi bแป hiแป‡u แปฉng", + "{C:attention}-1{} lรก giแปฏ trong tay", + }, + }, + mp_sticker_balanced_m_gold = { + name = "Cรขn Bแบฑng", + text = { + "Nhแบญn {C:money}$4{} thay vรฌ {C:money}$3{}", + }, + }, current_nemesis = { name = "ฤแป‘i Thแปง", text = { @@ -491,6 +779,26 @@ return { }, }, }, + Spectral = { + c_mp_ouija_standard = { + name = "Cแบงu Cฦก", + text = { + "Phรก hแปงy {C:attention}#1#{} lรก bร i ngแบซu nhiรชn,", + "sau ฤ‘รณ biแบฟn ฤ‘แป•i toร n bแป™ lรก bร i cรฒn lแบกi", + "trong tay thร nh mแป™t {C:attention}bแบญc{}", + "ngแบซu nhiรชn" + }, + }, + c_mp_ectoplasm_sandbox = { + name = "Ectoplasm", + text = { + "Add {C:dark_edition}Negative{} to", + "a random {C:attention}Joker,", + "Randomly apply one of:", + "{C:red}-1{} hand, {C:red}-1{} discard, or {C:red}-1{} hand size", + }, + }, + }, }, misc = { labels = { @@ -498,8 +806,13 @@ return { }, dictionary = { b_singleplayer = "Chฦกi ฤฦกn", + b_sp_with_ruleset = "Luyแป‡n Tแบญp", b_join_lobby = "Vร o Phรฒng", b_join_lobby_clipboard = "Vร o Tแปซ Bแป™ Nhแป› ฤแป‡m", + k_practice_collection_hint = "Suแปตt... ngฦฐฦกi chแปn gรฌ ta cho nแบฅy. Khรดng hแปi nhiแปu!", + k_unlimited_slots = "Vรด Hแบกn ร” Trแป‘ng", + k_edition_cycling = "Thay ฤแป•i แบคn Bแบฃn (Q)", + k_practice_options = "Tรนy chแปn Luyแป‡n Tแบญp...", b_return_lobby = "Quay lแบกi Phรฒng", b_reconnect = "Kแบฟt nแป‘i lแบกi", b_create_lobby = "Tแบกo Phรฒng", @@ -534,6 +847,7 @@ return { b_opts_lives = "Mแบกng", b_opts_multiplayer_jokers = "Cho phรฉp Lรก Chฦกi Mแบกng", b_opts_player_diff_deck = "Bแป™ bร i ngฦฐแปi chฦกi khรกc nhau", + b_opts_random_loadout = "Bแป™ bร i vร  mแปฉc cฦฐแปฃc ngแบซu nhiรชn", b_opts_normal_bosses = "Kรญch hoแบกt hiแป‡u แปฉng Boss Blind", b_opts_timer = "Cho Phรฉp ฤแบฟm Ngฦฐแปฃc", b_opts_disable_preview = "Tแบฏt Xem Trฦฐแป›c ฤiแปƒm", @@ -559,10 +873,14 @@ return { k_has_multiplayer_content = "Cรณ Vแบญt Phแบฉm Chฦกi Mแบกng", k_forces_lobby_options = "ร‰p Tuแปณ Chแป‰nh Phรฒng", k_forces_gamemode = "ร‰p Chแบฟ ฤแป™ Chฦกi", + k_may_roll_stickers = "Cรณ thแปƒ Gieo lแบกi Nhรฃn", k_values_are_modifiable = "* Giรก trแป‹ tuแปณ biแบฟn", k_rulesets = "Luแบญt Lแป‡", k_gamemodes = "Chแบฟ ฤแป™ Chฦกi", + k_matchmaking = "Ghรฉp Trแบญn", k_competitive = "Tranh ฤแบฅu", + k_tournament = "Giแบฃi ฤแบฅu", + k_custom = "Tรนy Chแปn", k_other = "Khรกc", k_battle = "Cuแป™c Chiแบฟn", k_challenge = "Thแปญ Thรกch", @@ -574,8 +892,9 @@ return { k_wait_enemy = "ฤang chแป ฤ‘แป‘i thแปง hoร n thร nh vรกn...", k_wait_enemy_reach_this_blind = "ฤang chแป ฤ‘แป‘i thแปง ฤ‘แบฟn blind nร y...", k_lives = "Mแบกng", + k_skips = "Bแป Qua", k_lost_life = "Mแบฅt 1 mแบกng", - k_total_lives_lost = " Mแบกng ฤ‘รฃ mแบฅt ($4 mแป—i mแบกng)", + k_total_lives_lost = " Mแบกng ฤ‘รฃ mแบฅt", k_attrition_name = "Hao Mรฒn", k_enter_lobby_code = "Nhแบญp Mรฃ Phรฒng", k_paste = "Dรกn Tแปซ Bแป™ Nhแป› ฤแป‡m", @@ -591,6 +910,7 @@ return { k_set_name = "ฤแบทt tรชn cแปงa bแบกn trong menu chรญnh! (Mod > Multiplayer > Tuแปณ Chแปn)", k_mod_hash_warning = "Ngฦฐแปi chฦกi ฤ‘ang cรณ mod hoแบทc phiรชn bแบฃn mod khรกc nhau, cรณ thแปƒ gรขy vแบฅn ฤ‘แป khi chฦกi!", k_steamodded_warning = "Ngฦฐแปi chฦกi ฤ‘ang cรณ phiรชn bแบฃn Steamodded khรกc nhau. Cรณ thแปƒ gรขy chรชnh lแป‡ch giแป‘ng.", + k_mp_version_warning = "Ngฦฐแปi chฦกi ฤ‘ang cรณ phiรชn bแบฃn Multiplayer khรกc nhau! Giแป‘ng vร  joker sแบฝ khรดng khแป›p nhau - cแบญp nhแบญt cรนng phiรชn bแบฃn nhรฉ.", k_warning_unlock_profile = "Hแป“ sฦก bแบกn ฤ‘ang chฦกi chฦฐa ฤ‘ฦฐแปฃc mแปŸ khoรก toร n bแป™. Nแบฟu ฤ‘รขy lร  trแบญn xแบฟp hแบกng/giแบฃi ฤ‘แบฅu, hรฃy tแบกo hแป“ sฦก mแป›i vร  nhแบฅn mแปŸ khoรก toร n bแป™ trong cร i ฤ‘แบทt hแป“ sฦก", k_warning_nemesis_unlock = "ฤแป‘i thแปง cแปงa bแบกn ฤ‘ang chฦกi trรชn hแป“ sฦก chฦฐa ฤ‘ฦฐแปฃc mแปŸ khoรก toร n bแป™. Hรฃy hฦฐแป›ng dแบซn hแป tแบกo hแป“ sฦก mแป›i vร  nhแบฅn mแปŸ khoรก toร n bแป™ trong cร i ฤ‘แบทt hแป“ sฦก", k_warning_no_order = "Mแป™t ngฦฐแปi chฦกi ฤ‘ang bแบญt tรญch hแปฃo The Order trong khi ngฦฐแปi kia thรฌ khรดng. ฤiแปu nรฃy sแบฝ khiแบฟn cรนng mแป™t giแป‘ng bแป‹ khรกc nhau.", @@ -618,6 +938,28 @@ return { k_opts_showdown_starting_antes = "Showdown Bแบฏt ฤ‘แบงu แปŸ Ante", k_opts_pvp_timer_increment = "Tฤƒng ฤแบฟm Ngฦฐแปฃc", k_opts_pvp_countdown_seconds = "Giรขy ฤแบฟm Ngฦฐแปฃc ฤแป‘i ฤแบงu", + k_opts_modifier_timer = "Thi Triแปƒn ฤแบฟm Ngฦฐแปฃc", -- feel free to fix this text if you don't like huy, i can't find a better word for this + k_experimental_modifiers_timers = { + "- Mแบทc ฤ‘แป‹nh: ฤแบฟm ngฦฐแปฃc {C:chips}150{} {C:inactive}(1x){} giรขy", + " ", + "- Khรดng Hiแป‡u แปฉng: ฤแบฟm ngฦฐแปฃc {C:chips}100{} {C:inactive}(0.67x){} giรขy {C:attention}trแปซ hiแป‡u แปฉng{}", + " ", + "- รp lแปฑc: ฤแบฟm ngฦฐแปฃc {C:chips}300{} {C:inactive}(2x){} giรขy {C:attention}trแปซ hiแป‡u แปฉng{},", + " bแบฏt ฤ‘แบงu {C:attention}ngay lแบญp tแปฉc{}", + " ", + "- รp lแปฑc+: Giแป‘ng {C:attention}รp lแปฑc{} thรชm {C:chips}15{} giรขy cho mแป—i tay bร i ฤ‘รฃ chฦกi", + }, + b_opts_modifier_pvp_timer = "ฤแบฟm Ngฦฐแปฃc trong ฤแป‘i ฤแบงu", + k_experimental_modifiers_pvp_timer = { + "- ฤแบฟm ngฦฐแปฃc ฤ‘ฦฐแปฃc sแปญ dแปฅng trong cรกc vรกn {C:mult}ฤแป‘i ฤแบงu{}.", + " {C:chips}90{} giรขy thรชm {C:chips}15{} giรขy cho mแป—i tay bร i ฤ‘รฃ chฦกi {C:attention}trแปซ hiแป‡u แปฉng{}.", + " Chแป‰ cรณ thแปƒ \"ฤ‘แบฟm ngฦฐแปฃc\" ฤ‘แป‘i thแปง khi bแบกn cรณ ฤ‘iแปƒm {C:attention}cao hฦกn{}" + }, + k_experimental_modifiers_smallworld = { + "- {C:attention}75%{} joker, lรก tiรชu thแปฅ, phiแบฟu vร  nhรฃn", + "bแป‹ cแบฅm ngแบซu nhiรชn mแป—i trแบญn ฤ‘แบฅu.", + "- Hiแป‡u แปฉng {C:attention}ร”ng Bแบงu{} luรดn ฤ‘ฦฐแปฃc kรญch hoแบกt.", + }, k_bl_life = "Sแป‘ng", k_bl_or = "hoแบทc", k_bl_death = "Chแบฟt", @@ -630,17 +972,23 @@ return { k_sandbox_description = "Nhฦฐ tiรชu chuแบฉn nhฦฐng mแบฅy lรก bร i thรญch tรกm chuyแป‡n vแป›i nแป‘c cร  phรช nhiแปu quรก.", k_vanilla = "Cฦก Bแบฃn", k_vanilla_description = "Thแปƒ thแปฉc nร y bแป hแบฟt vแบญt phแบฉm Chฦกi Mแบกng,\ncho phรฉp bแบกn chฦกi game ฤ‘รบng theo thiแบฟt kแบฟ cแปงa nรณ.\n\nThแปƒ thแปฉc nร y vแบซn bao gแป“m tรญnh nฤƒng Chฦกi Mแบกng nhฦฐ ฤ‘แบฟm ngฦฐแปฃc.\n\n(Cรณ thแปƒ tแบฏt แปŸ Tuแปณ Chแป‰nh Phรฒng)", - k_blitz = "Blitz", - k_blitz_description = "Thแปƒ thแปฉc nร y bao gแป“m lรก bร i vร  tรญnh nฤƒng khuyแบฟn khรญch chฦกi nhanh vร \ndรนng thแปi gian nhฦฐ mแป™t mรณn tร i nguyรชn.\n\nMแป™t sรณ lรก bร i ฤ‘ฦฐแปฃc cรขn bแบฑng lแบกi ฤ‘แปƒ phรน hแปฃp vแป›i meta Chฦกi mแบกng:\n- Lร m lแบกi Phiแบฟu ฤแปฅc Lแป—\n- Loแบกi bแป Cรดng Lรฝ\n- Lร m lแบกi Lรก Kรญnh\n\n(xem mแปฅc bแป‹ cแบฅm vร  lร m lแบกi ฤ‘แปƒ biแบฟt thรชm thรดng tin)", + k_blitz = "Tiรชu Chuแบฉn", + k_blitz_description = "Thแปƒ thแปฉc cรขn bแบฑng cแปงa Multiplayer,\nvแป›i cรกc lรก Joker Multiplayer vร  cรกc cรขn bแบฑng\ncรนng toร n quyแปn ฤ‘iแปƒu khiแปƒn tรนy chแป‰nh phรฒng cแปงa bแบกn\n\n(xem mแปฅc bแป‹ cแบฅm vร  lร m lแบกi ฤ‘แปƒ biแบฟt thรชm thรดng tin)", k_traditional = "Truyแปn Thแป‘ng", k_traditional_description = "Thแปƒ thแปฉc nร y loแบกi bแป cฦก chแบฟ Chฦกi Mแบกng dรนng thแปi gian lร m tร i nguyรชn.\n\nThแปƒ thแปฉc nร y cho phรฉp bแบกn chฦกi vแป›i vแบญt phแบฉn Chฦกi Mแบกng,\ntrong khi vแบซn giแปฏ vแปฏng tรญnh chiแบฟn thuแบญt.\n\nMแป™t sรณ lรก bร i ฤ‘ฦฐแปฃc cรขn bแบฑng lแบกi ฤ‘แปƒ phรน hแปฃp vแป›i meta Chฦกi mแบกng:\n- Lร m lแบกi Phiแบฟu ฤแปฅc Lแป—\n- Loแบกi bแป Cรดng Lรฝ\n- Lร m lแบกi Lรก Kรญnh\n\n(xem mแปฅc bแป‹ cแบฅm vร  lร m lแบกi ฤ‘แปƒ biแบฟt thรชm thรดng tin)", k_majorleague = "Giแบฃi Chรญnh", k_majorleague_description = "ฤรขy lร  ฤ‘iแปu lแป‡ chรญnh thแปฉc cho Giแบฃi Chรญnh Balatro.\n\nThแปƒ thแปฉc nร y giแป‘ng thแปƒ thแปฉc Cฦก Bแบฃn vแป›i mแป™t sแป‘ ngoแบกi lแป‡::\n- Bแบกn phแบฃi tแบฏt Tรญch Hแปฃp The Order\n- Thแปi gian ฤ‘แบฟm ngฦฐแปฃc la 180 giรขy\n- Lแบงn ฤ‘แบงu thแปi gian chแบกm mแป‘c 0 giรขy sแบฝ khรดng bแป‹ mแบฅt mแบกng", k_minorleague = "Giแบฃi Phแปฅ", k_minorleague_description = "ฤรขy lร  ฤ‘iแปu lแป‡ chรญnh thแปฉc cho Giแบฃi Phแปฅ Balatro.\n\nThแปƒ thแปฉc nร y giแป‘ng thแปƒ thแปฉc Cฦก Bแบฃn vแป›i mแป™t sแป‘ ngoแบกi lแป‡::\n- You phแบฃi bแบญt Tรญch Hแปฃp The Order\n- Thแปi gian ฤ‘แบฟm ngฦฐแปฃc la 180 giรขy\n- Lแบงn ฤ‘แบงu thแปi gian chแบกm mแป‘c 0 giรขy sแบฝ khรดng bแป‹ mแบฅt mแบกng", - k_ranked = "Xแบฟp Hแบกng", - k_ranked_description = "ฤรขy lร  ฤ‘iแปu lแป‡ chรญnh thแปฉc cho Xแบฟp Hแบกng cแปงa Balatro Multiplayer.\n\nThแปƒ thแปฉc nร y giแป‘ng thแปƒ thแปฉc Blitz vแป›i mแป™t sแป‘ ngoแบกi lแป‡:\n- Bแบกn phแบฃi bแบญt Tรญch Hแปฃp The Order\n- Bแบกn phแบฃi dรนng phiรชn bแบฃn Steamodded ฤ‘ฦฐแปฃc khuyแบฟn nghแป‹", - k_badlatro = "Badlatro", + k_standard_ranked = "Xแบฟp Hแบกng Tiรชu Chuแบฉn", + k_standard_ranked_description = "ฤรขy lร  ฤ‘iแปu lแป‡ chรญnh thแปฉc cho Xแบฟp Hแบกng cแปงa Balatro Multiplayer.\n\nThแปƒ thแปฉc nร y giแป‘ng thแปƒ thแปฉc Tiรชu Chuแบฉn vแป›i mแป™t sแป‘ ngoแบกi lแป‡:\n- Bแบกn phแบฃi bแบญt Tรญch Hแปฃp The Order\n- Bแบกn phแบฃi dรนng phiรชn bแบฃn Steamodded ฤ‘ฦฐแปฃc khuyแบฟn nghแป‹", + k_legacy_ranked = "Xแบฟp Hแบกng Cแป• ฤiแปƒn", + k_legacy_ranked_description = "ฤรขy lร  ฤ‘iแปu lแป‡ tranh ฤ‘แบฅu ฤ‘รฃ ฤ‘ฦฐแปฃc tแป‘i giแบฃn.\n\nThแปƒ thแปฉc nร y khรดng cรณ sแปฑ thay ฤ‘แป•i gรฌ cแปงa Multiplayer\ntrแปซ Lรก Kรญnh cรนng cรกc tรนy chแป‰nh cแป‘ ฤ‘แป‹nh:\n- Chแบฟ ฤ‘แป™ Hao Mรฒn\n- Bแบกn phแบฃi bแบญt Tรญch Hแปฃp The Order\n- Bแบกn phแบฃi dรนng phiรชn bแบฃn Steamodded ฤ‘ฦฐแปฃc khuyแบฟn nghแป‹", + k_experimental_standard = "Thแปญ Nghiแป‡m (Tiรชu Chuแบฉn)", + k_experimental_description = "ฤรขy lร  ฤ‘iแปu lแป‡ thแปญ nghiแป‡m Tiรชu Chuแบฉn.\n\nCรกc bแบฃn cรขn bแบฑng sแบฝ liรชn tแปฅc ฤ‘ฦฐแปฃc thแปญ nghiแป‡m tแบกi ฤ‘รขy\ntrฦฐแป›c khi ฤ‘ฦฐa vร o ฤ‘iแปu lแป‡ Tiรชu Chuแบฉn.\n\n(xem mแปฅc bแป‹ cแบฅm vร  lร m lแบกi ฤ‘แปƒ biแบฟt thรชm thรดng tin)", + k_experimental_legacy = "Thแปญ Nghiแป‡m (Cแป• ฤiแปƒn)", + k_experimental_legacy_description = "ฤรขy lร  ฤ‘iแปu lแป‡ mang รฝ kiแบฟn cรก nhรขn vแป›i Xแบฟp Hแบกng Truyแปn Thแป‘ng.\n\nGiแบฃm sแปฉc mแบกnh Lรก Kรญnh, lร m lแบกi Phiแบฟu ฤแปฅc Lแป—, cแบฅm Cรดng Lรฝ,\nCแป bแบกc lร  bรกc thแบฑng ฤ‘แบงn.", + k_badlatro = "Badlatro", k_badlatro_description = "Mแป™t thแปƒ thแปฉc tuแบงn ฤ‘ฦฐแปฃc thiแบฟt kแบฟ bแปŸi @dr_monty_the_snek trong mรกy chแปง ฤ‘รฃ ฤ‘ฦฐแปฃc thรชm vฤฉnh viแป…n vร o mod.\n\nThแปƒ thแปฉc nร y cแบฅm 48 joker, lรก tiรชu thแปฅ, nhรฃn bแป qua, v.v...", k_attrition = "Hao Mรฒn", k_attrition_description = "Sau Ante ฤ‘แบงu tiรชn, mแปi boss blind ฤ‘แปu lร  Blind ฤแป‘i ฤแบงu. Khรดng chuแบฉn bแป‹ gรฌ hแบฟt. Thแปƒ thแปฉc nร y รฉp bแบกn phแบฃi sแบตn sร ng ngay tแปซ ฤ‘แบงu.", @@ -651,8 +999,18 @@ return { k_weekly = "Giแบฃi Tuแบงn", k_weekly_description = "Mแป™t thแปƒ thแปฉc ฤ‘แบทc biแป‡t ฤ‘ฦฐแปฃc thay ฤ‘แป•i sau mแป—i 1 hoแบทc 2 tuแบงn. Cรณ vแบป ta phแบฃi tแปฑ khรกm phรก luแบญt lร  gรฌ rแป“i! Hiแป‡n tแบกi lร : ", k_smallworld = "Small World", - k_smallworld_description = "Mแป™t thแปƒ thแปฉc siรชu thแปญ nghiแป‡m, 3/4 sแป‘ vแบญt phแบฉm trong game\nsแบฝ bแป‹ cแบฅm ngแบซu nhiรชn vรฌ lรญ do nร o ฤ‘รณ", - k_destabilized = "Phi แป”n ฤแป‹nh", + k_smallworld_description = "Thแบฟ giแป›i nhแป bรฉ mร , ฤ‘รบng khรดng?\n3/4 sแป‘ vแบญt phแบฉm trong game sแบฝ bแป‹ cแบฅm ngแบซu nhiรชn,\nvร  sแบฝ ฤ‘ฦฐแปฃc thay thแบฟ thร nh cรกc vแบญt phแบฉm cรฒn lแบกi.\nCรณ tแป“n tแบกi Trรนng Lแบทp", + k_speedlatro = "Speedlatro", + k_speedlatro_description = "147 giรขy, phรณng nhฦฐ bay tแป›i ฤแป‘i ฤแบงu.\nฤ‚n Mร y thoแบฃi mรกi nhรฉ mแบฅy ฤ‘แปฉa :>", + k_chaos = "Hแป—n Loแบกn", + k_chaos_description = "Gi gแป‰ gรฌ gi, cรกi gรฌ cลฉng cรณ.\n\nKแบฟt hแปฃp Tiรชu Chuแบฉn, Small World, Sandbox/nvร  ฤ‘แบฟm ngฦฐแปฃc cแปงa Speedlatro vร o mแป™t thแปƒ thแปฉc. Chรบc may mแบฏn.", + k_release = "Phiรชn Bแบฃn Ra Mแบฏt", + k_release_description = "Allan thรดng tin ฤ‘รขu?", + k_mp_ruleset_tab_general = "Cฦก Bแบฃn", + k_mp_ruleset_tab_tournaments = "Giแบฃi ฤแบฅu", + k_mp_ruleset_tab_experimental = "Thแปญ Nghiแป‡m", + k_cost_up = "Tฤƒng Giรก", + k_destabilized = "Phi แป”n ฤแป‹nh", k_oops_ex = "รši!", k_asteroids = "Thiรชn Thแบกch", k_amount_short = "Lฦฐแปฃg", @@ -663,16 +1021,21 @@ return { k_your_jokers = "Joker cแปงa bแบกn", k_nemesis_deck = "Bแป™ bร i cแปงa ฤแป‘i Thแปง", k_your_deck = "Bแป™ bร i cแปงa bแบกn", + k_customization = "Tรนy chแป‰nh", k_the_order_credit = "*Cรดng nhแบญn cho @MathIsFun_", k_the_order_integration_desc = "Vรก quy trรฌnh tแบกo lรก bร i ฤ‘แปƒ khรดng cรฒn bแป‹ phแปฅ thuแป™c vร o ante vร  dรนng mแป™t tแบญp hแปฃp duy nhแบฅt cho mแป—i loแบกi/ฤ‘แป™ hiแบฟm", k_preview_credit = "*Cรดng nhแบญn cho @Fantom, @Divvy", k_preview_integration_desc = "Cรกi nร y sแบฝ bแบญt xem trฦฐแป›c ฤ‘iแปƒm trฦฐแป›c khi chฦกi mแป™t tay bร i", k_requires_restart = "*Yรชu cแบงu khแปŸi ฤ‘แป™ng lแบกi ฤ‘แปƒ cรณ hiแป‡u lแปฑc", + k_cocktail_select = "Chแปn bแป™ bร i ฤ‘แปƒ thรชm vร o", + k_cocktail_shiftclick = "Shift-click ฤ‘แปƒ รกnh kim bแป™ bร i, bแป™ bร i รกnh kim sแบฝ luรดn ฤ‘ฦฐแปฃc chแปn", + k_cocktail_rightclick = "Chuแป™t Phแบฃi ฤ‘แปƒ chแปn tแบฅt cแบฃ", k_new_weekly_ruleset = "Mแป™t thแปƒ thแปฉc tuแบงn mแป›i ฤ‘ang khแบฃ dแปฅng!", k_currently_colon = "Hiแป‡n tแบกi lร : ", k_sync_locally = "ฤแป“ng bแป™ cแปฅc bแป™ (Khแปi ฤ‘แป™ng lแบกi game)", k_bans = "Cแบฅm", k_reworks = "Thรชm vร o/Sแปญa lแบกi", + k_edit = "Chแป‰nh sแปญa", k_ruleset_disabled_the_order_required = "Bแบฏt Buแป™c dรนng The Order", k_ruleset_disabled_the_order_banned = "Cแบฅm dรนng The Order", k_ruleset_not_found = "Thแปƒ thแปฉc chฦฐa rรต", @@ -683,6 +1046,17 @@ return { "Vแป‹ trรญ", "ฤแป‘i Thแปง", }, + k_ghost_replays = "Lแป‹ch sแปญ ฤ‘แบฅu", + k_no_ghost_replays = "Chฦฐa cรณ lแป‹ch sแปญ ฤ‘แบฅu", + k_ghost = "Bรณng", + k_hide_mp_content = "แบจn nแป™i dung Multiplayer*", + k_applies_singleplayer_vanilla_rulesets = "*รp dแปฅng vแป›i Chฦกi ฤฦกn vร  cรกc thแปƒ thแปฉc cฦก bแบฃn", + k_timer_sfx = "Hiแป‡u แปฉng รขm thanh ฤแบฟm Ngฦฐแปฃc", + ml_mp_timersfx_opt = { + "Bแบญt", + "Mแป™t lแบงn mแป—i Ante", + "Tแบฏt", + }, ml_mp_kofi_message = { "Mod vร  mรกy chแปง nร y ฤ‘ฦฐแปฃc", "lแบญp trรฌnh vร  bแบฃo trรฌ bแปŸi", @@ -693,10 +1067,19 @@ return { "T.Tin", "Phรฒng", }, + ml_mp_modifier_timer_opt = { + "Mแบทc ฤ‘แป‹nh", + "Khรดng Hiแป‡u แปฉng", + "รp lแปฑc", + "รp lแปฑc+", + }, + k_sc_title = "PHรM TแบฎT", + k_sc_hint = "Chแปn phรญm hoแบทc thแบฃ Tab ฤ‘แปƒ tแบฏt", + b_sc_choose_deck = "Chแปn Bแป™ Bร i/Mแปฉc Cฦฐแปฃc", loc_ready = "Sแบตn sร ng ฤแป‘i ฤแบงu", - loc_selecting = "ฤang chแปn Blind", - loc_shop = "ฤang ฤ‘i chแปฃ", - loc_playing = "ฤang ฤ‘รกnh ", + loc_selecting = "ฤang chแปn", + loc_shop = "ฤang ฤ‘i chแปฃ trฦฐแป›c", + loc_playing = "ฤang ฤ‘รกnh", }, v_dictionary = { a_mp_art = { @@ -773,6 +1156,9 @@ return { c_mp_lets_go_gambling = "Cแป Bแบกc Level MAX", c_mp_speed = "Siรชu Tแป‘c", c_mp_balancing_act = "Hแป“i Cรขn Bแบฑng", + c_mp_bacon = "Bฤƒng Xanh", + c_mp_eeeee = "EEEEE", + c_mp_shared_pockets = "Chung Tรบi", }, }, -} +} \ No newline at end of file diff --git a/lovely/TheOrder.toml b/lovely/TheOrder.toml index 67bca2a2c..cd4584f93 100644 --- a/lovely/TheOrder.toml +++ b/lovely/TheOrder.toml @@ -203,4 +203,20 @@ if MP.should_use_the_order() then _poker_hands = MP.sorted_hand_list(self.ability.to_do_poker_hand) end ''' -match_indent = true \ No newline at end of file +match_indent = true + +# STEAMODDED BUG - fix for certificate calling pseudorandom for no reason +[[patches]] +[patches.pattern] +target = '''=[SMODS _ "src/utils.lua"]''' +pattern = ''' +local seal_poll = pseudorandom(pseudoseed(key or 'stdseal'..G.GAME.round_resets.ante)) +if seal_poll > 1 - (type_weight*mod / total_weight) or guaranteed then -- is a seal generated +''' +position = 'at' +payload = ''' +local seal_poll = guaranteed and 1 or pseudorandom(pseudoseed(key or 'stdseal'..G.GAME.round_resets.ante)) +if seal_poll > 1 - (type_weight*mod / total_weight) then -- is a seal generated +''' +match_indent = true +times = 1 \ No newline at end of file diff --git a/lovely/shared_area.toml b/lovely/shared_area.toml index f142500f3..19d223c0f 100644 --- a/lovely/shared_area.toml +++ b/lovely/shared_area.toml @@ -13,7 +13,7 @@ payload = '''if MP.LOBBY.code then 0, CAI.consumeable_H + 0.3, CAI.consumeable_W / 2, CAI.consumeable_H, - {card_limit = 0, type = 'joker', highlight_limit = 1}) + {card_limit = 0, type = 'joker', highlight_limit = 1, fixed_limit = true}) elseif MP.shared then MP.shared:remove() MP.shared = nil @@ -29,6 +29,7 @@ position = 'after' payload = '''if MP.shared then MP.shared.T.x = G.consumeables.T.x + (G.consumeables.T.w / 2) MP.shared.T.y = G.consumeables.T.y + G.consumeables.T.h + 0.4 + MP.shared:hard_set_T() end ''' match_indent = true diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 679e9a204..88f3927fc 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -312,6 +312,12 @@ end local function action_start_blind(p) local first_player = p.firstPlayer + -- Reset the stored opponent score each blind so the first frame after we + -- play (which lifts the "???" mask) shows 0, not last blind's stale score. + MP.GAME.enemy.score = MP.INSANE_INT.empty() + MP.GAME.enemy.score_text = "0" + -- Re-mask the opponent's hands until the first enemyInfo of the new blind. + MP.GAME.enemy.info_received = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false MP.GAME.timer_started = false @@ -418,6 +424,8 @@ local function action_enemy_info(p) MP.GAME.enemy.hands = hands_left MP.GAME.enemy.skips = skips MP.GAME.enemy.lives = lives + -- We've now heard from the opponent this blind: unmask their hands count. + MP.GAME.enemy.info_received = true if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end end @@ -796,7 +804,7 @@ function G.FUNCS.load_end_game_jokers() 0, 5 * G.CARD_W, G.CARD_H, - { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1 } + { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1, fixed_limit = true } ) return end @@ -1443,13 +1451,15 @@ function MP.register_action(name, cb) HANDLERS[name] = cb end +local network_to_ui_channel = love.thread.getChannel("networkToUi") + local game_update_ref = Game.update ---@diagnostic disable-next-line: duplicate-set-field function Game:update(dt) game_update_ref(self, dt) repeat - local msg = love.thread.getChannel("networkToUi"):pop() + local msg = network_to_ui_channel:pop() if msg then -- horribly messy catch if string.sub(msg, 1, 1) == "a" then @@ -1464,28 +1474,34 @@ function Game:update(dt) return end - local parsedAction = json.decode(msg) - - if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then - local log = string.format("Client got %s message: ", parsedAction.action) - for k, v in pairs(parsedAction) do - if parsedAction.action == "startGame" and k == "seed" then - last_game_seed = v - else - log = log .. string.format(" (%s: %s) ", k, v) - end - end - if - (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") - and last_game_seed - then - log = log .. string.format(" (seed: %s) ", last_game_seed) - end - sendTraceMessage(log, "MULTIPLAYER") - end - - local handler = HANDLERS[parsedAction.action] - if handler then handler(parsedAction) end + local ok, parsedAction = pcall(json.decode, msg) + if ok then + if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then + local log = string.format("Client got %s message: ", parsedAction.action) + for k, v in pairs(parsedAction) do + if parsedAction.action == "startGame" and k == "seed" then + last_game_seed = v + else + log = log .. string.format(" (%s: %s) ", k, v) + end + end + if + (parsedAction.action == "receiveEndGameJokers" or parsedAction.action == "stopGame") + and last_game_seed + then + log = log .. string.format(" (seed: %s) ", last_game_seed) + end + sendTraceMessage(log, "MULTIPLAYER") + end + + local handler = HANDLERS[parsedAction.action] + if handler then handler(parsedAction) end + else + sendWarnMessage( + "Invalid server response: " .. msg, + "MULTIPLAYER" + ) + end end until not msg end diff --git a/objects/challenges/bacon.lua b/objects/challenges/bacon.lua index 532b81f3a..16e6b6c76 100644 --- a/objects/challenges/bacon.lua +++ b/objects/challenges/bacon.lua @@ -43,7 +43,7 @@ SMODS.Challenge({ "p_arcana_mega_2", }, }, - } + }, }, apply = function(self) @@ -54,4 +54,4 @@ SMODS.Challenge({ unlocked = function(self) return true end, -}) \ No newline at end of file +}) diff --git a/objects/challenges/balancing_act.lua b/objects/challenges/balancing_act.lua index 10ccada74..cbd3b148b 100644 --- a/objects/challenges/balancing_act.lua +++ b/objects/challenges/balancing_act.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/score_instability.lua (via rules.custom -> mp_score_instability). SMODS.Challenge({ key = "balancing_act", rules = { @@ -13,68 +14,3 @@ SMODS.Challenge({ return true end, }) - --- put hook here ig -local bte_ref = Back.trigger_effect -function Back:trigger_effect(args) - if G.GAME.modifiers.mp_score_instability and args.context == "final_scoring_step" then -- me when i copy plasma deck - local diff = args.chips - args.mult - if diff > 0 then - diff = math.min(diff, args.mult - 1) - elseif diff < 0 then - diff = math.max(diff, -args.chips) - end - args.chips = args.chips + diff - args.mult = args.mult - diff - update_hand_text({ delay = 0 }, { mult = args.mult, chips = args.chips }) - - G.E_MANAGER:add_event(Event({ - func = function() - local text = localize("k_destabilized") - play_sound("timpani", 0.5 / 1.5, 0.4) - play_sound("timpani", 0.5, 0.5) - play_sound("timpani", 0.5 * 1.5, 0.6) - play_sound("tarot1", 1.5) - ease_colour(G.C.UI_CHIPS, G.C.PERISHABLE) - ease_colour(G.C.UI_MULT, G.C.ETERNAL) - attention_text({ - scale = 1.4, - text = text, - hold = 2, - align = "cm", - offset = { x = 0, y = -2.7 }, - major = G.play, - }) - G.E_MANAGER:add_event(Event({ - trigger = "after", - blockable = false, - blocking = false, - delay = 4.3, - func = function() - ease_colour(G.C.UI_CHIPS, G.C.BLUE, 2) - ease_colour(G.C.UI_MULT, G.C.RED, 2) - return true - end, - })) - G.E_MANAGER:add_event(Event({ - trigger = "after", - blockable = false, - blocking = false, - no_delete = true, - delay = 6.3, - func = function() - G.C.UI_CHIPS[1], G.C.UI_CHIPS[2], G.C.UI_CHIPS[3], G.C.UI_CHIPS[4] = - G.C.BLUE[1], G.C.BLUE[2], G.C.BLUE[3], G.C.BLUE[4] - G.C.UI_MULT[1], G.C.UI_MULT[2], G.C.UI_MULT[3], G.C.UI_MULT[4] = - G.C.RED[1], G.C.RED[2], G.C.RED[3], G.C.RED[4] - return true - end, - })) - return true - end, - })) - delay(0.6) - return args.chips, args.mult - end - return bte_ref(self, args) -end diff --git a/objects/challenges/eeeee.lua b/objects/challenges/eeeee.lua index ba44dd482..6c5c0d13e 100644 --- a/objects/challenges/eeeee.lua +++ b/objects/challenges/eeeee.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/eeeee.lua; activated here via rules.custom -> mp_eeeee. SMODS.Challenge({ key = "mp_eeeee", rules = { @@ -9,22 +10,3 @@ SMODS.Challenge({ return true end, }) - -local pseudoseed_ref = pseudoseed -function pseudoseed(key, predict_seed) - if G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_eeeee and not G._MP_UNSAVED_PRNG then - G.GAME.mp_eeeee = G.GAME.mp_eeeee or {} - local ante = G.GAME.round_resets.mp_real_ante or G.GAME.round_resets.ante - if not G.GAME.mp_eeeee[ante.."_"..key] then - math.randomseed(pseudohash((G.GAME.pseudorandom.seed or '')..ante.."mp_eeeee_"..key)) - G.GAME.mp_eeeee[ante.."_"..key] = { - poll = math.random(), - val = math.random(), - } - end - if G.GAME.mp_eeeee[ante.."_"..key].poll < 0.4 then - return G.GAME.mp_eeeee[ante.."_"..key].val - end - end - return pseudoseed_ref(key, predict_seed) -end diff --git a/objects/challenges/planet_tycoon.lua b/objects/challenges/planet_tycoon.lua index b0b2e7550..f69aebc96 100644 --- a/objects/challenges/planet_tycoon.lua +++ b/objects/challenges/planet_tycoon.lua @@ -10,7 +10,7 @@ SMODS.Challenge({ restrictions = { banned_cards = { { id = "v_planet_merchant", ids = { "v_planet_tycoon" } }, - } + }, }, apply = function(self) G.GAME.planet_rate = 360 diff --git a/objects/challenges/polymorph_spam.lua b/objects/challenges/polymorph_spam.lua index 31a90bb31..94020a7c2 100644 --- a/objects/challenges/polymorph_spam.lua +++ b/objects/challenges/polymorph_spam.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/polymorph_spam.lua (via rules.custom -> mp_polymorph_spam). SMODS.Challenge({ key = "polymorph_spam", rules = { @@ -26,142 +27,3 @@ SMODS.Challenge({ return true end, }) - -local function get_area(card) - if not card then return end - if card.config.center.set == "Joker" then - return G.jokers - elseif card.config.center.consumeable then - return G.consumeables - end - return nil -end - -local function get_pos(card) - local area = get_area(card) - for i, v in ipairs(area.cards) do - if card == v then return i end - end - return nil -end - -local function included(key) - if G.GAME.banned_keys[key] then - return false - elseif G.P_CENTERS[key].mp_include and type(G.P_CENTERS[key].mp_include) == "function" then - return G.P_CENTERS[key]:mp_include() - end - return true -end - --- i should have separated this into 2 functions but this works i suppose -local function get_transmutations_loc(card) - local done = false - local num = 0 - local area = get_area(card) - local limit = area.config.card_limit - local pos = get_pos(card) or nil - local ret = {} - while not done do - for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do - if included(v.key) then - if num > 0 then - ret[#ret + 1] = { - strings = { - localize({ type = "name_text", key = v.key, set = v.set }), - }, - control = { - C = (num - 1) == (limit - (pos or -1)) and "attention" or nil, - }, - } - if num == 1 then - done = true - break - end - end - if v == card.config.center then - num = limit - else - num = math.max(num - 1, 0) - end - end - end - end - return ret -end - -local function mass_polymorph(area) - for _, card in ipairs(area) do - local done = false - local swap = 0 - while not done do - for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do - if included(v.key) then - if swap == 1 then - card:set_ability(v) - card:set_cost() - done = true - break - end - if v == card.config.center then - swap = get_pos(card) - else - swap = math.max(swap - 1, 0) - end - end - end - end - end -end - -local calculate_context_ref = SMODS.calculate_context -function SMODS.calculate_context(context, return_table, no_resolve) - if G.GAME.modifiers.mp_polymorph_spam and context and type(context) == "table" and context.setting_blind then - mass_polymorph(G.jokers.cards) - mass_polymorph(G.consumeables.cards) - end - return calculate_context_ref(context, return_table, no_resolve) -end - -local set_ability_ref = Card.set_ability -function Card:set_ability(center, initial, delay_sprites) - local ret = set_ability_ref(self, center, initial, delay_sprites) - if G.GAME.modifiers.mp_polymorph_spam and G.OVERLAY_MENU then - if not included(center.key) then self.ability.perma_debuff = true end - end - return ret -end - -local transmute_card = nil -- global local :thinking: - -local generate_card_ui_ref = generate_card_ui -function generate_card_ui(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) - local ret = - generate_card_ui_ref(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) - if G.GAME.modifiers.mp_polymorph_spam then - if card and card.config.center then -- check for card and for tag - if get_area(card) and included(card.config.center.key) then - transmute_card = card -- whatever, surely won't break - generate_card_ui_ref({ key = "mp_transmutations", set = "Other" }, ret) -- don't need to assign this to ret because lua - end - end - end - return ret -end - --- really inefficient and throws away a metric shit ton of tables --- thanks to the advancements of my ancestors, i don't have to worry about it -local localize_ref = localize -function localize(args, misc_cat) - if args and type(args) == "table" and args.key and args.key == "mp_transmutations" then -- really safe get - local loc_target = G.localization.descriptions.Other.mp_transmutations.text_parsed - for i = 2, #loc_target do - table.remove(loc_target, 2) - end - local list = get_transmutations_loc(transmute_card) - for i = 1, #list do - loc_target[#loc_target + 1] = { list[i] } - end - end - return localize_ref(args, misc_cat) -end diff --git a/objects/challenges/shared_pockets.lua b/objects/challenges/shared_pockets.lua index 081b60f64..e5cec9ead 100644 --- a/objects/challenges/shared_pockets.lua +++ b/objects/challenges/shared_pockets.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/shared_pockets.lua (via rules.custom -> mp_shared_pockets). SMODS.Challenge({ key = "shared_pockets", rules = { @@ -11,63 +12,9 @@ SMODS.Challenge({ }, }, apply = function(self) - -- we're gonna do some hardcoded jank on the backend because slots are weird - -- so handling this like a reasonable person is riskier - G.GAME.starting_params.joker_slots = (G.GAME.starting_params.joker_slots or 0) + 1e5 - G.GAME.starting_params.consumable_slots = (G.GAME.starting_params.consumable_slots or 0) + 1e5 - G.GAME.starting_params.hand_size = (G.GAME.starting_params.hand_size or 0) + 7 - - G.GAME.mp_shared_pockets = {count = 0, slots = 15} + MP.setup_shared_pockets() end, unlocked = function(self) return true end, }) - --- just copy what vanilla does -local cardarea_update_ref = CardArea.update -function CardArea:update(dt) - if self == G.hand then - if G.GAME.modifiers.mp_shared_pockets then - self.config.mp_last_size = self.config.mp_last_size or 0 - local slots = (G.jokers.config.card_count - (G.jokers.config.card_limit - 100005)) + (G.consumeables.config.card_count - (G.consumeables.config.card_limit - 100002)) - if slots ~= self.config.last_poll_size then - self:change_size(self.config.mp_last_size - slots) - self.config.mp_last_size = slots - end - end - end - local ret = cardarea_update_ref(self, dt) - if G.GAME.modifiers.mp_shared_pockets then - G.GAME.mp_shared_pockets.count = G.hand.config.card_count + G.jokers.config.card_count + G.consumeables.config.card_count - G.GAME.mp_shared_pockets.limit = G.hand.config.card_limit + G.jokers.config.card_count + G.consumeables.config.card_count - end - return ret -end - --- ok look this might be a giant hack BUT --- yeah i have nothing to say --- whatever -local uie_update_text_ref = UIElement.update_text -function UIElement:update_text() - if G.GAME.modifiers.mp_shared_pockets then - if self.config.ref_value == "card_count" then - if self.config.ref_table == G.hand.config - or self.config.ref_table == G.jokers.config - or self.config.ref_table == G.consumeables.config then - self.config.ref_table = G.GAME.mp_shared_pockets - self.config.ref_value = "count" - end - end - if self.config.ref_value == "total_slots" then - if self.config.ref_table == G.hand.config.card_limits - or self.config.ref_table == G.jokers.config.card_limits - or self.config.ref_table == G.consumeables.config.card_limits then - self.config.ref_table = G.GAME.mp_shared_pockets - self.config.ref_value = "limit" - end - end - end - local ret = uie_update_text_ref(self) - return ret -end \ No newline at end of file diff --git a/objects/consumables/wraith.lua b/objects/consumables/wraith.lua new file mode 100644 index 000000000..0739c4e28 --- /dev/null +++ b/objects/consumables/wraith.lua @@ -0,0 +1,31 @@ +-- Reworked Wraith: random Uncommon joker + $5 (vanilla = random Rare, money to $0). +SMODS.Consumable({ + key = "wraith", + set = "Spectral", + pos = { x = 5, y = 4 }, + cost = 4, + unlocked = true, + discovered = true, + config = { extra = { dollars = 5 }, mp_balanced = true }, + loc_vars = function(self, info_queue, card) + return { vars = { card.ability.extra.dollars } } + end, + use = function(self, card, area, copier) + local used_tarot = copier or card + G.E_MANAGER:add_event(Event({ + trigger = "after", + delay = 0.4, + func = function() + play_sound("timpani") + SMODS.add_card({ set = "Joker", rarity = "Uncommon", key_append = "mp_wra" }) + used_tarot:juice_up(0.3, 0.5) + ease_dollars(card.ability.extra.dollars, true) + return true + end, + })) + delay(0.6) + end, + can_use = function(self, card) + return #G.jokers.cards < G.jokers.config.card_limit or card.area == G.jokers + end, +}) diff --git a/objects/enhancements/mp_glass.lua b/objects/enhancements/mp_glass.lua index 32caaa3a7..dc322f27a 100644 --- a/objects/enhancements/mp_glass.lua +++ b/objects/enhancements/mp_glass.lua @@ -1,5 +1,5 @@ MP.ReworkCenter("m_glass", { - layers = { "standard", "classic" }, + layers = { "standard", "classic", "wsob" }, config = { Xmult = 1.5, extra = 4 }, }) diff --git a/objects/jokers/standard/ticket.lua b/objects/jokers/standard/ticket.lua index 74e818d2a..bcb15f5f8 100644 --- a/objects/jokers/standard/ticket.lua +++ b/objects/jokers/standard/ticket.lua @@ -7,7 +7,7 @@ SMODS.Joker({ rarity = 2, cost = 6, pos = { x = 5, y = 3 }, - config = { extra = { dollars = 3 }, mp_balanced = true }, + config = { extra = { dollars = 4 }, mp_balanced = true }, loc_vars = function(self, info_queue, card) info_queue[#info_queue + 1] = G.P_CENTERS.m_gold return { vars = { card.ability.extra.dollars } } diff --git a/rulesets/ranked.lua b/rulesets/ranked.lua index 20fa35d3d..21bf62391 100644 --- a/rulesets/ranked.lua +++ b/rulesets/ranked.lua @@ -1,5 +1,5 @@ MP.Ruleset({ key = "standard_ranked", - layers = { "standard", "ranked" }, + layers = { "standard", "ranked", "pvp_timer" }, forced_gamemode = "gamemode_mp_attrition", }):inject() diff --git a/rulesets/wsob.lua b/rulesets/wsob.lua new file mode 100644 index 000000000..e44899616 --- /dev/null +++ b/rulesets/wsob.lua @@ -0,0 +1,33 @@ +-- World Series of Balatro Ruleset + +-- Layerless on purpose: WSOB is *not* the standard pool (no MP-original +-- content, far fewer reworks), so it declares its bans/reworks directly rather +-- than composing `standard` +MP.Ruleset({ + key = "wsob", + layers = { "ranked" }, -- let's gate on version though + multiplayer_content = false, + banned_silent = { + "j_hanging_chad", + "j_bloodstone", + }, + banned_jokers = {}, + banned_consumables = { + "c_justice", + }, + banned_vouchers = {}, + banned_enhancements = {}, + banned_tags = {}, + banned_blinds = {}, + reworked_jokers = { + "j_mp_hanging_chad", + "j_mp_bloodstone", + }, + reworked_consumables = {}, + reworked_vouchers = {}, + reworked_enhancements = { + "m_glass", + }, + reworked_tags = {}, + reworked_blinds = {}, +}):inject() diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index 136da8889..d3adc4af9 100644 --- a/ui/game/blind_hud.lua +++ b/ui/game/blind_hud.lua @@ -43,7 +43,7 @@ function MP.UI.update_blind_HUD(blind, reset, silent) -- Setup enemy hands G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").parent.parent.states.visible = false G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object.config.string = - { { ref_table = MP.GAME.enemy, ref_value = "hands" } } + { { ref_table = MP.GAME.enemy, ref_value = "hands_text" } } G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object:update_text() G.HUD_blind.alignment.offset.y = 0 @@ -198,6 +198,32 @@ function Blind:disable() end G.FUNCS.multiplayer_blind_chip_UI_scale = function(e) + -- Mask the opponent's hands as "?" until the first enemyInfo arrives this + -- blind (same gating as the hidden score), otherwise mirror the real count. + if + MP.LOBBY.config.hide_score_until_played + and MP.is_pvp_boss() + and not MP.GAME.enemy.info_received + then + MP.GAME.enemy.hands_text = "?" + else + MP.GAME.enemy.hands_text = tostring(MP.GAME.enemy.hands) + end + + -- Hide the opponent's score until we have played a hand this PvP blind, so + -- a player can't watch the enemy score before committing their own hand. + -- (The server also withholds the score, this is the matching display.) + -- Gated by the hide_score_until_played lobby option (on by default only on + -- standard-layer rulesets; host-toggleable in non-forcing lobbies). + if + MP.LOBBY.config.hide_score_until_played + and MP.is_pvp_boss() + and G.GAME.current_round + and G.GAME.current_round.hands_played == 0 + then + MP.GAME.enemy.score_text = "???" + return + end local new_score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score) if G.GAME.blind and MP.GAME.enemy.score and MP.GAME.enemy.score_text ~= new_score_text then if not MP.INSANE_INT.greater_than(MP.GAME.enemy.score, MP.INSANE_INT.create(0, G.E_SWITCH_POINT, 0)) then diff --git a/ui/game/game_end.lua b/ui/game/game_end.lua index 63a45f726..746c8d458 100644 --- a/ui/game/game_end.lua +++ b/ui/game/game_end.lua @@ -47,7 +47,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) 0, 5 * G.CARD_W, G.CARD_H, - { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1 } + { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1, fixed_limit = true } ) if not MP.end_game_jokers_received then MP.ACTIONS.get_end_game_jokers() @@ -305,7 +305,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) { n = G.UIT.T, config = { - text = localize("ml_mp_kofi_message")[3] .. " " .. localize("ml_mp_kofi_message")[4], + text = localize("ml_mp_kofi_message")[3] .. (localize("ml_mp_kofi_message")[4] and (" " .. localize("ml_mp_kofi_message")[4]) or ""), scale = 0.35, colour = G.C.UI.TEXT_LIGHT, col = true, diff --git a/ui/game/timer.lua b/ui/game/timer.lua index cc5bac32f..fba9256f6 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -4,6 +4,7 @@ function MP.UI.cam_timer_opponent() if not MP.LOBBY.config.timer then return false end if MP.GAME.pvp_countdown_in_progress then return false end if MP.GAME.timer <= 0 then return false end + if MP.GAME.nemesis_timer_started then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.score) then return true end @@ -339,6 +340,7 @@ function Game:update(dt) else -- Old timer: tick when opponent timering, not in pvp if is_pvp_boss then return end + if MP.GAME.pvp_reached and MP.GAME.ready_blind and not MP.GAME.timer_started then return end if not (MP.GAME.timer_started or MP.GAME.nemesis_timer_started) then return end end diff --git a/ui/lobby/_lobby_options/options_tab.lua b/ui/lobby/_lobby_options/options_tab.lua index 965d4f6d5..563419fd9 100644 --- a/ui/lobby/_lobby_options/options_tab.lua +++ b/ui/lobby/_lobby_options/options_tab.lua @@ -83,6 +83,11 @@ function MP.UI.create_lobby_options_tab() MP.ACTIONS.update_player_usernames() end), create_lobby_option_toggle("normal_bosses_toggle", "b_opts_normal_bosses", "normal_bosses"), + create_lobby_option_toggle( + "hide_score_until_played_toggle", + "b_opts_hide_score", + "hide_score_until_played" + ), }, } end diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index dfdd7f689..7e51f2774 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -34,20 +34,21 @@ G.FUNCS.mp_timer_blurb = function(e) end end - -- Each cycle is one table: label / mod / blurb on the same line. mod = nil means -- "no modifier" (the cycle's default, inherits whatever the ruleset does). local GLASS_VARIANTS = { - { label = "Inherit", mod = nil, blurb = "Inherit the main ruleset." }, - { label = "Vanilla", mod = "glass_vanilla", blurb = "x2 mult." }, - { label = "Standard", mod = "glass_standard", blurb = "x1.5 (Justice Disabled)." }, - { label = "Legacy", mod = "glass_legacy", blurb = "x1.5 (Justice Enabled)." }, + { label = "Inherit", mod = nil, blurb = "Inherit the main ruleset." }, + { label = "Vanilla", mod = "glass_vanilla", blurb = "x2 mult." }, + { label = "Standard", mod = "glass_standard", blurb = "x1.5 (Justice Disabled)." }, + { label = "Legacy", mod = "glass_legacy", blurb = "x1.5 (Justice Enabled)." }, { label = "Experimental", mod = "glass_experimental", blurb = "x2 (Grim/Familiar/Incantation only)." }, } local function variant_labels(variants) local labels = {} - for i, v in ipairs(variants) do labels[i] = v.label end + for i, v in ipairs(variants) do + labels[i] = v.label + end return labels end @@ -76,7 +77,9 @@ local function variant_blurb_func(variants) end end -G.FUNCS.change_glass_variant = function(args) pick_variant(GLASS_VARIANTS, args.to_key) end +G.FUNCS.change_glass_variant = function(args) + pick_variant(GLASS_VARIANTS, args.to_key) +end G.FUNCS.mp_glass_blurb = variant_blurb_func(GLASS_VARIANTS) local MUTATOR_WALL = { @@ -84,53 +87,135 @@ local MUTATOR_WALL = { name = "ECONOMY", colour = G.C.MONEY, cells = { - { key = "inflation", label = "Inflation", desc = { "Shop prices creep up $1 with", "every card you buy." } }, - { key = "no_interest", label = "No Interest", desc = { "Savings earn nothing.", "Spend it or lose the edge." } }, - { key = "discard_tax", label = "Discard Tax", desc = { "Every discard costs $1." } }, - { key = "frugal", label = "Frugal", desc = { "Unspent discards pay out $1."} }, + { + key = "inflation", + label = "Inflation", + desc = { "Shop prices creep up $1 with", "every card you buy." }, + soon = true, + credits = { idea = { "Kars" } }, + }, + { + key = "no_interest", + label = "No Interest", + desc = { "Savings earn nothing.", "Spend it or lose the edge." }, + soon = true, + }, + { key = "discard_tax", label = "Discard Tax", desc = { "Every discard costs $1." }, soon = true }, + { key = "frugal", label = "Frugal", desc = { "Unspent discards pay out $1." }, soon = true }, + { + key = "wraith_rework", + label = "Kind Wraith", + desc = { "Wraith spawns an Uncommon and", "pays $5 (was Rare, money to $0)." }, + credits = { code = { "steph" } }, + }, + { + key = "pvp_reward_draft", + label = "Reward Draft", + desc = { "Win a PvP blind, then draft", "1 of several rewards." }, + soon = true, + }, }, }, { name = "MAYHEM", colour = G.C.PURPLE, cells = { - { key = "flipped_cards", label = "Blind Poker", desc = { "Your hand is dealt face-down." } }, - { key = "debuff_played_cards", label = "Dead Cards", desc = { "Playing cards are debuffed.", "Jokers are the whole engine." } }, - { key = "all_eternal", label = "No Takebacks", desc = { "Every joker is eternal." } }, - { key = "shrinking_hand", label = "Heavy Pockets", desc = { "-1 hand size for every $10", "you're holding." } }, + { key = "flipped_cards", label = "Blind Poker", desc = { "Your hand is dealt face-down." }, soon = true }, + { + key = "debuff_played_cards", + label = "Dead Cards", + desc = { "Playing cards are debuffed.", "Jokers are the whole engine." }, + }, + { key = "all_eternal", label = "No Takebacks", desc = { "Every joker is eternal." }, soon = true }, + { + key = "shrinking_hand", + label = "Heavy Pockets", + desc = { "-1 hand size for every $10", "you're holding." }, + soon = true, + }, + { + key = "shared_pockets", + label = "Shared Pockets", + desc = { "Hand, jokers and consumables", "share one pool of slots." }, + }, + { + key = "score_instability", + label = "Instability", + desc = { "After scoring, chips and mult", "are dragged toward each other." }, + credits = { code = { "Toneblock" } }, + }, + { + key = "rubber_band", + label = "Rubber Band", + desc = { "Losing a life grants an", "escalating comeback buff." }, + soon = true, + }, }, }, { name = "HAZARDS", colour = G.C.RED, cells = { - { key = "gambling_opportunity", label = "No Easy Money", desc = { "No Gold or Lucky cards." } }, + { + key = "gambling_opportunity", + label = "No Easy Money", + desc = { "No Gold or Lucky cards." }, + credits = { idea = { "Kars" } }, + }, { key = "no_uncommons", label = "No Uncommons", desc = { "Uncommon jokers are out", "of the pool." } }, + { key = "no_red_seals", label = "No Red Seals", desc = { "Red seals never appear." } }, { key = "bigger_shop", label = "Bigger Shop", desc = { "One extra card slot", "in the shop." } }, - { key = "chip_cap", label = "Cash Ceiling", desc = { "Chip score can't exceed your", "cash. Greed is the only way up." } }, + { + key = "chip_cap", + label = "Cash Ceiling", + desc = { "Chip score can't exceed your", "cash. Greed is the only way up." }, + soon = true, + }, + { + key = "score_tax", + label = "Score Tax", + desc = { "Each hand you play nudges your", "opponent's target upward." }, + soon = true, + }, }, }, { name = "CHAOS", colour = G.C.ORANGE, cells = { - { key = "glass_cannon", label = "Glass Cannon", desc = { "Only 2 hands a round โ€” but", "every hand hits for 4x mult." } }, - { key = "smallworld", label = "Small World", desc = { "75% of the pool banned at", "random. Showman always on." } }, - { key = "spartan", label = "Spartan", desc = { "No cash from Small or Big", "blinds." } }, - { key = "pricey_packs", label = "Pricey Packs", desc = { "Booster packs cost more", "for each ante you reach." } }, + { + key = "glass_cannon", + label = "Glass Cannon", + desc = { "Only 2 hands a round โ€” but", "every hand hits for 4x mult." }, + }, + { + key = "smallworld", + label = "Small World", + desc = { "75% of the pool banned at", "random. Showman always on." }, + credits = { code = { "Toneblock" } }, + }, + { key = "spartan", label = "Spartan", desc = { "No cash from Small or Big", "blinds." }, soon = true }, + { + key = "pricey_packs", + label = "Pricey Packs", + desc = { "Booster packs cost more", "for each ante you reach." }, + soon = true, + }, + { + key = "polymorph_spam", + label = "Polymorph", + desc = { "Every blind, your jokers and", "consumables re-roll." }, + }, + { + key = "eeeee", + label = "Eeeee", + desc = { "~40% of random rolls lock to", "a fixed value each ante." }, + credits = { code = { "Tonebleee" } }, + }, }, }, } -local SOON = { - colour = G.C.GREY, - cells = { - { key = "pvp_reward_draft", label = "Reward Draft", desc = { "Win a PvP blind, draft 1 of 3", "rewards.", "(coming soon)" } }, - { key = "rubber_band", label = "Rubber Band", desc = { "Falling behind grants", "escalating buffs.", "(coming soon)" } }, - { key = "score_tax", label = "Score Tax", desc = { "Each hand you play raises", "your opponent's target.", "(coming soon)" } }, - }, -} - G.FUNCS.mp_toggle_mutator = function(e) local key = e.config.ref_table.key MP.MUTATORS_BLIND = false -- touching anything reveals the wall @@ -157,7 +242,7 @@ local function roll_mutators() for _, cat in ipairs(MUTATOR_WALL) do for _, cell in ipairs(cat.cells) do MP.remove_modifier(cell.key) - if math.random() < 0.35 then MP.add_modifier(cell.key) end + if not cell.soon and math.random() < 0.35 then MP.add_modifier(cell.key) end end end end @@ -176,9 +261,100 @@ G.FUNCS.mp_blind_random_mutators = function(e) e:juice_up(0.3, 0.1) end +-- TODO(rewire): fold this and mod_badges.lua's inline builder into one shared +-- MP.UI.credit_badge(credits) so we can use it anywhere we like :) +local function mutator_credit_badge(credits) + credits.art = credits.art or {} + credits.idea = credits.idea or {} + credits.code = credits.code or {} + + local function calc_scale_fac(text) + local size = 0.9 + local font = G.LANG.font + local max_text_width = 2 - 2 * 0.05 - 4 * 0.03 * size - 2 * 0.03 + local calced_text_width = 0 + for _, c in utf8.chars(text) do + local tx = font.FONT:getWidth(c) * (0.33 * size) * G.TILESCALE * font.FONTSCALE + + 2.7 * 1 * G.TILESCALE * font.FONTSCALE + calced_text_width = calced_text_width + tx / (G.TILESIZE * G.TILESCALE) + end + return calced_text_width > max_text_width and max_text_width / calced_text_width or 1 + end + + local strings = {} + for _, v in ipairs({ "art", "idea", "code" }) do + for i = 1, #credits[v] do + strings[#strings + 1] = localize({ type = "variable", key = "a_mp_" .. v, vars = { credits[v][i] } })[1] + end + end + + -- fallback if {} + if #strings == 0 then return { n = G.UIT.R, config = { align = "cm" }, nodes = {} } end + + local min_scale_fac = 1 + for i = 1, #strings do + min_scale_fac = math.min(min_scale_fac, calc_scale_fac(strings[i])) + end + + local ct = {} + for i = 1, #strings do + ct[i] = { string = strings[i] } + end + + return { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + { + n = G.UIT.R, + config = { + align = "cm", + colour = G.C.MULTIPLAYER, + r = 0.1, + minw = 2 / min_scale_fac, + minh = 0.36, + emboss = 0.05, + padding = 0.03 * 0.9, + }, + nodes = { + { n = G.UIT.B, config = { h = 0.1, w = 0.03 } }, + { + n = G.UIT.O, + config = { + object = DynaText({ + string = ct, + colours = { credits.text_colour or G.C.WHITE }, + silent = true, + float = true, + shadow = true, + offset_y = -0.03, + spacing = 1, + scale = 0.33 * 0.9, + }), + }, + }, + { n = G.UIT.B, config = { h = 0.1, w = 0.03 } }, + }, + }, + }, + } +end + local function mutator_cell(cell, colour, disabled) local on = colour local off = darken(colour, 0.72) + -- Disabled cells keep their real description with a "(Coming soon)" line appended + local tooltip_text = cell.desc + if disabled then + tooltip_text = {} + for _, line in ipairs(cell.desc) do + tooltip_text[#tooltip_text + 1] = line + end + tooltip_text[#tooltip_text + 1] = "(Coming soon)" + end + + local tooltip = { title = cell.label, text = tooltip_text } + if cell.credits then tooltip.filler = { func = mutator_credit_badge, args = cell.credits } end return { n = G.UIT.R, config = { align = "cm", padding = 0.035 }, @@ -198,7 +374,7 @@ local function mutator_cell(cell, colour, disabled) button = not disabled and "mp_toggle_mutator" or nil, func = not disabled and "mp_mutator_cell_colour" or nil, ref_table = { key = cell.key, on = on, off = off }, - tooltip = { title = cell.label, text = cell.desc }, + tooltip = tooltip, }, nodes = { { @@ -224,15 +400,11 @@ local function mutator_column(cat) }, } for _, cell in ipairs(cat.cells) do - nodes[#nodes + 1] = mutator_cell(cell, cat.colour) + nodes[#nodes + 1] = mutator_cell(cell, cat.colour, cell.soon) end return { n = G.UIT.C, config = { align = "tm", padding = 0.06 }, nodes = nodes } end --- --------------------------------------------------------------------------- --- Reusable builders (shared by overlays and the inline custom-ruleset editor โ€” --- one source of truth for the wall + timer controls). --- --------------------------------------------------------------------------- function MP.UI.build_timer_modifier_cycle() return MP.UI.Disableable_Option_Cycle({ id = "modifier_timer_option", @@ -307,19 +479,11 @@ function MP.UI.build_glass_cycle() }) end - --- The MUTATORS wall block: header + subtitle + themed columns + coming-soon line. --- Returns one node (rows stack vertically since they're R children). function MP.UI.build_mutators_wall() local columns = {} for _, cat in ipairs(MUTATOR_WALL) do columns[#columns + 1] = mutator_column(cat) end - local soon_labels = {} - for _, cell in ipairs(SOON.cells) do - soon_labels[#soon_labels + 1] = cell.label - end - local soon_line = "coming soon ยท " .. table.concat(soon_labels, " ยท ") return { n = G.UIT.R, config = { align = "cm" }, @@ -327,20 +491,28 @@ function MP.UI.build_mutators_wall() { n = G.UIT.R, config = { align = "cm", padding = 0.02 }, - nodes = { { n = G.UIT.T, config = { text = "MUTATORS", scale = 0.5, colour = G.C.UI.TEXT_LIGHT, shadow = true } } }, + nodes = { + { + n = G.UIT.T, + config = { text = "MUTATORS", scale = 0.5, colour = G.C.UI.TEXT_LIGHT, shadow = true }, + }, + }, }, { n = G.UIT.R, config = { align = "cm", padding = 0.02 }, - nodes = { { n = G.UIT.T, config = { text = "stack freely ยท hover for details", scale = 0.3, colour = G.C.UI.TEXT_INACTIVE } } }, + nodes = { + { + n = G.UIT.T, + config = { + text = "stack freely ยท hover for details", + scale = 0.3, + colour = G.C.UI.TEXT_INACTIVE, + }, + }, + }, }, { n = G.UIT.R, config = { align = "cm", padding = 0.04 }, nodes = columns }, - { n = G.UIT.R, config = { minh = 0.06 } }, - { - n = G.UIT.R, - config = { align = "cm", padding = 0.02 }, - nodes = { { n = G.UIT.T, config = { text = soon_line, scale = 0.3, colour = G.C.UI.TEXT_INACTIVE } } }, - }, }, } end diff --git a/ui/main_menu/play_button/custom_ruleset_editor.lua b/ui/main_menu/play_button/custom_ruleset_editor.lua index b19086fda..0bfb7b215 100644 --- a/ui/main_menu/play_button/custom_ruleset_editor.lua +++ b/ui/main_menu/play_button/custom_ruleset_editor.lua @@ -1,12 +1,22 @@ MP.CUSTOM = MP.CUSTOM or {} -local CONTENT_SETS = { "Vanilla", "Standard", "Standard (0.2)", "Sandbox", "Experimental" } +local CONTENT_SETS = { "Vanilla", "Standard", "Sandbox", "Experimental" } + +-- Content set (display string) -> base ruleset short key. The custom editor is +-- "this base + a stack of modifiers", so each set just points at an existing +-- ruleset whose layers we inherit. +local CONTENT_SET_RULESET = { + Vanilla = "vanilla", + Standard = "blitz", + Sandbox = "sandbox", + Experimental = "experimental", +} -- --------------------------------------------------------------------------- function MP.CUSTOM.new_draft() return { name = "My Ruleset", - base = "standard", -- content_sets + base = "Standard", -- key into CONTENT_SETS / CONTENT_SET_RULESET modifiers = {}, -- snapshotted modifiers banned_jokers = {}, banned_consumables = {}, @@ -131,6 +141,38 @@ G.FUNCS.mp_custom_set_base = function(args) if MP.CUSTOM.draft and args and args.to_key then MP.CUSTOM.draft.base = CONTENT_SETS[args.to_key] end end +-- Launch a custom run: chosen base ruleset + the wall's MP.MODIFIERS. +-- We set the ruleset directly and deliberately DON'T route through +-- change_ruleset_selection / apply_default_modifiers, which would wipe the +-- wall's modifier selections. Modifiers ride to the guest via start_lobby's +-- modifier_layers serialization, same as the Modifiers overlay. +G.FUNCS.mp_custom_play = function(e) + local d = MP.CUSTOM.draft or MP.CUSTOM.new_draft() + local mode = MP.CUSTOM.editor_mode or "mp" + local base_short = CONTENT_SET_RULESET[d.base] or "blitz" + local base_key = "ruleset_mp_" .. base_short + + if mode == "sp" or mode == "practice" then + MP.SP.ruleset = base_key + else + MP.LOBBY.config.ruleset = base_key + end + + -- Materialize reworks for the chosen base. active_layer_chain now folds in + -- MP.MODIFIERS (the ruleset is set above), so modifier reworks resolve too. + MP.LoadReworks(base_short) + + local ruleset = MP.Rulesets[base_key] + if mode == "sp" then + G.FUNCS.start_sp_run(e) + elseif mode == "practice" then + G.FUNCS.start_practice_run(e) + elseif ruleset and ruleset.forced_gamemode then + G.FUNCS["force_" .. ruleset.forced_gamemode](e) + else + G.FUNCS.select_gamemode(e) + end +end G.FUNCS.mp_custom_open_collection = function(e) MP.CUSTOM.editing_bans = true @@ -141,53 +183,36 @@ end -- --------------------------------------------------------------------------- -- Tab content -- --------------------------------------------------------------------------- -local function text_row(str, scale, colour) - return { - n = G.UIT.R, - config = { align = "cm", padding = 0.04 }, - nodes = { - { n = G.UIT.T, config = { text = str, scale = scale or 0.4, colour = colour or G.C.UI.TEXT_LIGHT } }, - }, - } -end - local function knob_row(node) return { n = G.UIT.R, config = { align = "cm", padding = 0.08 }, nodes = { node } } end --- COMING SOON banner across the top of the editor. -local function coming_soon_ribbon() +local function soon_pill(label) return { - n = G.UIT.R, - config = { align = "cm", padding = 0.12, r = 0.1, colour = G.C.BOOSTER, emboss = 0.05, minw = 15 }, + n = G.UIT.C, + config = { align = "cm", minw = 4.5, minh = 0.85, padding = 0.08, r = 0.1, emboss = 0.05, colour = G.C.UI.BACKGROUND_INACTIVE }, nodes = { - { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - { - n = G.UIT.T, - config = { text = "CUSTOM RULESETS - COMING SOON", scale = 0.55, colour = G.C.UI.TEXT_LIGHT, shadow = true }, - }, - }, - }, + { n = G.UIT.R, config = { align = "cm" }, nodes = { { n = G.UIT.T, config = { text = label, scale = 0.45, colour = G.C.UI.TEXT_INACTIVE } } } }, + { n = G.UIT.R, config = { align = "cm" }, nodes = { { n = G.UIT.T, config = { text = "coming soon", scale = 0.3, colour = G.C.UI.TEXT_INACTIVE } } } }, }, } end - -local function soon_pill(label) +-- The editor is local-only for now: custom card bans aren't wired into ApplyBans +-- or the lobby modifier sync yet, so on the lobby (mp) path we stub the tab. +-- It stays fully functional in sp / practice. +local function coming_soon_root() return { - n = G.UIT.C, - config = { align = "cm", minw = 4.5, minh = 0.85, padding = 0.08, r = 0.1, emboss = 0.05, colour = G.C.UI.BACKGROUND_INACTIVE }, + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR, minh = 6, minw = 15 }, nodes = { - { n = G.UIT.R, config = { align = "cm" }, nodes = { { n = G.UIT.T, config = { text = label, scale = 0.45, colour = G.C.UI.TEXT_INACTIVE } } } }, - { n = G.UIT.R, config = { align = "cm" }, nodes = { { n = G.UIT.T, config = { text = "coming soon", scale = 0.3, colour = G.C.UI.TEXT_INACTIVE } } } }, + { n = G.UIT.R, config = { align = "cm" }, nodes = { soon_pill("Custom ruleset") } }, }, } end function MP.UI.build_custom_ruleset_editor(mode) + if mode == "mp" then return coming_soon_root() end MP.CUSTOM.editor_mode = mode -- so the ban picker's Back knows where to return MP.CUSTOM.draft = MP.CUSTOM.draft or MP.CUSTOM.new_draft() local d = MP.CUSTOM.draft @@ -204,19 +229,8 @@ function MP.UI.build_custom_ruleset_editor(mode) w = 4, })) - -- edit-bans button - knobs[#knobs + 1] = knob_row(UIBox_button({ - button = "mp_custom_open_collection", - label = { "Edit joker bans" }, - minw = 4, - minh = 0.8, - scale = 0.45, - colour = G.C.RED, - hover = true, - shadow = true, - })) - knobs[#knobs + 1] = text_row("Banned jokers: " .. tostring(#d.banned_jokers), 0.32, G.C.UI.TEXT_INACTIVE) - knobs[#knobs + 1] = text_row("(hover a card, press DELETE)", 0.28, G.C.UI.TEXT_DARK) + -- edit-bans: custom card bans aren't wired into ApplyBans / network sync yet. + knobs[#knobs + 1] = knob_row(soon_pill("Edit joker bans")) -- Variants strip: option-cycles for graded knobs (timer / glass) plus the PvP -- toggle. Binary twists live on the wall below. @@ -237,11 +251,38 @@ function MP.UI.build_custom_ruleset_editor(mode) MP.UI.build_mutator_randomize_row(), } + -- Play button label matches the start flow the base ruleset will take. + local base_ruleset = MP.Rulesets["ruleset_mp_" .. (CONTENT_SET_RULESET[d.base] or "blitz")] + local play_label + if mode == "sp" or mode == "practice" then + play_label = localize("b_play_cap") + elseif base_ruleset and base_ruleset.forced_gamemode then + play_label = localize("b_create_lobby") + else + play_label = localize("b_next") + end + local actions = { n = G.UIT.R, config = { align = "cm", padding = 0.12 }, nodes = { - { n = G.UIT.C, config = { align = "cm", padding = 0.06 }, nodes = { soon_pill("Save & Play") } }, + { + n = G.UIT.C, + config = { align = "cm", padding = 0.06 }, + nodes = { + UIBox_button({ + id = "mp_custom_play_btn", + button = "mp_custom_play", + label = { play_label }, + colour = G.C.GREEN, + minw = 5, + minh = 0.8, + scale = 0.5, + hover = true, + shadow = true, + }), + }, + }, }, } @@ -249,7 +290,6 @@ function MP.UI.build_custom_ruleset_editor(mode) n = G.UIT.ROOT, config = { align = "cm", colour = G.C.CLEAR }, nodes = { - coming_soon_ribbon(), { n = G.UIT.R, config = { align = "cm" }, diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 22919570b..f9c897708 100644 --- a/ui/main_menu/play_button/play_button_callbacks.lua +++ b/ui/main_menu/play_button/play_button_callbacks.lua @@ -110,6 +110,10 @@ function G.FUNCS.start_lobby(e) MP.LOBBY.config.multiplayer_jokers = MP.current_ruleset().multiplayer_content + -- Hide opponent score until you play: default on for standard-layer rulesets + -- only. Set before force_lobby_options() so a forcing ruleset can override it. + MP.LOBBY.config.hide_score_until_played = MP.current_ruleset().standard == true + MP.LOBBY.config.forced_config = MP.current_ruleset():force_lobby_options() MP.LOBBY.config.modifier_layers = MP.modifiers_serialize() diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 11f8040d2..8003ab35b 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -1,33 +1,3 @@ -local ruleset_buttons_data = { - { - name = "k_matchmaking", - buttons = { - { button_id = "standard_ranked_ruleset_button", button_localize_key = "k_standard_ranked" }, - { button_id = "legacy_ranked_ruleset_button", button_localize_key = "k_legacy_ranked" }, - { button_id = "smallworld_ruleset_button", button_localize_key = "k_smallworld" }, - { button_id = "sandbox_ruleset_button", button_localize_key = "k_sandbox" }, - }, - }, - { - name = "k_custom", - buttons = { - { button_id = "blitz_ruleset_button", button_localize_key = "k_blitz" }, - { button_id = "traditional_ruleset_button", button_localize_key = "k_traditional" }, - { button_id = "vanilla_ruleset_button", button_localize_key = "k_vanilla" }, - { button_id = "badlatro_ruleset_button", button_localize_key = "k_badlatro" }, - { button_id = "speedlatro_ruleset_button", button_localize_key = "k_speedlatro" }, - { button_id = "chaos_ruleset_button", button_localize_key = "k_chaos" }, - }, - }, - { - name = "k_tournament", - buttons = { - { button_id = "majorleague_ruleset_button", button_localize_key = "k_majorleague" }, - { button_id = "minorleague_ruleset_button", button_localize_key = "k_minorleague" }, - }, - }, -} - local rulesets_tabs = { default = { { @@ -60,6 +30,7 @@ local rulesets_tabs = { buttons = { { button_id = "majorleague_ruleset_button", button_localize_key = "k_majorleague" }, { button_id = "minorleague_ruleset_button", button_localize_key = "k_minorleague" }, + { button_id = "wsob_ruleset_button", button_localize_key = "k_wsob" }, }, }, { From d563f19750fe3b57cee92b2c59c21a479d61be2f Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 13:46:19 -0600 Subject: [PATCH 06/24] Add PvP validation to ante timer actions (#477) * Fix life loss from PvP timer firing after PvP has ended After endPvP, the opponent's startAnteTimer message could re-arm nemesis_timer_started, causing the timer to tick in the old-timer path and costing an extra life. Add a post_pvp guard that blocks timer starts until the player readies for the next blind. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj * Revert "Fix life loss from PvP timer firing after PvP has ended" This reverts commit 1099127ee244ead313314ed62d7662175d9007d7. * Fix PvP timer causing life loss after PvP ends Tag startAnteTimer with isPvP when pressed during a PvP boss blind. The receiving client ignores isPvP timer starts if it's no longer in PvP, preventing the race condition where a PvP timer press arrives after endPvP has already cleared the PvP state. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj * Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts is_pvp_boss() stays true briefly after PvP ends until the game state transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the guard catches the race window where PvP is over but the blind hasn't changed yet. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj * Also ignore PvP timer starts when player has 0 hands left If hands are exhausted the PvP round is effectively over even before endPvP arrives, so reject stale PvP timer presses in that state too. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj --------- Co-authored-by: Claude --- networking/action_handlers.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 88f3927fc..ece83fde4 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -957,6 +957,8 @@ end -- Dual-call: dispatched from network (fromNemesis defaults to true) or self-triggered -- by MP.ACTIONS.start_ante_timer (passes fromNemesis = false explicitly). local function action_start_ante_timer(p) + if p.isPvP and (MP.GAME.end_pvp or not MP.is_pvp_boss() or G.GAME.current_round.hands_left <= 0) then return end + local time = p.time local from_nemesis = p.fromNemesis if from_nemesis == nil then from_nemesis = true end @@ -1290,9 +1292,11 @@ function MP.ACTIONS.request_nemesis_stats() end function MP.ACTIONS.start_ante_timer() + local is_pvp = MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") Client.send({ action = "startAnteTimer", time = MP.GAME.timer, + isPvP = is_pvp or nil, }) action_start_ante_timer({ time = MP.GAME.timer, fromNemesis = false }) end From fbd46a27caf14b33babf5a30b2726c21362953e5 Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 13:46:57 -0600 Subject: [PATCH 07/24] Update version from 0.5.0 to 0.5.1 --- Multiplayer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Multiplayer.json b/Multiplayer.json index cf81ad04a..94e83eaf5 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.5.0", + "version": "0.5.1", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", From 1183a6802bf98d0ee15b1cab8d879d0bfebcfbb9 Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 14:40:17 -0600 Subject: [PATCH 08/24] Pause timer when opponent disconnects (#478) * Pause timer for both players when opponent disconnects Clear both timer_started and nemesis_timer_started on enemyDisconnected so the timer stops ticking for the remaining player. The timer stays paused after reconnection and must be manually restarted. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj * Also pause timer for the reconnecting player Clear timer_started and nemesis_timer_started on rejoinedLobby so the player who disconnected doesn't have their timer resume immediately on reconnect. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj * Pause timer on reconnecting (disconnecting player's side) The game loop keeps running while disconnected, so the timer keeps ticking. Pause it in action_reconnecting when the connection drops, not just on rejoinedLobby which is too late. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj --------- Co-authored-by: Claude --- networking/action_handlers.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index ece83fde4..f6a5be5c6 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -94,6 +94,8 @@ local function action_rejoinedLobby(p) reconnectToken = token lastLobbyCode = code MP.self_reconnect_countdown = nil + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false MP.ACTIONS.sync_client() MP.ACTIONS.lobby_info() MP.UI.update_connection_status() @@ -145,6 +147,9 @@ local function action_enemyDisconnected(p) local timeout = p.timeout or 60 sendWarnMessage("Opponent disconnected, waiting for reconnection...", "MULTIPLAYER") + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false + MP.enemy_disconnect_countdown = { end_time = love.timer.getTime() + timeout, display = timeout .. "s remaining", @@ -242,6 +247,8 @@ local function action_reconnecting() -- Only show if we were in a lobby and don't already have a countdown running if reconnectToken and lastLobbyCode and not MP.self_reconnect_countdown then MP.LOBBY.connected = false + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false MP.UI.update_connection_status() sendWarnMessage("Connection lost, attempting to reconnect...", "MULTIPLAYER") From 182ba5dceda4c1c6482e977443a4aab222619a2c Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Wed, 1 Jul 2026 23:53:09 +0300 Subject: [PATCH 09/24] Fix PvP timer toggle when it shouldn't --- core.lua | 1 + lib/ghost_replay.lua | 2 ++ networking/action_handlers.lua | 2 ++ ui/game/timer.lua | 5 +++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core.lua b/core.lua index 64b84a64e..e35ec61cd 100644 --- a/core.lua +++ b/core.lua @@ -217,6 +217,7 @@ function MP.reset_game_states() end_pvp = false, enemy = { score = MP.INSANE_INT.empty(), + real_score = MP.INSANE_INT.empty(), score_text = "0", hands = 4, hands_text = "4", diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index e0a27539c..4828d89b0 100644 --- a/lib/ghost_replay.lua +++ b/lib/ghost_replay.lua @@ -58,6 +58,7 @@ function MP.GHOST.init_playback(ante) MP.GHOST._hand_idx = 1 local score = MP.INSANE_INT.from_string(hands[1].score) MP.GAME.enemy.score = score + MP.GAME.enemy.real_score = score MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score) MP.GAME.enemy.hands = hands[1].hands_left or 0 MP.GAME.enemy.info_received = true @@ -97,6 +98,7 @@ function MP.GHOST.advance_hand() func = function(t) return math.floor(t) end, })) + MP.GAME.enemy.real_score = score MP.GAME.enemy.hands = entry.hands_left or 0 MP.GAME.enemy.info_received = true if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index ece83fde4..45c40894b 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -315,6 +315,7 @@ local function action_start_blind(p) -- Reset the stored opponent score each blind so the first frame after we -- play (which lifts the "???" mask) shows 0, not last blind's stale score. MP.GAME.enemy.score = MP.INSANE_INT.empty() + MP.GAME.enemy.real_score = MP.INSANE_INT.empty() MP.GAME.enemy.score_text = "0" -- Re-mask the opponent's hands until the first enemyInfo of the new blind. MP.GAME.enemy.info_received = false @@ -421,6 +422,7 @@ local function action_enemy_info(p) play_sound("gong", 0.765, 0.4) end + MP.GAME.enemy.real_score = score MP.GAME.enemy.hands = hands_left MP.GAME.enemy.skips = skips MP.GAME.enemy.lives = lives diff --git a/ui/game/timer.lua b/ui/game/timer.lua index fba9256f6..5c177dd1b 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -7,8 +7,9 @@ function MP.UI.cam_timer_opponent() if MP.GAME.nemesis_timer_started then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end - if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.score) then return true end - if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.score) then return MP.GAME.pvp_reached_first end + if MP.LOBBY.config.hide_score_until_played and not MP.GAME.enemy.info_received then return false end + if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.real_score) then return true end + if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.real_score) then return MP.GAME.pvp_reached_first end return false end return MP.GAME.ready_blind From 950b8b5932b219b0d6c5a24056a6e28a091bc199 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Wed, 1 Jul 2026 23:55:47 +0300 Subject: [PATCH 10/24] just in case --- ui/game/timer.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 5c177dd1b..39aac5fb5 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -6,6 +6,7 @@ function MP.UI.cam_timer_opponent() if MP.GAME.timer <= 0 then return false end if MP.GAME.nemesis_timer_started then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end if MP.LOBBY.config.hide_score_until_played and not MP.GAME.enemy.info_received then return false end if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.real_score) then return true end From 77b5615a64f5a8d7d167fc2a6d87163f50e71e33 Mon Sep 17 00:00:00 2001 From: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:57:20 +0300 Subject: [PATCH 11/24] PvP + hidden score fixes (#479) * Fix PvP timer toggle when it shouldn't * just in case --- core.lua | 1 + lib/ghost_replay.lua | 2 ++ networking/action_handlers.lua | 2 ++ ui/game/timer.lua | 6 ++++-- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core.lua b/core.lua index 64b84a64e..e35ec61cd 100644 --- a/core.lua +++ b/core.lua @@ -217,6 +217,7 @@ function MP.reset_game_states() end_pvp = false, enemy = { score = MP.INSANE_INT.empty(), + real_score = MP.INSANE_INT.empty(), score_text = "0", hands = 4, hands_text = "4", diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index e0a27539c..4828d89b0 100644 --- a/lib/ghost_replay.lua +++ b/lib/ghost_replay.lua @@ -58,6 +58,7 @@ function MP.GHOST.init_playback(ante) MP.GHOST._hand_idx = 1 local score = MP.INSANE_INT.from_string(hands[1].score) MP.GAME.enemy.score = score + MP.GAME.enemy.real_score = score MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score) MP.GAME.enemy.hands = hands[1].hands_left or 0 MP.GAME.enemy.info_received = true @@ -97,6 +98,7 @@ function MP.GHOST.advance_hand() func = function(t) return math.floor(t) end, })) + MP.GAME.enemy.real_score = score MP.GAME.enemy.hands = entry.hands_left or 0 MP.GAME.enemy.info_received = true if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index f6a5be5c6..ea454a99c 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -322,6 +322,7 @@ local function action_start_blind(p) -- Reset the stored opponent score each blind so the first frame after we -- play (which lifts the "???" mask) shows 0, not last blind's stale score. MP.GAME.enemy.score = MP.INSANE_INT.empty() + MP.GAME.enemy.real_score = MP.INSANE_INT.empty() MP.GAME.enemy.score_text = "0" -- Re-mask the opponent's hands until the first enemyInfo of the new blind. MP.GAME.enemy.info_received = false @@ -428,6 +429,7 @@ local function action_enemy_info(p) play_sound("gong", 0.765, 0.4) end + MP.GAME.enemy.real_score = score MP.GAME.enemy.hands = hands_left MP.GAME.enemy.skips = skips MP.GAME.enemy.lives = lives diff --git a/ui/game/timer.lua b/ui/game/timer.lua index fba9256f6..39aac5fb5 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -6,9 +6,11 @@ function MP.UI.cam_timer_opponent() if MP.GAME.timer <= 0 then return false end if MP.GAME.nemesis_timer_started then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end - if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.score) then return true end - if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.score) then return MP.GAME.pvp_reached_first end + if MP.LOBBY.config.hide_score_until_played and not MP.GAME.enemy.info_received then return false end + if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.real_score) then return true end + if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.real_score) then return MP.GAME.pvp_reached_first end return false end return MP.GAME.ready_blind From 70ed3b99708c55d3370f76029bef57460360103e Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Sun, 5 Jul 2026 15:09:09 +0300 Subject: [PATCH 12/24] Return blind select sound --- ui/game/round.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/game/round.lua b/ui/game/round.lua index e9fc24969..673b02795 100644 --- a/ui/game/round.lua +++ b/ui/game/round.lua @@ -36,7 +36,15 @@ end local ease_round_ref = ease_round function ease_round(mod) if MP.is_mp_or_ghost() and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then - G.GAME.round = G.GAME.round + mod + G.E_MANAGER:add_event(Event({ + trigger = 'immediate', + func = function() + G.GAME.round = G.GAME.round + mod + play_sound('timpani', 0.8) + play_sound('generic1') + return true + end + })) return end ease_round_ref(mod) From 9b9b9a92a536a68cb0a9870af3cddd2d11fa61f1 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Sun, 5 Jul 2026 21:35:26 +0300 Subject: [PATCH 13/24] More strict timer checks --- core.lua | 3 +++ networking/action_handlers.lua | 11 +++++++---- ui/game/functions.lua | 1 + ui/game/timer.lua | 6 +++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/core.lua b/core.lua index e35ec61cd..fe30e986b 100644 --- a/core.lua +++ b/core.lua @@ -245,8 +245,11 @@ function MP.reset_game_states() highest_score = MP.INSANE_INT.empty(), timer = MP.UTILS.timer_base(), timer_started = false, + nemesis_timer_started = false, + nemesis_timer_was_started = false, timer_consumed = false, pvp_reached = false, + pvp_reached_first = false, pvp_countdown = 0, real_money = 0, ce_cache = false, diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index ea454a99c..d19c5d08f 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -312,6 +312,11 @@ end local function begin_pvp_blind() if MP.GAME.next_blind_context then G.FUNCS.select_blind(MP.GAME.next_blind_context) + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false + MP.GAME.timer_consumed = false + MP.GAME.timer = MP.UTILS.pvp_timer_base() else sendErrorMessage("No next blind context", "MULTIPLAYER") end @@ -328,10 +333,6 @@ local function action_start_blind(p) MP.GAME.enemy.info_received = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false - MP.GAME.timer_started = false - MP.GAME.nemesis_timer_started = false - MP.GAME.timer_consumed = false - MP.GAME.timer = MP.UTILS.pvp_timer_base() MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player MP.UI.start_pvp_countdown(begin_pvp_blind) end @@ -462,6 +463,7 @@ local function action_end_pvp(p) MP.GAME.timer_consumed = false MP.GAME.timer_started = false MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false MP.GAME.pvp_reached_first = false @@ -999,6 +1001,7 @@ local function action_start_ante_timer(p) end if from_nemesis then MP.GAME.nemesis_timer_started = true + MP.GAME.nemesis_timer_was_started = true else MP.GAME.timer_started = true end diff --git a/ui/game/functions.lua b/ui/game/functions.lua index cd018ac69..57c64f5e5 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -89,6 +89,7 @@ G.FUNCS.skip_blind = function(e) and not MP.GAME.timer_started and not MP.GAME.nemesis_timer_started and not MP.GAME.timer_consumed + and not MP.GAME.nemesis_timer_was_started and not MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 then diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 39aac5fb5..4254c14bd 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -5,6 +5,7 @@ function MP.UI.cam_timer_opponent() if MP.GAME.pvp_countdown_in_progress then return false end if MP.GAME.timer <= 0 then return false end if MP.GAME.nemesis_timer_started then return false end + if MP.GAME.enemy.location_type == "loc_ready" then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end @@ -21,9 +22,7 @@ function G.FUNCS.mp_timer_button(e) -- but the button still needs to fire โ€” pressing it broadcasts startAnteTimer, -- which is what flips the opponent's nemesis_timer_started and triggers 2x. if MP.UI.cam_timer_opponent() then - if MP.GAME.timer <= 0 then - return - elseif not MP.GAME.timer_started then + if not MP.GAME.timer_started then MP.ACTIONS.start_ante_timer() else MP.ACTIONS.pause_ante_timer() @@ -308,6 +307,7 @@ function Game:update(dt) if not MP.LOBBY.code then return end if not MP.LOBBY.config.timer then return end if MP.GAME.timer_consumed then return end + if MP.GAME.pvp_countdown_in_progress then return end if not MP.GAME.timer or MP.GAME.timer <= 0 then return end if MP.is_layer_active("speedlatro_timer") then return end From 091499f9660f7f30325b13b7240c3ce3f3cf0991 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Mon, 6 Jul 2026 00:29:23 +0300 Subject: [PATCH 14/24] Get Logs and Open Parser buttons in game over screen --- localization/en-us.lua | 2 ++ ui/game/functions.lua | 41 +++++++++++++++++++++++++++++++++++++++++ ui/game/game_end.lua | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index adc86f1ed..6e9ca236e 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1446,6 +1446,8 @@ return { loc_selecting = "Selecting", loc_shop = "Shopping before", loc_playing = "Playing", + b_get_log_file = "Get Logs", + b_open_log_parser = "Log Parser", }, v_dictionary = { a_mp_art = { diff --git a/ui/game/functions.lua b/ui/game/functions.lua index 57c64f5e5..f834e027a 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -416,6 +416,47 @@ function MP.UI.show_asteroid_hand_level_up() SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 }) end +function G.FUNCS.mp_open_log_parser(e) + love.system.openURL("https://balatromp.com/log-parser") +end + +function G.FUNCS.mp_get_lovely_log_file(e) + local file_path = require("lovely").log_path + local os_name = love.system.getOS() + + local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" + end + + local function fileUri(path) + path = path:gsub("\\", "/") + return "file://" .. path:gsub("([^A-Za-z0-9%-%._~/%:/])", function(c) + return string.format("%%%02X", c:byte()) + end) + end + + local ok + if os_name == "Windows" then + ok = os.execute('explorer.exe /select,"' .. file_path:gsub("/", "\\") .. '"') + elseif os_name == "OS X" then + os.execute("open -R " .. shellQuote(file_path)) + elseif os_name == "Linux" then + local cmd = + "dbus-send --session " .. + "--dest=org.freedesktop.FileManager1 " .. + "--type=method_call " .. + "/org/freedesktop/FileManager1 " .. + "org.freedesktop.FileManager1.ShowItems " .. + "array:string:" .. shellQuote(fileUri(file_path)) .. " string:''" + + ok = os.execute(cmd) + end + if not ok then + local parent_dir = file_path:match("^(.*)[/\\][^/\\]*$") or "." + love.system.openURL(fileUri(parent_dir)) + end +end + --[[ function MP.UI.create_UIBox_Misprint_Display() return { diff --git a/ui/game/game_end.lua b/ui/game/game_end.lua index 746c8d458..c55b86685 100644 --- a/ui/game/game_end.lua +++ b/ui/game/game_end.lua @@ -386,7 +386,34 @@ function MP.UI.create_UIBox_mp_game_end(has_won) { n = G.UIT.R, config = { align = "cm", minh = 0.45, minw = 0.1 }, - nodes = {}, + nodes = { + UIBox_button({ + button = "mp_open_log_parser", + label = { localize("b_open_log_parser") }, + minw = 1.75, + maxw = 1.75, + minh = 0.5, + maxh = 0.5, + col = true, + colour = G.C.CHIPS, + padding = 0.1, + scale = 0.4 + }), + { n = G.UIT.C, config = { minw = 0.1 } }, + UIBox_button({ + id = "from_game_won", + button = "mp_get_lovely_log_file", + label = { localize("b_get_log_file") }, + minw = 1.75, + maxw = 1.75, + minh = 0.5, + maxh = 0.5, + col = true, + colour = G.C.CHIPS, + padding = 0.1, + scale = 0.4 + }), + }, }, UIBox_button({ id = "from_game_won", @@ -394,7 +421,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) label = { localize("b_return_lobby") }, minw = 4, maxw = 4, - minh = 0.8, + minh = 0.75, focus_args = { nav = "wide", snap_to = true }, }), UIBox_button({ @@ -402,7 +429,7 @@ function MP.UI.create_UIBox_mp_game_end(has_won) label = { localize("b_leave_lobby") }, minw = 4, maxw = 4, - minh = 0.8, + minh = 0.75, focus_args = { nav = "wide" }, }), }, From f58c903cb78011a9af8072ee9aa19a9a354a3ca1 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Mon, 6 Jul 2026 01:17:28 +0300 Subject: [PATCH 15/24] Add Log Parser and Open Logs in mod config --- ui/smods_menu/config_tab.lua | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ui/smods_menu/config_tab.lua b/ui/smods_menu/config_tab.lua index 578cc0172..bfb5188d8 100644 --- a/ui/smods_menu/config_tab.lua +++ b/ui/smods_menu/config_tab.lua @@ -107,6 +107,36 @@ function MP.UI.create_config_tab() }, }, }, + { + n = G.UIT.R, + config = { + padding = 0.1, + align = "cm", + }, + nodes = { + UIBox_button({ + button = "mp_open_log_parser", + label = { localize("b_open_log_parser") }, + minw = 3, + maxw = 3, + minh = 0.8, + maxh = 0.8, + col = true, + colour = G.C.CHIPS, + }), + UIBox_button({ + id = "from_game_won", + button = "mp_get_lovely_log_file", + label = { localize("b_get_log_file") }, + minw = 3, + maxw = 3, + minh = 0.8, + maxh = 0.8, + col = true, + colour = G.C.CHIPS, + }), + }, + }, }, } return ret From 1e7044837762e4232c0d719948ecc1d673461746 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Mon, 6 Jul 2026 01:39:28 +0300 Subject: [PATCH 16/24] Add "mods' and "collection' button to lobby --- lovely/pause.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lovely/pause.toml b/lovely/pause.toml index 1cd1704f2..28102c7ef 100644 --- a/lovely/pause.toml +++ b/lovely/pause.toml @@ -12,6 +12,16 @@ payload = ''' local unstuck_button = nil local return_to_lobby = nil local leave_lobby = nil + +if MP.LOBBY.code and G.STATE == G.STATES.MENU then + your_collection = UIBox_button({ + label = { localize("b_collection") }, + button = "your_collection", + minw = 5, + id = "your_collection", + }) + mods = UIBox_button{ id = "mods_button", label = {localize('b_mods')}, button = "mods_button", minw = 5} +end ''' match_indent = true times = 1 From 09292965c83cdd0c47e9b17ef4ec71414ad8b44a Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Thu, 9 Jul 2026 21:23:31 +0300 Subject: [PATCH 17/24] always display remaining hands --- networking/action_handlers.lua | 128 ++++++++++++++++----------------- ui/game/blind_hud.lua | 12 +--- 2 files changed, 65 insertions(+), 75 deletions(-) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index d19c5d08f..a493dfb17 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -338,8 +338,7 @@ local function action_start_blind(p) end local function action_enemy_info(p) - local score = MP.INSANE_INT.from_string(p.score) - + local score local hands_left = tonumber(p.handsLeft) local skips = tonumber(p.skips) local lives = tonumber(p.lives) @@ -362,64 +361,68 @@ local function action_enemy_info(p) end end - if score == nil or hands_left == nil then - sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER") - return - end - - if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end + if not p.noScore then + score = MP.INSANE_INT.from_string(p.score) + end - -- PvP timer: stop timer according to score - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.INSANE_INT.greater_than(MP.GAME.score, score) then - MP.GAME.nemesis_timer_started = false - elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then - MP.GAME.nemesis_timer_started = false - else - MP.GAME.timer_started = false - end - end + if score then + if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "ease", - delay = 3, - 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 = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "coeffiocient", -- why is this misspelled - ease_to = score.coeffiocient, - func = function(t) - local mult = 1 - if score.exponent > 0 then mult = 100 end - return math.floor(t * mult) / mult - end, - })) - - G.E_MANAGER:add_event(Event({ - blockable = false, - blocking = false, - trigger = "ease", - delay = 3, - ref_table = MP.GAME.enemy.score, - ref_value = "exponent", - ease_to = score.exponent, - func = function(t) - return math.floor(t) - end, - })) + -- PvP timer: stop timer according to score + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if MP.INSANE_INT.greater_than(MP.GAME.score, score) then + MP.GAME.nemesis_timer_started = false + elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then + MP.GAME.nemesis_timer_started = false + else + MP.GAME.timer_started = false + end + end + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 3, + 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 = 3, + ref_table = MP.GAME.enemy.score, + ref_value = "coeffiocient", -- why is this misspelled + ease_to = score.coeffiocient, + func = function(t) + local mult = 1 + if score.exponent > 0 then mult = 100 end + return math.floor(t * mult) / mult + end, + })) + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 3, + ref_table = MP.GAME.enemy.score, + ref_value = "exponent", + ease_to = score.exponent, + func = function(t) + return math.floor(t) + end, + })) + + MP.GAME.enemy.real_score = score + MP.GAME.enemy.info_received = true + end if MP.GAME.enemy.lives > lives then play_sound("holo1", 0.865, 0.9) @@ -430,12 +433,9 @@ local function action_enemy_info(p) play_sound("gong", 0.765, 0.4) end - MP.GAME.enemy.real_score = score - MP.GAME.enemy.hands = hands_left - MP.GAME.enemy.skips = skips - MP.GAME.enemy.lives = lives - -- We've now heard from the opponent this blind: unmask their hands count. - MP.GAME.enemy.info_received = true + MP.GAME.enemy.hands = hands_left or 0 + MP.GAME.enemy.skips = skips or 0 + MP.GAME.enemy.lives = lives or 0 if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end end diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index d3adc4af9..e0710a8a3 100644 --- a/ui/game/blind_hud.lua +++ b/ui/game/blind_hud.lua @@ -198,17 +198,7 @@ function Blind:disable() end G.FUNCS.multiplayer_blind_chip_UI_scale = function(e) - -- Mask the opponent's hands as "?" until the first enemyInfo arrives this - -- blind (same gating as the hidden score), otherwise mirror the real count. - if - MP.LOBBY.config.hide_score_until_played - and MP.is_pvp_boss() - and not MP.GAME.enemy.info_received - then - MP.GAME.enemy.hands_text = "?" - else - MP.GAME.enemy.hands_text = tostring(MP.GAME.enemy.hands) - end + MP.GAME.enemy.hands_text = tostring(MP.GAME.enemy.hands) -- Hide the opponent's score until we have played a hand this PvP blind, so -- a player can't watch the enemy score before committing their own hand. From 04342d943aedab57593fb84d4b700b5cad0efb13 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Thu, 9 Jul 2026 22:43:06 +0300 Subject: [PATCH 18/24] server-side pvp timer --- core.lua | 1 + networking/action_handlers.lua | 38 ++++++++++++++-------------------- ui/game/timer.lua | 11 +++++----- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/core.lua b/core.lua index fe30e986b..bb9eab294 100644 --- a/core.lua +++ b/core.lua @@ -266,6 +266,7 @@ function MP.reset_game_states() reroll_cost_total = 0, -- Add more stats here in the future }, + pvp_timer_order = nil, } end MP.reset_game_states() diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index a493dfb17..c5a2bb91e 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -333,6 +333,7 @@ local function action_start_blind(p) MP.GAME.enemy.info_received = false MP.GAME.ready_blind = false MP.GAME.pvp_reached = false + MP.GAME.pvp_timer_order = nil MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player MP.UI.start_pvp_countdown(begin_pvp_blind) end @@ -368,17 +369,6 @@ local function action_enemy_info(p) if score then if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end - -- PvP timer: stop timer according to score - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.INSANE_INT.greater_than(MP.GAME.score, score) then - MP.GAME.nemesis_timer_started = false - elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then - MP.GAME.nemesis_timer_started = false - else - MP.GAME.timer_started = false - end - end - G.E_MANAGER:add_event(Event({ blockable = false, blocking = false, @@ -424,6 +414,21 @@ local function action_enemy_info(p) MP.GAME.enemy.info_received = true end + if p.pvpTimerOrder ~= nil then + MP.GAME.pvp_timer_order = p.pvpTimerOrder + -- PvP timer: server determines who and when can activate pvp timer + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then + MP.GAME.nemesis_timer_started = false + if not MP.GAME.timer_started and MP.UI.can_timer_opponent() then + -- TODO: auto-timer activation + end + else + MP.GAME.timer_started = false + end + end + end + if MP.GAME.enemy.lives > lives then play_sound("holo1", 0.865, 0.9) play_sound("gong", 0.765, 0.4) @@ -1161,17 +1166,6 @@ function MP.ACTIONS.play_hand(score, hands_left) MP.GAME.highest_score = insane_int_score end - -- Stop PvP timers according to score - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.enemy.score) then - MP.GAME.nemesis_timer_started = false - elseif MP.INSANE_INT.equal(insane_int_score, MP.GAME.enemy.score) and MP.GAME.pvp_reached_first then - MP.GAME.nemesis_timer_started = false - else - MP.GAME.timer_started = false - end - end - Client.send({ action = "playHand", score = fixed_score, diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 4254c14bd..ae0ab5fd0 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -1,6 +1,6 @@ -- ease_round override moved to game/round.lua -function MP.UI.cam_timer_opponent() +function MP.UI.can_timer_opponent() if not MP.LOBBY.config.timer then return false end if MP.GAME.pvp_countdown_in_progress then return false end if MP.GAME.timer <= 0 then return false end @@ -9,9 +9,7 @@ function MP.UI.cam_timer_opponent() if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end - if MP.LOBBY.config.hide_score_until_played and not MP.GAME.enemy.info_received then return false end - if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.real_score) then return true end - if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.real_score) then return MP.GAME.pvp_reached_first end + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then return true end return false end return MP.GAME.ready_blind @@ -21,7 +19,7 @@ function G.FUNCS.mp_timer_button(e) -- Under pressure_timer the local timer auto-ticks regardless of timer_started, -- but the button still needs to fire โ€” pressing it broadcasts startAnteTimer, -- which is what flips the opponent's nemesis_timer_started and triggers 2x. - if MP.UI.cam_timer_opponent() then + if MP.UI.can_timer_opponent() then if not MP.GAME.timer_started then MP.ACTIONS.start_ante_timer() else @@ -256,7 +254,7 @@ function G.FUNCS.set_timer_box(e) } return end - if not MP.GAME.timer_started and MP.UI.cam_timer_opponent() then + if not MP.GAME.timer_started and MP.UI.can_timer_opponent() then e.config.colour = G.C.IMPORTANT e.children[1].config.object.colours = { G.C.UI.TEXT_LIGHT } return @@ -326,6 +324,7 @@ function Game:update(dt) if is_pvp_timer then -- PvP timer: tick when opponent timering, stop when animations, state checks, pvp blind only if not MP.GAME.nemesis_timer_started then return end + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then return end if G.GAME.current_round.hands_left <= 0 then return end if G.STATE == G.STATES.NEW_ROUND or G.STATE == G.STATES.ROUND_EVAL then return end should_check_animations = true From 7da6baa0218b9191f3e664f34b7a5d31b372dd47 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Thu, 9 Jul 2026 23:21:15 +0300 Subject: [PATCH 19/24] automatic pvp timer --- core.lua | 1 + localization/en-us.lua | 5 ++++ networking/action_handlers.lua | 42 ++++++++++++++++++++++++++-------- ui/game/timer.lua | 6 +++++ ui/smods_menu/config_tab.lua | 18 +++++++++++++++ 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/core.lua b/core.lua index bb9eab294..96b3dafb6 100644 --- a/core.lua +++ b/core.lua @@ -267,6 +267,7 @@ function MP.reset_game_states() -- Add more stats here in the future }, pvp_timer_order = nil, + pvp_timer_activated = false, } end MP.reset_game_states() diff --git a/localization/en-us.lua b/localization/en-us.lua index 6e9ca236e..d8783dfde 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1417,6 +1417,11 @@ return { k_ghost = "Ghost", k_hide_mp_content = "Hide Multiplayer content*", k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets", + k_automatic_pvp_timer = "Automatic PvP timer", + k_automatic_pvp_timer_description = { + "Reactivate PvP timer when became available", + "(Must be activated manually first)" + }, k_timer_sfx = "Timer Sound Effects", ml_mp_kofi_message = { "This mod and game server is", diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index c5a2bb91e..c154cab10 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -334,6 +334,7 @@ local function action_start_blind(p) MP.GAME.ready_blind = false MP.GAME.pvp_reached = false MP.GAME.pvp_timer_order = nil + MP.GAME.pvp_timer_activated = false MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player MP.UI.start_pvp_countdown(begin_pvp_blind) end @@ -414,18 +415,21 @@ local function action_enemy_info(p) MP.GAME.enemy.info_received = true end - if p.pvpTimerOrder ~= nil then + -- PvP timer: server determines who and when can activate pvp timer + if p.pvpTimerOrder ~= nil and MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then MP.GAME.pvp_timer_order = p.pvpTimerOrder - -- PvP timer: server determines who and when can activate pvp timer - if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then - if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then - MP.GAME.nemesis_timer_started = false - if not MP.GAME.timer_started and MP.UI.can_timer_opponent() then - -- TODO: auto-timer activation - end - else - MP.GAME.timer_started = false + if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then + MP.GAME.nemesis_timer_started = false + if + not MP.GAME.timer_started + and MP.UI.can_timer_opponent() + and MP.GAME.pvp_timer_activated + and SMODS.Mods["Multiplayer"].config.automatic_pvp_timer + then + MP.ACTIONS.start_ante_timer() end + else + MP.GAME.timer_started = false end end @@ -472,7 +476,25 @@ local function action_end_pvp(p) MP.GAME.ready_blind = false MP.GAME.pvp_reached = false MP.GAME.pvp_reached_first = false + MP.GAME.pvp_timer_activated = false MP.GAME.score = nil + + -- Sanity check + G.E_MANAGER:add_event(Event({ + func = function() + G.E_MANAGER:add_event(Event({ + func = function() + MP.GAME.timer = MP.UTILS.timer_base() + MP.GAME.timer_started = false + MP.GAME.nemesis_timer_started = false + MP.GAME.nemesis_timer_was_started = false + MP.GAME.pvp_timer_activated = false + return true + end, + })) + return true + end, + })) end local function action_player_info(p) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index ae0ab5fd0..7617a1f06 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -22,8 +22,14 @@ function G.FUNCS.mp_timer_button(e) if MP.UI.can_timer_opponent() then if not MP.GAME.timer_started then MP.ACTIONS.start_ante_timer() + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + MP.GAME.pvp_timer_activated = true + end else MP.ACTIONS.pause_ante_timer() + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + MP.GAME.pvp_timer_activated = false + end end end end diff --git a/ui/smods_menu/config_tab.lua b/ui/smods_menu/config_tab.lua index bfb5188d8..4562df0e1 100644 --- a/ui/smods_menu/config_tab.lua +++ b/ui/smods_menu/config_tab.lua @@ -9,6 +9,24 @@ function MP.UI.create_config_tab() colour = G.C.BLACK, }, nodes = { + { + n = G.UIT.R, + config = { + padding = 0, + align = "cm", + on_demand_tooltip = { + text = localize("k_automatic_pvp_timer_description"), + }, + }, + nodes = { + create_toggle({ + id = "multiplayer_automatic_pvp_timer", + label = localize("k_automatic_pvp_timer"), + ref_table = SMODS.Mods["Multiplayer"].config, + ref_value = "automatic_pvp_timer", + }), + }, + }, { n = G.UIT.R, config = { From 391bdff59210810a34045cff32338429a97e0b9e Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Thu, 9 Jul 2026 23:33:02 +0300 Subject: [PATCH 20/24] PvP timer 60+10 --- layers/pvp_timer.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/layers/pvp_timer.lua b/layers/pvp_timer.lua index b0aa2282d..7476203c4 100644 --- a/layers/pvp_timer.lua +++ b/layers/pvp_timer.lua @@ -1,4 +1,4 @@ MP.Layer("pvp_timer", { - pvp_timer_base_seconds = 90, - pvp_timer_hand_played_increment_seconds = 15, + pvp_timer_base_seconds = 60, + pvp_timer_hand_played_increment_seconds = 10, }) From 1957fedcb6e90b94b89de0b31d191490e69b84a6 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Thu, 9 Jul 2026 23:55:09 +0300 Subject: [PATCH 21/24] Prevent asteroid go into negative levels --- ui/game/functions.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/game/functions.lua b/ui/game/functions.lua index f834e027a..eb9cad98a 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -413,7 +413,9 @@ function MP.UI.show_asteroid_hand_level_up() end end end - SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 }) + if to_big(max_level) >= to_big(1) then + SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 }) + end end function G.FUNCS.mp_open_log_parser(e) From eadd77e1f1f117f2d72858484b70928423d8b993 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Fri, 10 Jul 2026 00:08:32 +0300 Subject: [PATCH 22/24] update version, I guess --- Multiplayer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Multiplayer.json b/Multiplayer.json index 94e83eaf5..64bf5e580 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.5.1", + "version": "0.5.2~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", From 14ea74183df05fcd2e985308a331192e1e855cda Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Fri, 10 Jul 2026 00:58:26 +0300 Subject: [PATCH 23/24] prevent timer during game over/win --- ui/game/timer.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 7617a1f06..3187dd25c 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -6,6 +6,7 @@ function MP.UI.can_timer_opponent() if MP.GAME.timer <= 0 then return false end if MP.GAME.nemesis_timer_started then return false end if MP.GAME.enemy.location_type == "loc_ready" then return false end + if G.STATE == G.STATES.GAME_OVER or MP.GAME.won then return false end if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then if MP.GAME.end_pvp then return false end if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end @@ -307,7 +308,7 @@ function Game:update(dt) math.min(animation_budget_capacity, MP.TIMER_ANIMATION_BUDGET + timer_dt * animation_budget_restore_rate) -- Bail fast: not an MP PvP-timer context - if G.STATE == G.STATES.GAME_OVER then return end + if G.STATE == G.STATES.GAME_OVER or MP.GAME.won then return end if not MP.LOBBY.code then return end if not MP.LOBBY.config.timer then return end if MP.GAME.timer_consumed then return end From 813604f3a6603f2a5c7d08e66d4175fe4d2d4713 Mon Sep 17 00:00:00 2001 From: SleepyG11 Date: Mon, 13 Jul 2026 15:03:35 +0300 Subject: [PATCH 24/24] use real timer for updating opponent's score --- networking/action_handlers.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index c154cab10..e5aebfa1b 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -374,7 +374,8 @@ local function action_enemy_info(p) blockable = false, blocking = false, trigger = "ease", - delay = 3, + delay = 0.75, + timer = "REAL", ref_table = MP.GAME.enemy.score, ref_value = "e_count", ease_to = score.e_count, @@ -387,7 +388,8 @@ local function action_enemy_info(p) blockable = false, blocking = false, trigger = "ease", - delay = 3, + delay = 0.75, + timer = "REAL", ref_table = MP.GAME.enemy.score, ref_value = "coeffiocient", -- why is this misspelled ease_to = score.coeffiocient, @@ -402,7 +404,8 @@ local function action_enemy_info(p) blockable = false, blocking = false, trigger = "ease", - delay = 3, + delay = 0.75, + timer = "REAL", ref_table = MP.GAME.enemy.score, ref_value = "exponent", ease_to = score.exponent,