From 0b6334f558c95f85c2209aa635aab836d1247365 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 8 Jun 2026 17:58:59 +0200 Subject: [PATCH 01/15] chore(release): bump version to 0.4.1~DEV --- Multiplayer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Multiplayer.json b/Multiplayer.json index a5dadd5a..14bfb40e 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.0~pre3-DEV", + "version": "0.4.1~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", From b3a6358b6d37f2687e6f557d479d883c9a6e115a Mon Sep 17 00:00:00 2001 From: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:07:32 +0300 Subject: [PATCH 02/15] fixes (#462) --- ui/game/timer.lua | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index e3b4c659..8665cc76 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 6b3baf4c2580d68d1bf27852de8fb9b97bed74ba Mon Sep 17 00:00:00 2001 From: Fantom-Balatro Date: Mon, 8 Jun 2026 22:00:55 -0600 Subject: [PATCH 03/15] Update standard.lua WSOB Ruleset lets go --- layers/standard.lua | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/layers/standard.lua b/layers/standard.lua index 7c03164e..c3d9d7bf 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", }, From 2a2530d13d0572f2af14b3a5cc3c0c204745dacc Mon Sep 17 00:00:00 2001 From: steph Date: Thu, 18 Jun 2026 11:25:09 +0200 Subject: [PATCH 04/15] 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 --- Multiplayer.json | 2 +- layers/standard.lua | 12 ++++++- localization/en-us.lua | 2 ++ objects/enhancements/mp_glass.lua | 2 +- rulesets/wsob.lua | 33 +++++++++++++++++++ .../play_button/ruleset_selection.lua | 31 +---------------- 6 files changed, 49 insertions(+), 33 deletions(-) create mode 100644 rulesets/wsob.lua diff --git a/Multiplayer.json b/Multiplayer.json index 14bfb40e..5b0e2fe3 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.3~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/layers/standard.lua b/layers/standard.lua index c3d9d7bf..7c03164e 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", }, diff --git a/localization/en-us.lua b/localization/en-us.lua index d0a7b8eb..1b79f93f 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1330,6 +1330,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", diff --git a/objects/enhancements/mp_glass.lua b/objects/enhancements/mp_glass.lua index 32caaa3a..dc322f27 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/rulesets/wsob.lua b/rulesets/wsob.lua new file mode 100644 index 00000000..e4489961 --- /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/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 11f8040d..8003ab35 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 b809b5539f3d718c648ef6b827b4434c4d60b73b Mon Sep 17 00:00:00 2001 From: steph Date: Thu, 18 Jun 2026 11:58:52 +0200 Subject: [PATCH 05/15] feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- 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/wraith_rework.lua | 9 + localization/en-us.lua | 15 + 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 ++ ui/main_menu/play_button/_mutators_wall.lua | 278 ++++++++++++++---- .../play_button/custom_ruleset_editor.lua | 134 ++++++--- 20 files changed, 726 insertions(+), 456 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 diff --git a/layers/economy_mutators.lua b/layers/economy_mutators.lua index d3943a2d..df7fe7b3 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 00000000..526d2554 --- /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 96f54c93..aed0d49a 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 16b23342..49ac226d 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 94c2356b..2034ee0b 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 00000000..72df4666 --- /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 00000000..6268404b --- /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 00000000..6fb5ce33 --- /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 00000000..0abd4972 --- /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/wraith_rework.lua b/layers/wraith_rework.lua new file mode 100644 index 00000000..6fc8200a --- /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/localization/en-us.lua b/localization/en-us.lua index 1b79f93f..99ddfdc3 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -958,6 +958,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 +1149,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 = { diff --git a/objects/challenges/bacon.lua b/objects/challenges/bacon.lua index 532b81f3..16e6b6c7 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 10ccada7..cbd3b148 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 ba44dd48..6c5c0d13 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 b0b2e755..f69aebc9 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 31a90bb3..94020a7c 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 081b60f6..e5cec9ea 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 00000000..0739c4e2 --- /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/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index dfdd7f68..7e51f277 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 b19086fd..0bfb7b21 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" }, From 65fd998adaa99f73a7c33bd354468cd6075c06a7 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 26 Jun 2026 19:50:27 +0100 Subject: [PATCH 06/15] 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 --- 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 + 13 files changed, 921 insertions(+), 19 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/compatibility/Preview/CorePreview.lua b/compatibility/Preview/CorePreview.lua index ee84cffe..0a2fa08e 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 dbb3fe3b..0adcaf88 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 00000000..2f6ad645 --- /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 42856d38..ac5b628c 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 c84ef6eb..679e9a20 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 47fbe745..26379398 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 9ccaebfc..8ade6ff3 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 00000000..f6be1664 --- /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 00000000..7525e718 --- /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 00000000..6b681d56 --- /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 00000000..9c9d2794 --- /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 6c2bd225..cd018ac6 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 8665cc76..cc5bac32 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 9d8d97b3ed584be786e52d453c888712dd4e5acd Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 09:17:51 -0600 Subject: [PATCH 07/15] 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 --- core.lua | 6 ++++ lib/ghost_replay.lua | 2 ++ localization/en-us.lua | 1 + networking/action_handlers.lua | 8 ++++++ ui/game/blind_hud.lua | 28 ++++++++++++++++++- ui/lobby/_lobby_options/options_tab.lua | 5 ++++ .../play_button/play_button_callbacks.lua | 4 +++ 7 files changed, 53 insertions(+), 1 deletion(-) diff --git a/core.lua b/core.lua index ca644cac..64b84a64 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/lib/ghost_replay.lua b/lib/ghost_replay.lua index 8274ab54..e0a27539 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 99ddfdc3..82472859 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1213,6 +1213,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", diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 679e9a20..41874075 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 diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index 136da888..d3adc4af 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/lobby/_lobby_options/options_tab.lua b/ui/lobby/_lobby_options/options_tab.lua index 965d4f6d..563419fd 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/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua index 22919570..f9c89770 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() From e9fee40039302c742b48508cdc598fe111dd7ffa Mon Sep 17 00:00:00 2001 From: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:18:11 +0300 Subject: [PATCH 08/15] 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 --- localization/en-us.lua | 2 +- localization/es_419.lua | 2 +- localization/es_ES.lua | 2 +- localization/fr.lua | 2 +- localization/it.lua | 1 - localization/mi.lua | 2 +- lovely/TheOrder.toml | 18 ++++++++++- lovely/shared_area.toml | 3 +- networking/action_handlers.lua | 56 +++++++++++++++++++--------------- ui/game/game_end.lua | 4 +-- ui/game/timer.lua | 2 ++ 11 files changed, 60 insertions(+), 34 deletions(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index 82472859..8e4427c9 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1419,7 +1419,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 da833e4b..921f7aad 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 cf281b4a..76fd47d0 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 4844f114..192d9a18 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 6c77895c..dc90d661 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 bf1ed58c..b42cd9de 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/lovely/TheOrder.toml b/lovely/TheOrder.toml index 67bca2a2..cd4584f9 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 f142500f..19d223c0 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 41874075..88f3927f 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -804,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 @@ -1451,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 @@ -1472,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/ui/game/game_end.lua b/ui/game/game_end.lua index 63a45f72..746c8d45 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 cc5bac32..fba9256f 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 From c05c4e3b8d79b79944e631d46dac1e80d58cacfd Mon Sep 17 00:00:00 2001 From: EchoHeli Date: Wed, 1 Jul 2026 09:18:44 -0600 Subject: [PATCH 09/15] Update Phantom Edition Text (#464) The current phantom edition text is somewhat confusing, this looks to make it clear what this edition does. --- localization/en-us.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index 8e4427c9..adc86f1e 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.", }, }, }, From 648ed6611981f599274fced882c3bb67bf724a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Qu=E1=BB=91c=20L=C3=A2n?= <62930566+minotour4869@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:19:03 +0700 Subject: [PATCH 10/15] feat: update vietnamese's localization (wip) (#468) --- localization/vi.lua | 418 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 402 insertions(+), 16 deletions(-) diff --git a/localization/vi.lua b/localization/vi.lua index 2e42c778..6fa7362b 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 From daafb52a84cff6682ec2251babfff92c51e01215 Mon Sep 17 00:00:00 2001 From: Connor Mills Date: Wed, 1 Jul 2026 09:38:03 -0600 Subject: [PATCH 11/15] Season 7 changese --- Multiplayer.json | 2 +- layers/standard.lua | 5 +++++ objects/jokers/standard/ticket.lua | 2 +- rulesets/ranked.lua | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Multiplayer.json b/Multiplayer.json index 5b0e2fe3..cd48c4c7 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.3~DEV", + "version": "0.5.0~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/layers/standard.lua b/layers/standard.lua index 7c03164e..4e1d90a4 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/objects/jokers/standard/ticket.lua b/objects/jokers/standard/ticket.lua index 74e818d2..bcb15f5f 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 20fa35d3..21bf6239 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() From e96b513efb26e994056adbbeee2237e6f5c6b50d Mon Sep 17 00:00:00 2001 From: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:03:06 +0300 Subject: [PATCH 12/15] Nemesis timer display (#495) --- localization/en-us.lua | 1 + networking/action_handlers.lua | 26 +++++- ui/game/timer.lua | 159 +++++++++++++++++++++++++++------ ui/lobby/lobby.lua | 11 +++ 4 files changed, 166 insertions(+), 31 deletions(-) diff --git a/localization/en-us.lua b/localization/en-us.lua index adc86f1e..e2fc833e 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1385,6 +1385,7 @@ return { k_amount_short = "Amt.", k_filed_ex = "Filed!", k_timer = "Timer", + k_nemesis_timer = "Nemesis", k_mods_list = "Mods List", k_enemy_jokers = "Enemy Jokers", k_your_jokers = "Your Jokers", diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 88f3927f..d2aa1774 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -26,9 +26,16 @@ local json = require("json") Client = {} +local no_log_actions = { + dataSync = true, + keepAlive = true, + keepAliveAck = true, +} + function Client.send(msg) + local should_send_log = not (msg and msg.action and no_log_actions[msg.action]) msg = json.encode(msg) - if msg ~= '{"action":"keepAliveAck"}' then + if should_send_log then sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER") end love.thread.getChannel("uiToNetwork"):push(msg) @@ -781,6 +788,12 @@ local function action_magnet_response(p) sendTraceMessage(string.format("Received magnet joker: %s", MP.UTILS.joker_to_string(card)), "MULTIPLAYER") end +local function action_data_sync(data) + if data.timer then + MP.GAME.enemy.last_timer = data.timer or 0 + end +end + function G.FUNCS.load_end_game_jokers() local card_area_save, success, err @@ -1380,6 +1393,14 @@ function MP.ACTIONS.update_player_usernames() end end +function MP.ACTIONS.data_sync() + local timer = (MP.is_layer_active("speedlatro_timer") and MP.speedlatro_timer and MP.speedlatro_timer.real) or MP.GAME.timer + Client.send({ + action = "dataSync", + timer = timer + }) +end + local function string_to_table(str) local tbl = {} for part in string.gmatch(str, "([^,]+)") do @@ -1438,6 +1459,7 @@ local HANDLERS = { moddedAction = action_modded_action, error = action_error, keepAlive = action_keep_alive, + dataSync = action_data_sync, } function MP.register_action(name, cb) @@ -1476,7 +1498,7 @@ function Game:update(dt) local ok, parsedAction = pcall(json.decode, msg) if ok then - if not ((parsedAction.action == "keepAlive") or (parsedAction.action == "keepAliveAck")) then + if not no_log_actions[parsedAction.action] 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 diff --git a/ui/game/timer.lua b/ui/game/timer.lua index fba9256f..624e82a6 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -2,13 +2,13 @@ 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.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 - if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.score) then return MP.GAME.pvp_reached_first end + if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.score) then return MP.GAME.pvp_reached_first end return false end return MP.GAME.ready_blind @@ -41,6 +41,8 @@ function MP.UI.timer_hud() colour = G.C.DYN_UI.BOSS_MAIN, emboss = 0.05, r = 0.1, + hover = true, + collideable = true, }, nodes = { { @@ -69,6 +71,8 @@ function MP.UI.timer_hud() id = "row_round_text", func = "set_timer_box", button = "mp_timer_button", + hover = true, + collideable = true, }, nodes = { { @@ -105,6 +109,86 @@ function MP.UI.timer_hud() } end end +function MP.UI.nemesis_timer_hud() + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm", + padding = 0.05, + minw = 1.45, + minh = 1, + colour = G.C.DYN_UI.BOSS_MAIN, + emboss = 0.05, + r = 0.1, + }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", maxw = 1.35 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_nemesis_timer"), + minh = 0.33, + scale = 0.34, + colour = G.C.UI.TEXT_LIGHT, + shadow = true, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { + align = "cm", + r = 0.1, + minw = 1.2, + colour = G.C.DYN_UI.BOSS_DARK, + id = "row_round_text", + }, + nodes = { + { + n = G.UIT.O, + config = { + object = DynaText({ + string = { + { + ref_table = setmetatable({}, { + __index = function() + if not MP.GAME.enemy or not MP.GAME.enemy.last_timer then + return "0.0" + end + -- All numbers bigger then 10 - display as integer + -- Also accounting for rounding to prevent 10.0 to be displayed + if MP.GAME.enemy.last_timer > 9.95 then + return string.format("%d", MP.GAME.enemy.last_timer) + end + -- Less than 10 - display decimal part + return string.format("%.1f", MP.GAME.enemy.last_timer) + end, + }), + ref_value = "timer", + }, + }, -- sorry + colours = { G.C.IMPORTANT }, + shadow = true, + scale = 0.8, + }), + id = "timer_UI_count", + }, + }, + }, + }, + }, + }, + }, + } +end function MP.UI.start_pvp_countdown(callback) local seconds = countdown_seconds @@ -112,7 +196,7 @@ function MP.UI.start_pvp_countdown(callback) if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.pvp_countdown_seconds then seconds = MP.LOBBY.config.pvp_countdown_seconds end - MP.GAME.pvp_countdown_in_progress = true + MP.GAME.pvp_countdown_in_progress = true MP.GAME.pvp_countdown = seconds G.CONTROLLER.locks.enter_pvp = true @@ -120,19 +204,19 @@ function MP.UI.start_pvp_countdown(callback) local function show_next() if MP.GAME.pvp_countdown <= 0 then if callback then callback() end - G.E_MANAGER:add_event(Event({ - blocking = false, - func = function() - G.E_MANAGER:add_event(Event({ - blocking = false, - func = function() - MP.GAME.pvp_countdown_in_progress = nil - return true - end, - })) - return true - end, - })) + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + MP.GAME.pvp_countdown_in_progress = nil + return true + end, + })) + return true + end, + })) G.E_MANAGER:add_event(Event({ no_delete = true, trigger = "after", @@ -227,7 +311,7 @@ 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_value = MP.GAME.ready_blind and 0 or -(MP.speedlatro_timer.real or 0) + 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 @@ -246,6 +330,23 @@ SMODS.Gradient({ }) function G.FUNCS.set_timer_box(e) + if e.states and e.parent and e.parent.states then + local is_hovered = e.states.hover.is or e.parent.states.hover.is + if is_hovered and not e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info = UIBox({ + definition = MP.UI.nemesis_timer_hud(), + config = { + parent = e.parent, + major = e.parent, + align = "tm", + offset = { x = 0, y = -0.1 }, + }, + }) + elseif not is_hovered and e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info:remove() + e.parent.children.mp_timer_info = nil + end + end if MP.LOBBY.config.timer then if MP.GAME.timer_started or MP.GAME.nemesis_timer_started then e.config.colour = G.C.DYN_UI.BOSS_DARK @@ -261,7 +362,7 @@ function G.FUNCS.set_timer_box(e) return end e.config.colour = G.C.DYN_UI.BOSS_DARK - e.children[1].config.object.colours = + e.children[1].config.object.colours = { (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) and G.C.IMPORTANT or G.C.UI.TEXT_DARK @@ -322,25 +423,25 @@ function Game:update(dt) local should_check_animations = false if is_pvp_timer then - -- PvP timer: tick when opponent timering, stop when animations, state checks, pvp blind only + -- 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 G.GAME.current_round.hands_left <= 0 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 elseif is_pressure_timer then - -- Pressure timer: tick from the start of a game, stop when reached pvp (unless timered) or animations, not in pvp blind + -- Pressure timer: tick from the start of a game, stop when reached pvp (unless timered) or animations, not in pvp blind if MP.GAME.pvp_reached and not MP.GAME.nemesis_timer_started then return end if MP.GAME.ready_blind or is_pvp_boss then return end should_check_animations = true elseif is_no_animation_timer then - -- No-animation timer: tick when opponen timering, stop when animations, not in pvp + -- No-animation timer: tick when opponen timering, stop when animations, not in pvp if not MP.GAME.nemesis_timer_started then return end if MP.GAME.ready_blind or is_pvp_boss then return end should_check_animations = true 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 + -- 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 @@ -367,8 +468,8 @@ function Game:update(dt) if MP.GAME.timer == 0 then MP.GAME.timer_consumed = true - -- PvP timer: end PvP immediately as a loss (only when timered by opponent) - if is_pvp_timer then + -- 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 diff --git a/ui/lobby/lobby.lua b/ui/lobby/lobby.lua index ab53011b..094c8e1d 100644 --- a/ui/lobby/lobby.lua +++ b/ui/lobby/lobby.lua @@ -548,6 +548,9 @@ function set_main_menu_UI() end end +local data_sync_interval = 5 +local data_sync_delay = data_sync_interval + local in_lobby = false local gameUpdateRef = Game.update ---@diagnostic disable-next-line: duplicate-set-field @@ -561,6 +564,14 @@ function Game:update(dt) MP.reset_game_states() end end + -- Data sync every 5 seconds + if MP.LOBBY.code and G.STAGE == G.STAGES.RUN then + data_sync_delay = data_sync_delay - dt + if data_sync_delay < 0 then + data_sync_delay = data_sync_interval + MP.ACTIONS.data_sync() + end + end gameUpdateRef(self, dt) end From 00ea4a6a6fdf8cbd79d01c33b79785d14edc3dd7 Mon Sep 17 00:00:00 2001 From: Lilo Date: Thu, 16 Jul 2026 01:06:16 +0100 Subject: [PATCH 13/15] Update Multiplayer.json (#481) --- Multiplayer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Multiplayer.json b/Multiplayer.json index cd48c4c7..c526b7f3 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -14,8 +14,8 @@ "badge_text_colour": "FFFFFF", "version": "0.5.0~DEV", "dependencies": [ - "Steamodded (>=1.0.0~BETA-1221a)", - "Lovely (>=0.8)", + "Steamodded (>=1.0.0~BETA-1620a)", + "Lovely (>=0.9)", "Balatro (>=1.0.1o)" ], "conflicts": [ From a441df2579d782a962bae1d99787bdb7ad8932fb Mon Sep 17 00:00:00 2001 From: 12problems <74747315+12problems@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:07:24 -0400 Subject: [PATCH 14/15] Fixed idol gg (#463) * Fixed idol gg * made change according to review by Toneblock * Finalized sorting algo --- compatibility/TheOrder.lua | 293 +++++++++++++++++++++++++++++++++---- 1 file changed, 264 insertions(+), 29 deletions(-) diff --git a/compatibility/TheOrder.lua b/compatibility/TheOrder.lua index 512b5281..332ac4e2 100644 --- a/compatibility/TheOrder.lua +++ b/compatibility/TheOrder.lua @@ -30,9 +30,58 @@ end local original_reset_idol_card = reset_idol_card function reset_idol_card() if MP.should_use_the_order() then + G.GAME.current_round.idol_card.rank = "Ace" G.GAME.current_round.idol_card.suit = "Spades" + -- ---------------------------------------------------------------- + -- Helper: enhancement / seal / edition weights + -- NOTE: field names below assume base-Balatro conventions: + -- card.ability.effect -> "Wild Card" / "Glass Card" / "Lucky Card" / ... + -- card.seal -> "Red" / "Blue" / "Gold" / "Purple" / nil + -- card.edition -> { foil=true } / { holo=true } / { polychrome=true } / nil + -- Adjust these three helpers if your mod stores these differently. + -- ---------------------------------------------------------------- + local function is_wild(card) + return card.ability and card.ability.effect == "Wild Card" + end + + local function edition_weight(card) + local e = card.edition + if not e then return 0.0 end + if e.polychrome then return 1.05 end + if e.glass then return 0.95 end -- some mods track glass as an edition, not enhancement + if e.holo then return 0.50 end + if e.foil then return 0.15 end + return 0.0 + end + + local function enhancement_weight(card) + local eff = card.ability and card.ability.effect + if eff == "Glass Card" then return 0.95 end + if eff == "Lucky Card" then return 0.45 end + if eff == "Steel Card" then return 0.15 end + if eff == "Wild Card" then return 0.15 end + if eff == "Bonus Card" then return 0.10 end + if eff == "Mult Card" then return 0.10 end + if eff == "Gold Card" then return 0.05 end + return 0.0 + end + + local function seal_weight(card) + local s = card.seal + if s == "Red" then return 1.2 end + if s == "Purple" then return 0.15 end + if s == "Gold" then return 0.30 end + if s == "Blue" then return 0.05 end + return 0.0 + end + + -- ---------------------------------------------------------------- + -- Step 1: Build count_map keyed by (value, suit), tracking every + -- physical card so we can later sum seal/edition/enhancement + -- weights and detect wild cards. + -- ---------------------------------------------------------------- local count_map = {} local valid_idol_cards = {} @@ -40,44 +89,234 @@ function reset_idol_card() if v.ability.effect ~= "Stone Card" then local key = v.base.value .. "_" .. v.base.suit if not count_map[key] then - count_map[key] = { count = 0, card = v } + count_map[key] = { + count = 0, + card = v, + value = v.base.value, + suit = v.base.suit, + cards = {}, + wild_count = 0, + } table.insert(valid_idol_cards, count_map[key]) end - count_map[key].count = count_map[key].count + 1 + local entry = count_map[key] + entry.count = entry.count + 1 + table.insert(entry.cards, v) + if is_wild(v) then + entry.wild_count = entry.wild_count + 1 + end end end - --failsafe in case all are stone or no cards in deck. Defaults to Ace of Spades + if #valid_idol_cards == 0 then return end - local value_order = {} - for i, rank in ipairs(SMODS.Rank.obj_buffer) do - value_order[rank] = i + -- ---------------------------------------------------------------- + -- Step 2: Rank / suit ordering from SMODS (positional index) + -- ---------------------------------------------------------------- + local rank_index = {} + for i, rank_key in ipairs(SMODS.Rank.obj_buffer) do + rank_index[rank_key] = i end - local suit_order = {} - for i, suit in ipairs(SMODS.Suit.obj_buffer) do - suit_order[suit] = i + local suit_index = {} + for i, suit_key in ipairs(SMODS.Suit.obj_buffer) do + suit_index[suit_key] = i end - table.sort(valid_idol_cards, function(a, b) - -- Sort by count descending first - if a.count ~= b.count then return a.count > b.count end + -- ---------------------------------------------------------------- + -- Step 3: Aggregate per-rank totals (physical only) + wild-by-rank + -- ---------------------------------------------------------------- + local rank_totals = {} -- rank_key -> total physical count across all suits + local wild_by_rank = {} -- rank_key -> total wild-enhanced count across all suits + local distinct_ranks_set = {} + + for _, entry in ipairs(valid_idol_cards) do + local r = entry.value + rank_totals[r] = (rank_totals[r] or 0) + entry.count + wild_by_rank[r] = (wild_by_rank[r] or 0) + entry.wild_count + distinct_ranks_set[r] = true + end + + local distinct_ranks = 0 + for _ in pairs(distinct_ranks_set) do + distinct_ranks = distinct_ranks + 1 + end + + local total_cards = 0 + for _, entry in ipairs(valid_idol_cards) do + total_cards = total_cards + entry.count + end + + local raw_mean_by_number = total_cards / distinct_ranks + + -- ---------------------------------------------------------------- + -- Step 4: Face / low pools + baselines (unchanged, mean-based, + -- rank-restricted: only face ranks / nominal 2-5 ranks qualify) + -- ---------------------------------------------------------------- + local face_pool = 0 + local low_pool = 0 + local face_ranks_present = 0 + local low_ranks_present = 0 + + for rank_key, total in pairs(rank_totals) do + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj then + if rank_obj.face then + face_pool = face_pool + total + face_ranks_present = face_ranks_present + 1 + elseif rank_obj.nominal and rank_obj.nominal >= 2 and rank_obj.nominal <= 5 then + low_pool = low_pool + total + low_ranks_present = low_ranks_present + 1 + end + end + end + + local function round_to_nearest_05(x) + return math.floor(x * 20 + 0.5) / 20 + end - local a_suit = a.card.base.suit - local b_suit = b.card.base.suit - if suit_order[a_suit] ~= suit_order[b_suit] then return suit_order[a_suit] < suit_order[b_suit] end + local face_baseline = round_to_nearest_05(raw_mean_by_number * face_ranks_present) + local low_baseline = round_to_nearest_05(raw_mean_by_number * low_ranks_present) - local a_value = a.card.base.value - local b_value = b.card.base.value - return value_order[a_value] < value_order[b_value] + local W_GEN = 0.05 + local GEN_FLOOR = 0.00 + + local function face_score_for_rank(rank_key) + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj and rank_obj.face then + return math.max(GEN_FLOOR, W_GEN * 1.1 * math.max(0.0, face_pool - face_baseline)) + end + return 0.0 + end + + local function low_score_for_rank(rank_key) + local rank_obj = SMODS.Ranks[rank_key] + if rank_obj and rank_obj.nominal and rank_obj.nominal >= 2 and rank_obj.nominal <= 5 then + return math.max(GEN_FLOOR, W_GEN * math.max(0.0, low_pool - low_baseline)) + end + return 0.0 + end + + -- ---------------------------------------------------------------- + -- Step 5: Previous rank (positional wrap: index 1 -> last index) + -- ---------------------------------------------------------------- + local function previous_rank_key(rank_key) + local idx = rank_index[rank_key] + if not idx then return nil end + if idx == 1 then + return SMODS.Rank.obj_buffer[#SMODS.Rank.obj_buffer] + else + return SMODS.Rank.obj_buffer[idx - 1] + end + end + + -- ---------------------------------------------------------------- + -- Step 6: Tier weights / achievability weights + -- ---------------------------------------------------------------- + local TARGET_COPIES = 5 -- how many exact copies we're trying to reach + + local W_EDITION_A = 1.3 -- Tier A: extra effect of enhanced cards (quality-dominant tier) + local W_EDITION_B = 0.7 -- Tier B: cares less about this bonus and more about quantity + local W_COUNT_A = 0.5 -- Tier A: reward per existing copy (quality-dominant tier) + local W_MAIN = 2.0 -- Tier B: reward per existing copy (progress toward 5) + local W_OFF = 1.0 -- Tier B: suit-changer potential, capped by need + local W_STR = 1.0 -- Tier B: Strength potential, capped by need + + -- ---------------------------------------------------------------- + -- Step 7: Compute total score for each distinct (rank, suit) entry + -- ---------------------------------------------------------------- + for _, entry in ipairs(valid_idol_cards) do + local rank_key = entry.value + local suit_key = entry.suit + local own_count = entry.count + + -- Effective count: physical copies + wild cards of same rank, other suits + local wild_elsewhere = (wild_by_rank[rank_key] or 0) - entry.wild_count + local effective_count = own_count + wild_elsewhere + + -- Quality terms (apply in both tiers) + local face_score = face_score_for_rank(rank_key) + local low_score = low_score_for_rank(rank_key) + local seal_score = 0.0 + local edition_score = 0.0 + for _, card in ipairs(entry.cards) do + seal_score = seal_score + seal_weight(card) + edition_score = edition_score + edition_weight(card) + enhancement_weight(card) + end + + local main_hit, off_hit, strength_adj = 0.0, 0.0, 0.0 + local tier + + if effective_count >= TARGET_COPIES then + -- ------------------------------------------------------ + -- TIER A: already at/above 5 — no operations required. + -- Score by raw count + quality only; achievability terms + -- (main/off/strength) don't apply, there's no gap to close. + -- ------------------------------------------------------ + tier = 1 + entry.total_score = (W_COUNT_A * effective_count) + + face_score + low_score + ((seal_score + edition_score) * W_EDITION_A) + else + -- ------------------------------------------------------ + -- TIER B: below 5 — operations are required. Achievability + -- terms are capped both by their mechanical limit AND by + -- the actual gap remaining (closing more than the gap + -- doesn't make the card any easier to complete). + -- ------------------------------------------------------ + tier = 0 + local needed = TARGET_COPIES - effective_count + + -- Main Hit: direct reward for existing progress + main_hit = W_MAIN * effective_count + + -- Off Hit: suit-changer potential (non-wild, off-suit, same rank) + -- capped at 3 (suit-changer limit) and by remaining need + local convertible_pool = (rank_totals[rank_key] or 0) - own_count - wild_elsewhere + off_hit = W_OFF * math.min(3, math.max(0.0, convertible_pool), needed) + + -- Strength Adjacent: same-suit rank-1 neighbor (+ off-suit wild + -- at rank-1), capped at 2 (Strength limit) and by remaining need + local prev_rank = previous_rank_key(rank_key) + local neighbor_count = 0 + if prev_rank then + local neighbor_key = prev_rank .. "_" .. suit_key + local neighbor_entry = count_map[neighbor_key] + local physical_same_suit = neighbor_entry and neighbor_entry.count or 0 + local neighbor_wild_same_suit = neighbor_entry and neighbor_entry.wild_count or 0 + local prev_wild_total = wild_by_rank[prev_rank] or 0 + local prev_wild_elsewhere = prev_wild_total - neighbor_wild_same_suit + neighbor_count = physical_same_suit + prev_wild_elsewhere + end + strength_adj = W_STR * math.min(2, neighbor_count, needed) + + entry.total_score = main_hit + off_hit + strength_adj + + face_score + low_score + ((seal_score + edition_score) * W_EDITION_B) + end + + entry.tier = tier + + end + + -- ---------------------------------------------------------------- + -- Step 8: Sort — tier first (A above B), then score, then + -- deterministic tiebreakers. Selection odds in Step 9 remain + -- untouched: pure count / total_weight. + -- ---------------------------------------------------------------- + table.sort(valid_idol_cards, function(a, b) + if a.tier ~= b.tier then return a.tier > b.tier end + if a.total_score ~= b.total_score then return a.total_score > b.total_score end + if (rank_index[a.value] or 0) ~= (rank_index[b.value] or 0) then return (rank_index[a.value] or 0) > (rank_index[b.value] or 0) end + if suit_index[a.suit] ~= suit_index[b.suit] then return suit_index[a.suit] > suit_index[b.suit] end + return (rank_index[a.value] or 0) < (rank_index[b.value] or 0) end) - -- Weighted random selection based on count local total_weight = 0 for _, entry in ipairs(valid_idol_cards) do total_weight = total_weight + entry.count end + if total_weight <= 0 then return end + local raw_random = pseudorandom("idol" .. G.GAME.round_resets.ante) local threshold = 0 @@ -86,19 +325,14 @@ function reset_idol_card() if raw_random < threshold then local idol_card = entry.card sendDebugMessage( - "(Idol) Selected card " - .. idol_card.base.value - .. " of " - .. idol_card.base.suit - .. " with weight " - .. entry.count - .. " of total " - .. total_weight, - "MULTIPLAYER" + string.format( + "Selected %s of %s, with a count of %d", + idol_card.base.value, idol_card.base.suit, entry.count + ), "IdolAlgo" ) G.GAME.current_round.idol_card.rank = idol_card.base.value G.GAME.current_round.idol_card.suit = idol_card.base.suit - G.GAME.current_round.idol_card.id = idol_card.base.id + G.GAME.current_round.idol_card.id = idol_card.base.id break end end @@ -108,6 +342,7 @@ function reset_idol_card() return original_reset_idol_card() end + local original_reset_mail_rank = reset_mail_rank function reset_mail_rank() From f97dd827aa4376d44fb8186d548df1ce034821dc Mon Sep 17 00:00:00 2001 From: SleepyG11 <35398172+SleepyG11@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:11:29 +0300 Subject: [PATCH 15/15] Timer, UI and Asteroid fixes (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> * remove ~DEV for release * Revert ruleset changes (0.4.2) (#466) * Update standard.lua * Update Multiplayer.json * 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 * 0.5.0 Merge (#476) * 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> * 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 * Update version from 0.5.0 to 0.5.1 * 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 * Fix PvP timer toggle when it shouldn't * just in case * PvP + hidden score fixes (#479) * Fix PvP timer toggle when it shouldn't * just in case * Return blind select sound * More strict timer checks * Get Logs and Open Parser buttons in game over screen * Add Log Parser and Open Logs in mod config * Add "mods' and "collection' button to lobby * always display remaining hands * server-side pvp timer * automatic pvp timer * PvP timer 60+10 * Prevent asteroid go into negative levels * update version, I guess * prevent timer during game over/win * use real timer for updating opponent's score --------- Co-authored-by: Fantom-Balatro Co-authored-by: Stephen Kirk Co-authored-by: Casper JB Co-authored-by: Connor Mills 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/pvp_timer.lua | 4 +- lib/ghost_replay.lua | 2 + localization/en-us.lua | 7 + lovely/pause.toml | 10 ++ networking/action_handlers.lua | 187 ++++++++++++--------- ui/game/blind_hud.lua | 12 +- ui/game/functions.lua | 46 ++++- ui/game/game_end.lua | 33 +++- ui/game/round.lua | 10 +- ui/game/timer.lua | 296 +++++++++++++++++---------------- ui/smods_menu/config_tab.lua | 48 ++++++ 13 files changed, 422 insertions(+), 241 deletions(-) diff --git a/Multiplayer.json b/Multiplayer.json index c526b7f3..4bcd84d5 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.5.0~DEV", + "version": "0.5.2~DEV", "dependencies": [ "Steamodded (>=1.0.0~BETA-1620a)", "Lovely (>=0.9)", diff --git a/core.lua b/core.lua index 64b84a64..96b3dafb 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", @@ -244,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, @@ -262,6 +266,8 @@ function MP.reset_game_states() reroll_cost_total = 0, -- Add more stats here in the future }, + pvp_timer_order = nil, + pvp_timer_activated = false, } end MP.reset_game_states() diff --git a/layers/pvp_timer.lua b/layers/pvp_timer.lua index b0aa2282..7476203c 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, }) diff --git a/lib/ghost_replay.lua b/lib/ghost_replay.lua index e0a27539..4828d89b 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/localization/en-us.lua b/localization/en-us.lua index e2fc833e..79934cdc 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1418,6 +1418,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", @@ -1447,6 +1452,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/lovely/pause.toml b/lovely/pause.toml index 1cd1704f..28102c7e 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 diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index d2aa1774..bf04a4a4 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -101,6 +101,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() @@ -152,6 +154,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", @@ -249,6 +254,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") @@ -312,6 +319,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 @@ -322,22 +334,20 @@ 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 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_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 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) @@ -360,64 +370,78 @@ 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 not p.noScore then + score = MP.INSANE_INT.from_string(p.score) + end - if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score 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 = 0.75, + timer = "REAL", + ref_table = MP.GAME.enemy.score, + ref_value = "e_count", + ease_to = score.e_count, + func = function(t) + return math.floor(t) + end, + })) + + G.E_MANAGER:add_event(Event({ + blockable = false, + blocking = false, + trigger = "ease", + delay = 0.75, + timer = "REAL", + 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 = 0.75, + timer = "REAL", + 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 - -- 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 + -- 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 + 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 - - 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.timer_started = false + end + end if MP.GAME.enemy.lives > lives then play_sound("holo1", 0.865, 0.9) @@ -428,11 +452,9 @@ local function action_enemy_info(p) play_sound("gong", 0.765, 0.4) end - 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 @@ -460,10 +482,29 @@ 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 + 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) @@ -970,6 +1011,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 @@ -1001,6 +1044,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 @@ -1160,17 +1204,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, @@ -1303,9 +1336,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 diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua index d3adc4af..e0710a8a 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. diff --git a/ui/game/functions.lua b/ui/game/functions.lua index cd018ac6..eb9cad98 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 @@ -412,7 +413,50 @@ 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) + 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 --[[ diff --git a/ui/game/game_end.lua b/ui/game/game_end.lua index 746c8d45..c55b8668 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" }, }), }, diff --git a/ui/game/round.lua b/ui/game/round.lua index e9fc2496..673b0279 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) diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 624e82a6..c68cf56f 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -1,14 +1,16 @@ -- 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 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 - 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.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then return true end return false end return MP.GAME.ready_blind @@ -18,13 +20,13 @@ 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.GAME.timer <= 0 then - return - elseif not MP.GAME.timer_started then + 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 @@ -71,8 +73,8 @@ function MP.UI.timer_hud() id = "row_round_text", func = "set_timer_box", button = "mp_timer_button", - hover = true, - collideable = true, + hover = true, + collideable = true, }, nodes = { { @@ -110,84 +112,84 @@ function MP.UI.timer_hud() end end function MP.UI.nemesis_timer_hud() - return { - n = G.UIT.ROOT, - config = { align = "cm", colour = G.C.CLEAR }, - nodes = { - { - n = G.UIT.C, - config = { - align = "cm", - padding = 0.05, - minw = 1.45, - minh = 1, - colour = G.C.DYN_UI.BOSS_MAIN, - emboss = 0.05, - r = 0.1, - }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm", maxw = 1.35 }, - nodes = { - { - n = G.UIT.T, - config = { - text = localize("k_nemesis_timer"), - minh = 0.33, - scale = 0.34, - colour = G.C.UI.TEXT_LIGHT, - shadow = true, - }, - }, - }, - }, - { - n = G.UIT.R, - config = { - align = "cm", - r = 0.1, - minw = 1.2, - colour = G.C.DYN_UI.BOSS_DARK, - id = "row_round_text", - }, - nodes = { - { - n = G.UIT.O, - config = { - object = DynaText({ - string = { - { - ref_table = setmetatable({}, { - __index = function() - if not MP.GAME.enemy or not MP.GAME.enemy.last_timer then - return "0.0" - end - -- All numbers bigger then 10 - display as integer - -- Also accounting for rounding to prevent 10.0 to be displayed - if MP.GAME.enemy.last_timer > 9.95 then - return string.format("%d", MP.GAME.enemy.last_timer) - end - -- Less than 10 - display decimal part - return string.format("%.1f", MP.GAME.enemy.last_timer) - end, - }), - ref_value = "timer", - }, - }, -- sorry - colours = { G.C.IMPORTANT }, - shadow = true, - scale = 0.8, - }), - id = "timer_UI_count", - }, - }, - }, - }, - }, - }, - }, - } + return { + n = G.UIT.ROOT, + config = { align = "cm", colour = G.C.CLEAR }, + nodes = { + { + n = G.UIT.C, + config = { + align = "cm", + padding = 0.05, + minw = 1.45, + minh = 1, + colour = G.C.DYN_UI.BOSS_MAIN, + emboss = 0.05, + r = 0.1, + }, + nodes = { + { + n = G.UIT.R, + config = { align = "cm", maxw = 1.35 }, + nodes = { + { + n = G.UIT.T, + config = { + text = localize("k_nemesis_timer"), + minh = 0.33, + scale = 0.34, + colour = G.C.UI.TEXT_LIGHT, + shadow = true, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { + align = "cm", + r = 0.1, + minw = 1.2, + colour = G.C.DYN_UI.BOSS_DARK, + id = "row_round_text", + }, + nodes = { + { + n = G.UIT.O, + config = { + object = DynaText({ + string = { + { + ref_table = setmetatable({}, { + __index = function() + if not MP.GAME.enemy or not MP.GAME.enemy.last_timer then + return "0.0" + end + -- All numbers bigger then 10 - display as integer + -- Also accounting for rounding to prevent 10.0 to be displayed + if MP.GAME.enemy.last_timer > 9.95 then + return string.format("%d", MP.GAME.enemy.last_timer) + end + -- Less than 10 - display decimal part + return string.format("%.1f", MP.GAME.enemy.last_timer) + end, + }), + ref_value = "timer", + }, + }, -- sorry + colours = { G.C.IMPORTANT }, + shadow = true, + scale = 0.8, + }), + id = "timer_UI_count", + }, + }, + }, + }, + }, + }, + }, + } end function MP.UI.start_pvp_countdown(callback) @@ -330,23 +332,23 @@ SMODS.Gradient({ }) function G.FUNCS.set_timer_box(e) - if e.states and e.parent and e.parent.states then - local is_hovered = e.states.hover.is or e.parent.states.hover.is - if is_hovered and not e.parent.children.mp_timer_info then - e.parent.children.mp_timer_info = UIBox({ - definition = MP.UI.nemesis_timer_hud(), - config = { - parent = e.parent, - major = e.parent, - align = "tm", - offset = { x = 0, y = -0.1 }, - }, - }) - elseif not is_hovered and e.parent.children.mp_timer_info then - e.parent.children.mp_timer_info:remove() - e.parent.children.mp_timer_info = nil - end - end + if e.states and e.parent and e.parent.states then + local is_hovered = e.states.hover.is or e.parent.states.hover.is + if is_hovered and not e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info = UIBox({ + definition = MP.UI.nemesis_timer_hud(), + config = { + parent = e.parent, + major = e.parent, + align = "tm", + offset = { x = 0, y = -0.1 }, + }, + }) + elseif not is_hovered and e.parent.children.mp_timer_info then + e.parent.children.mp_timer_info:remove() + e.parent.children.mp_timer_info = nil + end + end if MP.LOBBY.config.timer then if MP.GAME.timer_started or MP.GAME.nemesis_timer_started then e.config.colour = G.C.DYN_UI.BOSS_DARK @@ -356,17 +358,17 @@ 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 end e.config.colour = G.C.DYN_UI.BOSS_DARK - e.children[1].config.object.colours = - { - (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) - and G.C.IMPORTANT or G.C.UI.TEXT_DARK - } + e.children[1].config.object.colours = { + (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) + and G.C.IMPORTANT + or G.C.UI.TEXT_DARK, + } end end @@ -403,10 +405,11 @@ 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 + 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 @@ -425,6 +428,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 @@ -470,18 +474,16 @@ function Game:update(dt) MP.GAME.timer_consumed = true -- 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 + 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 @@ -507,26 +509,28 @@ 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 - -- PvP timer: Increment timer when hand is played during pvp - if MP.is_layer_active("pvp_timer") then - local increment = MP.LOBBY.config.pvp_timer_hand_played_increment_seconds or MP.current_ruleset().pvp_timer_hand_played_increment_seconds or 0 - MP.UI.restore_timer(increment) - end - else - -- No-animation, Pressure timers: Increment timer when hand is played during regular blinds - if MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) then - local increment = MP.LOBBY.config.timer_hand_played_increment_seconds or MP.current_ruleset().timer_hand_played_increment_seconds or 0 - MP.UI.restore_timer(increment) - end - end - end -end \ No newline at end of file + -- 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 + -- PvP timer: Increment timer when hand is played during pvp + if MP.is_layer_active("pvp_timer") then + local increment = MP.LOBBY.config.pvp_timer_hand_played_increment_seconds + or MP.current_ruleset().pvp_timer_hand_played_increment_seconds + or 0 + MP.UI.restore_timer(increment) + end + else + -- No-animation, Pressure timers: Increment timer when hand is played during regular blinds + if MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) then + local increment = MP.LOBBY.config.timer_hand_played_increment_seconds + or MP.current_ruleset().timer_hand_played_increment_seconds + or 0 + MP.UI.restore_timer(increment) + end + end + end +end diff --git a/ui/smods_menu/config_tab.lua b/ui/smods_menu/config_tab.lua index 578cc017..4562df0e 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 = { @@ -107,6 +125,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