From 2536bd505a6fd7cdc24e2796a164c98c27572125 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 15 Jun 2026 11:43:38 +0900 Subject: [PATCH 01/12] 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. --- layers/eeeee.lua | 23 ++++ 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 | 32 +++++ ui/main_menu/play_button/_mutators_wall.lua | 125 ++++++++++++++--- 14 files changed, 483 insertions(+), 303 deletions(-) create mode 100644 layers/eeeee.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/eeeee.lua b/layers/eeeee.lua new file mode 100644 index 000000000..526d2554d --- /dev/null +++ b/layers/eeeee.lua @@ -0,0 +1,23 @@ +-- Eeeee: ~40% of RNG poll keys per ante return a fixed value, not the seed-derived one. +MP.Layer("eeeee", { + on_apply_bans = function() + G.GAME.modifiers.mp_eeeee = true + end, +}) + +local pseudoseed_ref = pseudoseed +function pseudoseed(key, predict_seed) + if G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_eeeee and not G._MP_UNSAVED_PRNG then + G.GAME.mp_eeeee = G.GAME.mp_eeeee or {} + local ante = G.GAME.round_resets.mp_real_ante or G.GAME.round_resets.ante + if not G.GAME.mp_eeeee[ante .. "_" .. key] then + math.randomseed(pseudohash((G.GAME.pseudorandom.seed or "") .. ante .. "mp_eeeee_" .. key)) + G.GAME.mp_eeeee[ante .. "_" .. key] = { + poll = math.random(), + val = math.random(), + } + end + if G.GAME.mp_eeeee[ante .. "_" .. key].poll < 0.4 then return G.GAME.mp_eeeee[ante .. "_" .. key].val end + end + return pseudoseed_ref(key, predict_seed) +end diff --git a/layers/polymorph_spam.lua b/layers/polymorph_spam.lua new file mode 100644 index 000000000..6268404b2 --- /dev/null +++ b/layers/polymorph_spam.lua @@ -0,0 +1,145 @@ +-- Polymorph Spam: every blind, your jokers and consumables re-roll into other centers. +MP.Layer("polymorph_spam", { + on_apply_bans = function() + G.GAME.modifiers.mp_polymorph_spam = true + end, +}) + +local function get_area(card) + if not card then return end + if card.config.center.set == "Joker" then + return G.jokers + elseif card.config.center.consumeable then + return G.consumeables + end + return nil +end + +local function get_pos(card) + local area = get_area(card) + for i, v in ipairs(area.cards) do + if card == v then return i end + end + return nil +end + +local function included(key) + if G.GAME.banned_keys[key] then + return false + elseif G.P_CENTERS[key].mp_include and type(G.P_CENTERS[key].mp_include) == "function" then + return G.P_CENTERS[key]:mp_include() + end + return true +end + +-- i should have separated this into 2 functions but this works i suppose +local function get_transmutations_loc(card) + local done = false + local num = 0 + local area = get_area(card) + local limit = area.config.card_limit + local pos = get_pos(card) or nil + local ret = {} + while not done do + for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do + if included(v.key) then + if num > 0 then + ret[#ret + 1] = { + strings = { + localize({ type = "name_text", key = v.key, set = v.set }), + }, + control = { + C = (num - 1) == (limit - (pos or -1)) and "attention" or nil, + }, + } + if num == 1 then + done = true + break + end + end + if v == card.config.center then + num = limit + else + num = math.max(num - 1, 0) + end + end + end + end + return ret +end + +local function mass_polymorph(area) + for _, card in ipairs(area) do + local done = false + local swap = 0 + while not done do + for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do + if included(v.key) then + if swap == 1 then + card:set_ability(v) + card:set_cost() + done = true + break + end + if v == card.config.center then + swap = get_pos(card) + else + swap = math.max(swap - 1, 0) + end + end + end + end + end +end + +local calculate_context_ref = SMODS.calculate_context +function SMODS.calculate_context(context, return_table, no_resolve) + if G.GAME.modifiers.mp_polymorph_spam and context and type(context) == "table" and context.setting_blind then + mass_polymorph(G.jokers.cards) + mass_polymorph(G.consumeables.cards) + end + return calculate_context_ref(context, return_table, no_resolve) +end + +local set_ability_ref = Card.set_ability +function Card:set_ability(center, initial, delay_sprites) + local ret = set_ability_ref(self, center, initial, delay_sprites) + if G.GAME.modifiers.mp_polymorph_spam and G.OVERLAY_MENU then + if not included(center.key) then self.ability.perma_debuff = true end + end + return ret +end + +local transmute_card = nil -- global local :thinking: + +local generate_card_ui_ref = generate_card_ui +function generate_card_ui(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) + local ret = + generate_card_ui_ref(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) + if G.GAME.modifiers.mp_polymorph_spam then + if card and card.config.center then -- check for card and for tag + if get_area(card) and included(card.config.center.key) then + transmute_card = card -- whatever, surely won't break + generate_card_ui_ref({ key = "mp_transmutations", set = "Other" }, ret) -- don't need to assign this to ret because lua + end + end + end + return ret +end + +-- really inefficient and throws away a metric shit ton of tables +-- thanks to the advancements of my ancestors, i don't have to worry about it +local localize_ref = localize +function localize(args, misc_cat) + if args and type(args) == "table" and args.key and args.key == "mp_transmutations" then -- really safe get + local loc_target = G.localization.descriptions.Other.mp_transmutations.text_parsed + for i = 2, #loc_target do + table.remove(loc_target, 2) + end + local list = get_transmutations_loc(transmute_card) + for i = 1, #list do + loc_target[#loc_target + 1] = { list[i] } + end + end + return localize_ref(args, misc_cat) +end diff --git a/layers/score_instability.lua b/layers/score_instability.lua new file mode 100644 index 000000000..6fb5ce33e --- /dev/null +++ b/layers/score_instability.lua @@ -0,0 +1,71 @@ +-- Score Instability: after scoring, chips and mult are dragged toward each other. +MP.Layer("score_instability", { + on_apply_bans = function() + G.GAME.modifiers.mp_score_instability = true + end, +}) + +-- put hook here ig +local bte_ref = Back.trigger_effect +function Back:trigger_effect(args) + if G.GAME.modifiers.mp_score_instability and args.context == "final_scoring_step" then -- me when i copy plasma deck + local diff = args.chips - args.mult + if diff > 0 then + diff = math.min(diff, args.mult - 1) + elseif diff < 0 then + diff = math.max(diff, -args.chips) + end + args.chips = args.chips + diff + args.mult = args.mult - diff + update_hand_text({ delay = 0 }, { mult = args.mult, chips = args.chips }) + + G.E_MANAGER:add_event(Event({ + func = function() + local text = localize("k_destabilized") + play_sound("timpani", 0.5 / 1.5, 0.4) + play_sound("timpani", 0.5, 0.5) + play_sound("timpani", 0.5 * 1.5, 0.6) + play_sound("tarot1", 1.5) + ease_colour(G.C.UI_CHIPS, G.C.PERISHABLE) + ease_colour(G.C.UI_MULT, G.C.ETERNAL) + attention_text({ + scale = 1.4, + text = text, + hold = 2, + align = "cm", + offset = { x = 0, y = -2.7 }, + major = G.play, + }) + G.E_MANAGER:add_event(Event({ + trigger = "after", + blockable = false, + blocking = false, + delay = 4.3, + func = function() + ease_colour(G.C.UI_CHIPS, G.C.BLUE, 2) + ease_colour(G.C.UI_MULT, G.C.RED, 2) + return true + end, + })) + G.E_MANAGER:add_event(Event({ + trigger = "after", + blockable = false, + blocking = false, + no_delete = true, + delay = 6.3, + func = function() + G.C.UI_CHIPS[1], G.C.UI_CHIPS[2], G.C.UI_CHIPS[3], G.C.UI_CHIPS[4] = + G.C.BLUE[1], G.C.BLUE[2], G.C.BLUE[3], G.C.BLUE[4] + G.C.UI_MULT[1], G.C.UI_MULT[2], G.C.UI_MULT[3], G.C.UI_MULT[4] = + G.C.RED[1], G.C.RED[2], G.C.RED[3], G.C.RED[4] + return true + end, + })) + return true + end, + })) + delay(0.6) + return args.chips, args.mult + end + return bte_ref(self, args) +end diff --git a/layers/shared_pockets.lua b/layers/shared_pockets.lua new file mode 100644 index 000000000..0abd4972c --- /dev/null +++ b/layers/shared_pockets.lua @@ -0,0 +1,77 @@ +-- Shared Pockets: hand, jokers and consumables share one pool of slots. +function MP.setup_shared_pockets() + if G.GAME.mp_shared_pockets then return end + G.GAME.modifiers.mp_shared_pockets = true + -- we're gonna do some hardcoded jank on the backend because slots are weird + -- so handling this like a reasonable person is riskier + G.GAME.starting_params.joker_slots = (G.GAME.starting_params.joker_slots or 0) + 1e5 + G.GAME.starting_params.consumable_slots = (G.GAME.starting_params.consumable_slots or 0) + 1e5 + G.GAME.starting_params.hand_size = (G.GAME.starting_params.hand_size or 0) + 7 + G.GAME.mp_shared_pockets = { count = 0, slots = 15 } +end + +MP.Layer("shared_pockets", { + banned_jokers = { + "j_stencil", + }, + on_apply_bans = function() + MP.setup_shared_pockets() + end, +}) + +-- just copy what vanilla does +local cardarea_update_ref = CardArea.update +function CardArea:update(dt) + if self == G.hand then + if G.GAME.modifiers.mp_shared_pockets then + self.config.mp_last_size = self.config.mp_last_size or 0 + local slots = (G.jokers.config.card_count - (G.jokers.config.card_limit - 100005)) + + (G.consumeables.config.card_count - (G.consumeables.config.card_limit - 100002)) + if slots ~= self.config.last_poll_size then + self:change_size(self.config.mp_last_size - slots) + self.config.mp_last_size = slots + end + end + end + local ret = cardarea_update_ref(self, dt) + if G.GAME.modifiers.mp_shared_pockets then + G.GAME.mp_shared_pockets.count = G.hand.config.card_count + + G.jokers.config.card_count + + G.consumeables.config.card_count + G.GAME.mp_shared_pockets.limit = G.hand.config.card_limit + + G.jokers.config.card_count + + G.consumeables.config.card_count + end + return ret +end + +-- ok look this might be a giant hack BUT +-- yeah i have nothing to say +-- whatever +local uie_update_text_ref = UIElement.update_text +function UIElement:update_text() + if G.GAME.modifiers.mp_shared_pockets then + if self.config.ref_value == "card_count" then + if + self.config.ref_table == G.hand.config + or self.config.ref_table == G.jokers.config + or self.config.ref_table == G.consumeables.config + then + self.config.ref_table = G.GAME.mp_shared_pockets + self.config.ref_value = "count" + end + end + if self.config.ref_value == "total_slots" then + if + self.config.ref_table == G.hand.config.card_limits + or self.config.ref_table == G.jokers.config.card_limits + or self.config.ref_table == G.consumeables.config.card_limits + then + self.config.ref_table = G.GAME.mp_shared_pockets + self.config.ref_value = "limit" + end + end + end + local ret = uie_update_text_ref(self) + return ret +end diff --git a/layers/wraith_rework.lua b/layers/wraith_rework.lua new file mode 100644 index 000000000..6fc8200a2 --- /dev/null +++ b/layers/wraith_rework.lua @@ -0,0 +1,9 @@ +-- Wraith Rework: swaps vanilla Wraith for c_mp_wraith (random Uncommon + $5). +MP.Layer("wraith_rework", { + reworked_consumables = { + "c_mp_wraith", + }, + banned_silent = { + "c_wraith", + }, +}) diff --git a/localization/en-us.lua b/localization/en-us.lua index d0a7b8eb4..1c3d14f89 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 532b81f3a..16e6b6c76 100644 --- a/objects/challenges/bacon.lua +++ b/objects/challenges/bacon.lua @@ -43,7 +43,7 @@ SMODS.Challenge({ "p_arcana_mega_2", }, }, - } + }, }, apply = function(self) @@ -54,4 +54,4 @@ SMODS.Challenge({ unlocked = function(self) return true end, -}) \ No newline at end of file +}) diff --git a/objects/challenges/balancing_act.lua b/objects/challenges/balancing_act.lua index 10ccada74..cbd3b148b 100644 --- a/objects/challenges/balancing_act.lua +++ b/objects/challenges/balancing_act.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/score_instability.lua (via rules.custom -> mp_score_instability). SMODS.Challenge({ key = "balancing_act", rules = { @@ -13,68 +14,3 @@ SMODS.Challenge({ return true end, }) - --- put hook here ig -local bte_ref = Back.trigger_effect -function Back:trigger_effect(args) - if G.GAME.modifiers.mp_score_instability and args.context == "final_scoring_step" then -- me when i copy plasma deck - local diff = args.chips - args.mult - if diff > 0 then - diff = math.min(diff, args.mult - 1) - elseif diff < 0 then - diff = math.max(diff, -args.chips) - end - args.chips = args.chips + diff - args.mult = args.mult - diff - update_hand_text({ delay = 0 }, { mult = args.mult, chips = args.chips }) - - G.E_MANAGER:add_event(Event({ - func = function() - local text = localize("k_destabilized") - play_sound("timpani", 0.5 / 1.5, 0.4) - play_sound("timpani", 0.5, 0.5) - play_sound("timpani", 0.5 * 1.5, 0.6) - play_sound("tarot1", 1.5) - ease_colour(G.C.UI_CHIPS, G.C.PERISHABLE) - ease_colour(G.C.UI_MULT, G.C.ETERNAL) - attention_text({ - scale = 1.4, - text = text, - hold = 2, - align = "cm", - offset = { x = 0, y = -2.7 }, - major = G.play, - }) - G.E_MANAGER:add_event(Event({ - trigger = "after", - blockable = false, - blocking = false, - delay = 4.3, - func = function() - ease_colour(G.C.UI_CHIPS, G.C.BLUE, 2) - ease_colour(G.C.UI_MULT, G.C.RED, 2) - return true - end, - })) - G.E_MANAGER:add_event(Event({ - trigger = "after", - blockable = false, - blocking = false, - no_delete = true, - delay = 6.3, - func = function() - G.C.UI_CHIPS[1], G.C.UI_CHIPS[2], G.C.UI_CHIPS[3], G.C.UI_CHIPS[4] = - G.C.BLUE[1], G.C.BLUE[2], G.C.BLUE[3], G.C.BLUE[4] - G.C.UI_MULT[1], G.C.UI_MULT[2], G.C.UI_MULT[3], G.C.UI_MULT[4] = - G.C.RED[1], G.C.RED[2], G.C.RED[3], G.C.RED[4] - return true - end, - })) - return true - end, - })) - delay(0.6) - return args.chips, args.mult - end - return bte_ref(self, args) -end diff --git a/objects/challenges/eeeee.lua b/objects/challenges/eeeee.lua index ba44dd482..6c5c0d13e 100644 --- a/objects/challenges/eeeee.lua +++ b/objects/challenges/eeeee.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/eeeee.lua; activated here via rules.custom -> mp_eeeee. SMODS.Challenge({ key = "mp_eeeee", rules = { @@ -9,22 +10,3 @@ SMODS.Challenge({ return true end, }) - -local pseudoseed_ref = pseudoseed -function pseudoseed(key, predict_seed) - if G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_eeeee and not G._MP_UNSAVED_PRNG then - G.GAME.mp_eeeee = G.GAME.mp_eeeee or {} - local ante = G.GAME.round_resets.mp_real_ante or G.GAME.round_resets.ante - if not G.GAME.mp_eeeee[ante.."_"..key] then - math.randomseed(pseudohash((G.GAME.pseudorandom.seed or '')..ante.."mp_eeeee_"..key)) - G.GAME.mp_eeeee[ante.."_"..key] = { - poll = math.random(), - val = math.random(), - } - end - if G.GAME.mp_eeeee[ante.."_"..key].poll < 0.4 then - return G.GAME.mp_eeeee[ante.."_"..key].val - end - end - return pseudoseed_ref(key, predict_seed) -end diff --git a/objects/challenges/planet_tycoon.lua b/objects/challenges/planet_tycoon.lua index b0b2e7550..f69aebc96 100644 --- a/objects/challenges/planet_tycoon.lua +++ b/objects/challenges/planet_tycoon.lua @@ -10,7 +10,7 @@ SMODS.Challenge({ restrictions = { banned_cards = { { id = "v_planet_merchant", ids = { "v_planet_tycoon" } }, - } + }, }, apply = function(self) G.GAME.planet_rate = 360 diff --git a/objects/challenges/polymorph_spam.lua b/objects/challenges/polymorph_spam.lua index 31a90bb31..94020a7c2 100644 --- a/objects/challenges/polymorph_spam.lua +++ b/objects/challenges/polymorph_spam.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/polymorph_spam.lua (via rules.custom -> mp_polymorph_spam). SMODS.Challenge({ key = "polymorph_spam", rules = { @@ -26,142 +27,3 @@ SMODS.Challenge({ return true end, }) - -local function get_area(card) - if not card then return end - if card.config.center.set == "Joker" then - return G.jokers - elseif card.config.center.consumeable then - return G.consumeables - end - return nil -end - -local function get_pos(card) - local area = get_area(card) - for i, v in ipairs(area.cards) do - if card == v then return i end - end - return nil -end - -local function included(key) - if G.GAME.banned_keys[key] then - return false - elseif G.P_CENTERS[key].mp_include and type(G.P_CENTERS[key].mp_include) == "function" then - return G.P_CENTERS[key]:mp_include() - end - return true -end - --- i should have separated this into 2 functions but this works i suppose -local function get_transmutations_loc(card) - local done = false - local num = 0 - local area = get_area(card) - local limit = area.config.card_limit - local pos = get_pos(card) or nil - local ret = {} - while not done do - for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do - if included(v.key) then - if num > 0 then - ret[#ret + 1] = { - strings = { - localize({ type = "name_text", key = v.key, set = v.set }), - }, - control = { - C = (num - 1) == (limit - (pos or -1)) and "attention" or nil, - }, - } - if num == 1 then - done = true - break - end - end - if v == card.config.center then - num = limit - else - num = math.max(num - 1, 0) - end - end - end - end - return ret -end - -local function mass_polymorph(area) - for _, card in ipairs(area) do - local done = false - local swap = 0 - while not done do - for i, v in ipairs(G.P_CENTER_POOLS[card.config.center.set]) do - if included(v.key) then - if swap == 1 then - card:set_ability(v) - card:set_cost() - done = true - break - end - if v == card.config.center then - swap = get_pos(card) - else - swap = math.max(swap - 1, 0) - end - end - end - end - end -end - -local calculate_context_ref = SMODS.calculate_context -function SMODS.calculate_context(context, return_table, no_resolve) - if G.GAME.modifiers.mp_polymorph_spam and context and type(context) == "table" and context.setting_blind then - mass_polymorph(G.jokers.cards) - mass_polymorph(G.consumeables.cards) - end - return calculate_context_ref(context, return_table, no_resolve) -end - -local set_ability_ref = Card.set_ability -function Card:set_ability(center, initial, delay_sprites) - local ret = set_ability_ref(self, center, initial, delay_sprites) - if G.GAME.modifiers.mp_polymorph_spam and G.OVERLAY_MENU then - if not included(center.key) then self.ability.perma_debuff = true end - end - return ret -end - -local transmute_card = nil -- global local :thinking: - -local generate_card_ui_ref = generate_card_ui -function generate_card_ui(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) - local ret = - generate_card_ui_ref(_c, full_UI_table, specific_vars, card_type, badges, hide_desc, main_start, main_end, card) - if G.GAME.modifiers.mp_polymorph_spam then - if card and card.config.center then -- check for card and for tag - if get_area(card) and included(card.config.center.key) then - transmute_card = card -- whatever, surely won't break - generate_card_ui_ref({ key = "mp_transmutations", set = "Other" }, ret) -- don't need to assign this to ret because lua - end - end - end - return ret -end - --- really inefficient and throws away a metric shit ton of tables --- thanks to the advancements of my ancestors, i don't have to worry about it -local localize_ref = localize -function localize(args, misc_cat) - if args and type(args) == "table" and args.key and args.key == "mp_transmutations" then -- really safe get - local loc_target = G.localization.descriptions.Other.mp_transmutations.text_parsed - for i = 2, #loc_target do - table.remove(loc_target, 2) - end - local list = get_transmutations_loc(transmute_card) - for i = 1, #list do - loc_target[#loc_target + 1] = { list[i] } - end - end - return localize_ref(args, misc_cat) -end diff --git a/objects/challenges/shared_pockets.lua b/objects/challenges/shared_pockets.lua index 081b60f64..e5cec9ead 100644 --- a/objects/challenges/shared_pockets.lua +++ b/objects/challenges/shared_pockets.lua @@ -1,3 +1,4 @@ +-- Effect lives in layers/shared_pockets.lua (via rules.custom -> mp_shared_pockets). SMODS.Challenge({ key = "shared_pockets", rules = { @@ -11,63 +12,9 @@ SMODS.Challenge({ }, }, apply = function(self) - -- we're gonna do some hardcoded jank on the backend because slots are weird - -- so handling this like a reasonable person is riskier - G.GAME.starting_params.joker_slots = (G.GAME.starting_params.joker_slots or 0) + 1e5 - G.GAME.starting_params.consumable_slots = (G.GAME.starting_params.consumable_slots or 0) + 1e5 - G.GAME.starting_params.hand_size = (G.GAME.starting_params.hand_size or 0) + 7 - - G.GAME.mp_shared_pockets = {count = 0, slots = 15} + MP.setup_shared_pockets() end, unlocked = function(self) return true end, }) - --- just copy what vanilla does -local cardarea_update_ref = CardArea.update -function CardArea:update(dt) - if self == G.hand then - if G.GAME.modifiers.mp_shared_pockets then - self.config.mp_last_size = self.config.mp_last_size or 0 - local slots = (G.jokers.config.card_count - (G.jokers.config.card_limit - 100005)) + (G.consumeables.config.card_count - (G.consumeables.config.card_limit - 100002)) - if slots ~= self.config.last_poll_size then - self:change_size(self.config.mp_last_size - slots) - self.config.mp_last_size = slots - end - end - end - local ret = cardarea_update_ref(self, dt) - if G.GAME.modifiers.mp_shared_pockets then - G.GAME.mp_shared_pockets.count = G.hand.config.card_count + G.jokers.config.card_count + G.consumeables.config.card_count - G.GAME.mp_shared_pockets.limit = G.hand.config.card_limit + G.jokers.config.card_count + G.consumeables.config.card_count - end - return ret -end - --- ok look this might be a giant hack BUT --- yeah i have nothing to say --- whatever -local uie_update_text_ref = UIElement.update_text -function UIElement:update_text() - if G.GAME.modifiers.mp_shared_pockets then - if self.config.ref_value == "card_count" then - if self.config.ref_table == G.hand.config - or self.config.ref_table == G.jokers.config - or self.config.ref_table == G.consumeables.config then - self.config.ref_table = G.GAME.mp_shared_pockets - self.config.ref_value = "count" - end - end - if self.config.ref_value == "total_slots" then - if self.config.ref_table == G.hand.config.card_limits - or self.config.ref_table == G.jokers.config.card_limits - or self.config.ref_table == G.consumeables.config.card_limits then - self.config.ref_table = G.GAME.mp_shared_pockets - self.config.ref_value = "limit" - end - end - end - local ret = uie_update_text_ref(self) - return ret -end \ No newline at end of file diff --git a/objects/consumables/wraith.lua b/objects/consumables/wraith.lua new file mode 100644 index 000000000..3d1ccdbfc --- /dev/null +++ b/objects/consumables/wraith.lua @@ -0,0 +1,32 @@ +-- Reworked Wraith: random Uncommon joker + $5 (vanilla = random Rare, money to $0). +SMODS.Consumable({ + key = "wraith", + set = "Spectral", + atlas = "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 dfdd7f689..a2b2cd54e 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,10 +87,23 @@ 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 = "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 = "frugal", label = "Frugal", desc = { "Unspent discards pay out $1." } }, + { + key = "wraith_rework", + label = "Kind Wraith", + desc = { "Wraith spawns an Uncommon and", "pays $5 (was Rare, money to $0)." }, + }, }, }, { @@ -95,9 +111,27 @@ local MUTATOR_WALL = { 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 = "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 = "shrinking_hand", + label = "Heavy Pockets", + desc = { "-1 hand size for every $10", "you're holding." }, + }, + { + 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." }, + }, }, }, { @@ -107,17 +141,39 @@ local MUTATOR_WALL = { { key = "gambling_opportunity", label = "No Easy Money", desc = { "No Gold or Lucky cards." } }, { key = "no_uncommons", label = "No Uncommons", desc = { "Uncommon jokers are out", "of the pool." } }, { 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." }, + }, }, }, { 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 = "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 = "pricey_packs", + label = "Pricey Packs", + desc = { "Booster packs cost more", "for each ante you reach." }, + }, + { + 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." } }, }, }, } @@ -125,9 +181,21 @@ local MUTATOR_WALL = { 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)" } }, + { + 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)" }, + }, }, } @@ -307,7 +375,6 @@ 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() @@ -327,12 +394,26 @@ 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 } }, From 18cbcafb3ef29f14d3c9a23685e098b892f97c6a Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 15 Jun 2026 12:28:31 +0900 Subject: [PATCH 02/12] fix broken wraith --- objects/consumables/wraith.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/objects/consumables/wraith.lua b/objects/consumables/wraith.lua index 3d1ccdbfc..0739c4e28 100644 --- a/objects/consumables/wraith.lua +++ b/objects/consumables/wraith.lua @@ -2,7 +2,6 @@ SMODS.Consumable({ key = "wraith", set = "Spectral", - atlas = "Spectral", pos = { x = 5, y = 4 }, cost = 4, unlocked = true, From 871368238cfd317f83c7b2ee26137ee92666c24c Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 15 Jun 2026 12:29:20 +0900 Subject: [PATCH 03/12] 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. --- .../play_button/custom_ruleset_editor.lua | 122 +++++++++++------- 1 file changed, 74 insertions(+), 48 deletions(-) diff --git a/ui/main_menu/play_button/custom_ruleset_editor.lua b/ui/main_menu/play_button/custom_ruleset_editor.lua index b19086fda..5ea0b59c9 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,41 +183,10 @@ 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() - return { - n = G.UIT.R, - config = { align = "cm", padding = 0.12, r = 0.1, colour = G.C.BOOSTER, emboss = 0.05, minw = 15 }, - 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 }, - }, - }, - }, - }, - } -end - - local function soon_pill(label) return { n = G.UIT.C, @@ -204,19 +215,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 +237,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 +276,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 f5f8475590fa885d40952d4ed0621da68fbb4d3b Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 15 Jun 2026 12:36:30 +0900 Subject: [PATCH 04/12] Remove coming-soon line from mutators wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/main_menu/play_button/_mutators_wall.lua | 32 --------------------- 1 file changed, 32 deletions(-) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index a2b2cd54e..5cf2671b6 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -178,27 +178,6 @@ local MUTATOR_WALL = { }, } -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 @@ -382,11 +361,6 @@ function MP.UI.build_mutators_wall() 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" }, @@ -416,12 +390,6 @@ function MP.UI.build_mutators_wall() }, }, { 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 From 61b2b50e6b5aef72303b056f812bd1b226a1746b Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Mon, 15 Jun 2026 18:28:55 +0900 Subject: [PATCH 05/12] 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. --- layers/no_red_seals.lua | 21 +++++++++++++++++++++ ui/main_menu/play_button/_mutators_wall.lua | 1 + 2 files changed, 22 insertions(+) create mode 100644 layers/no_red_seals.lua diff --git a/layers/no_red_seals.lua b/layers/no_red_seals.lua new file mode 100644 index 000000000..72df46665 --- /dev/null +++ b/layers/no_red_seals.lua @@ -0,0 +1,21 @@ +-- No Red Seals: red seals never get applied — pack polls, Certificate, copies, etc. +-- Deja Vu does nothing but apply a Red Seal, so ban it outright rather than leave a +-- dead spectral in the pool +MP.Layer("no_red_seals", { + banned_consumables = { "c_deja_vu" }, + on_apply_bans = function() + G.GAME.modifiers.mp_no_red_seals = true + end, +}) + +-- the "clean" way is to yank Red out of the seal pool. sure. except deja vu hardcodes +-- a red seal, certificate flings random ones, copied cards drag their seal along... and +-- i am not lovely patching every single one of those. no thank you. +-- returning instead of passing nil so it doesn't nuke a seal that was already sitting there +local set_seal_ref = Card.set_seal +function Card:set_seal(_seal, silent, immediate) + if _seal == "Red" and G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_no_red_seals then + return -- not today + end + return set_seal_ref(self, _seal, silent, immediate) +end diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 5cf2671b6..595d85a87 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -140,6 +140,7 @@ local MUTATOR_WALL = { cells = { { key = "gambling_opportunity", label = "No Easy Money", desc = { "No Gold or Lucky cards." } }, { 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", From 4d05c7d5934c5e9c8f5a31421914f725bb72b6be Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:19:42 +0900 Subject: [PATCH 06/12] 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. --- layers/economy_mutators.lua | 17 ++++------------- layers/glass_cannon.lua | 15 +++------------ ui/main_menu/play_button/_mutators_wall.lua | 15 +++++++++------ .../play_button/custom_ruleset_editor.lua | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/layers/economy_mutators.lua b/layers/economy_mutators.lua index d3943a2d3..72497ec7f 100644 --- a/layers/economy_mutators.lua +++ b/layers/economy_mutators.lua @@ -1,22 +1,11 @@ --- 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). - -- Shop prices climb +$1 per purchase, compounding across the run. -- (card.lua: `if G.GAME.modifiers.inflation then G.GAME.inflation = ... + 1`) +-- TODO doesn't work 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 }, }) @@ -34,12 +23,14 @@ MP.Layer("frugal", { -- No cash reward from Small or Big blinds (Boss/PvP payout left intact). -- (blind.lua: dollars zeroed when no_blind_reward[blind_type] is set) +-- TODO doesn't work MP.Layer("spartan", { game_modifiers = { no_blind_reward = { Small = true, Big = true } }, }) -- Booster packs cost +$1 more for each ante you're into the run. -- (card.lua set_cost: cost + round_resets.ante - 1 when booster_ante_scaling) +-- TODO doesn't work MP.Layer("pricey_packs", { game_modifiers = { booster_ante_scaling = true }, }) diff --git a/layers/glass_cannon.lua b/layers/glass_cannon.lua index 16b23342a..ee42d35e9 100644 --- a/layers/glass_cannon.lua +++ b/layers/glass_cannon.lua @@ -1,19 +1,10 @@ --- 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 diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 595d85a87..8fd4f3193 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -91,14 +91,16 @@ local MUTATOR_WALL = { key = "inflation", label = "Inflation", desc = { "Shop prices creep up $1 with", "every card you buy." }, + soon = true, }, { 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." } }, - { key = "frugal", label = "Frugal", desc = { "Unspent discards pay out $1." } }, + { 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", @@ -110,7 +112,7 @@ local MUTATOR_WALL = { name = "MAYHEM", colour = G.C.PURPLE, cells = { - { key = "flipped_cards", label = "Blind Poker", desc = { "Your hand is dealt face-down." } }, + { key = "flipped_cards", label = "Blind Poker", desc = { "Your hand is dealt face-down." }, soon = true }, { key = "debuff_played_cards", label = "Dead Cards", @@ -205,7 +207,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 @@ -246,7 +248,8 @@ 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 = disabled and { title = cell.label, text = { "Coming soon." } } + or { title = cell.label, text = cell.desc }, }, nodes = { { @@ -272,7 +275,7 @@ 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 diff --git a/ui/main_menu/play_button/custom_ruleset_editor.lua b/ui/main_menu/play_button/custom_ruleset_editor.lua index 5ea0b59c9..0bfb7b215 100644 --- a/ui/main_menu/play_button/custom_ruleset_editor.lua +++ b/ui/main_menu/play_button/custom_ruleset_editor.lua @@ -198,7 +198,21 @@ local function soon_pill(label) } end +-- 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.ROOT, + config = { align = "cm", colour = G.C.CLEAR, minh = 6, minw = 15 }, + nodes = { + { 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 From 77a96399672dc6e8eba6a1f8c4b6241e7657cfa6 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:21:02 +0900 Subject: [PATCH 07/12] Disable Spartan and Pricey Packs mutators (not yet working) --- ui/main_menu/play_button/_mutators_wall.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 8fd4f3193..90e81b9a6 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -165,11 +165,12 @@ local MUTATOR_WALL = { 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 = "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", From 984fabd5005d4cc4deade2116844c16f6eb6c159 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:21:56 +0900 Subject: [PATCH 08/12] 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. --- ui/main_menu/play_button/_mutators_wall.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 90e81b9a6..abf353946 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -106,6 +106,12 @@ local MUTATOR_WALL = { label = "Kind Wraith", desc = { "Wraith spawns an Uncommon and", "pays $5 (was Rare, money to $0)." }, }, + { + key = "pvp_reward_draft", + label = "Reward Draft", + desc = { "Win a PvP blind, then draft", "1 of several rewards." }, + soon = true, + }, }, }, { @@ -134,6 +140,12 @@ local MUTATOR_WALL = { label = "Instability", desc = { "After scoring, chips and mult", "are dragged toward each other." }, }, + { + key = "rubber_band", + label = "Rubber Band", + desc = { "Losing a life grants an", "escalating comeback buff." }, + soon = true, + }, }, }, { @@ -149,6 +161,12 @@ local MUTATOR_WALL = { label = "Cash Ceiling", desc = { "Chip score can't exceed your", "cash. Greed is the only way up." }, }, + { + key = "score_tax", + label = "Score Tax", + desc = { "Each hand you play nudges your", "opponent's target upward." }, + soon = true, + }, }, }, { From 63a24b1354618bb23ff9ff2c376ebf1d87ede516 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:24:33 +0900 Subject: [PATCH 09/12] 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. --- ui/main_menu/play_button/_mutators_wall.lua | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index abf353946..3183e31be 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -124,11 +124,12 @@ local MUTATOR_WALL = { 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 = "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", @@ -248,6 +249,16 @@ 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 (built fresh so we never mutate the shared cell.desc table). + 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 return { n = G.UIT.R, config = { align = "cm", padding = 0.035 }, @@ -267,8 +278,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 = disabled and { title = cell.label, text = { "Coming soon." } } - or { title = cell.label, text = cell.desc }, + tooltip = { title = cell.label, text = tooltip_text }, }, nodes = { { From dbdba9425f722d7da3cdcc3d179efbe12a7b1215 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:26:18 +0900 Subject: [PATCH 10/12] Disable Cash Ceiling mutator (not yet working) --- ui/main_menu/play_button/_mutators_wall.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 3183e31be..23cce9b9c 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -161,6 +161,7 @@ local MUTATOR_WALL = { 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", From c5ffcac3a5de5cbc95e912c881e1b03388858705 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:38:12 +0900 Subject: [PATCH 11/12] 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. --- layers/economy_mutators.lua | 11 +++++------ layers/esoteric_mutators.lua | 26 -------------------------- layers/glass_cannon.lua | 10 ++++------ layers/mutator_stubs.lua | 18 ++---------------- 4 files changed, 11 insertions(+), 54 deletions(-) diff --git a/layers/economy_mutators.lua b/layers/economy_mutators.lua index 72497ec7f..df7fe7b3b 100644 --- a/layers/economy_mutators.lua +++ b/layers/economy_mutators.lua @@ -1,6 +1,8 @@ +-- 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`) --- TODO doesn't work + MP.Layer("inflation", { game_modifiers = { inflation = true }, }) @@ -10,27 +12,24 @@ 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 }, }) -- No cash reward from Small or Big blinds (Boss/PvP payout left intact). -- (blind.lua: dollars zeroed when no_blind_reward[blind_type] is set) --- TODO doesn't work MP.Layer("spartan", { game_modifiers = { no_blind_reward = { Small = true, Big = true } }, }) -- Booster packs cost +$1 more for each ante you're into the run. -- (card.lua set_cost: cost + round_resets.ante - 1 when booster_ante_scaling) --- TODO doesn't work MP.Layer("pricey_packs", { game_modifiers = { booster_ante_scaling = true }, }) diff --git a/layers/esoteric_mutators.lua b/layers/esoteric_mutators.lua index 96f54c931..aed0d49ac 100644 --- a/layers/esoteric_mutators.lua +++ b/layers/esoteric_mutators.lua @@ -1,45 +1,19 @@ --- Esoteric mutator layers (green knobs — the genuinely weird ones). --- --- All engine-native G.GAME.modifiers flags, so they're pure data and --- deterministic. See economy_mutators.lua for the applier contract. - --- Hand is dealt face-down. You play blind. (cardarea.lua / common_events.lua --- read modifiers.flipped_cards when drawing.) MP.Layer("flipped_cards", { game_modifiers = { flipped_cards = true }, }) --- Played cards contribute nothing — you win purely on jokers and mult. --- (state_events.lua: modifiers.debuff_played_cards) MP.Layer("debuff_played_cards", { game_modifiers = { debuff_played_cards = true }, }) --- Every joker is eternal: can't be sold or destroyed. Your build locks in. --- (common_events.lua: modifiers.all_eternal) MP.Layer("all_eternal", { game_modifiers = { all_eternal = true }, }) --- The shop sells jokers carrying eternal / perishable / rental stickers — --- roguelike risk on every purchase. (common_events.lua: enable_*_in_shop) -MP.Layer("sticker_shop", { - game_modifiers = { - enable_eternals_in_shop = true, - enable_perishables_in_shop = true, - enable_rentals_in_shop = true, - }, -}) - --- Scoring chips are capped at your current money. Hoarding cash literally raises --- your ceiling. Brutal in PvP — opt-in only. (misc_functions.lua mod_chips: --- _chips = min(_chips, max(dollars, 0))) MP.Layer("chip_cap", { game_modifiers = { chips_dollar_cap = true }, }) --- Hand size shrinks by 1 for every $10 you hold. Rich = clumsy. --- (cardarea.lua: minus_hand_size_per_X_dollar) MP.Layer("shrinking_hand", { game_modifiers = { minus_hand_size_per_X_dollar = 10 }, }) diff --git a/layers/glass_cannon.lua b/layers/glass_cannon.lua index ee42d35e9..49ac226d5 100644 --- a/layers/glass_cannon.lua +++ b/layers/glass_cannon.lua @@ -1,4 +1,3 @@ --- Balance knobs (provisional — tune freely). -- TODO XMult works, but hand size change doesn't work local GLASS_CANNON_HANDS = 2 -- doesn't work local GLASS_CANNON_XMULT = 4 @@ -7,13 +6,12 @@ MP.Layer("glass_cannon", { starting_params = { hands = GLASS_CANNON_HANDS }, -- doesn't work }) --- final_scoring_step is the canonical once-per-hand seam: vanilla calls it after --- all jokers to let the deck rebalance chips/mult (it's where Plasma halves and --- recombines). We wrap it, let the real deck run, then scale mult on top when the --- layer is live. nu_mult can come back nil (most decks), in which case the engine --- falls back to the incoming mult — so we base our scale on (nu_mult or args.mult). +-- final_scoring_step called after jokers to let the deck rebalance chips/mult +-- (it's where Plasma does its math) +-- so we wrap that burrito and then scale the mult on top local _back_trigger_effect = Back.trigger_effect function Back:trigger_effect(args) + -- magic. don't ask local nu_chip, nu_mult = _back_trigger_effect(self, args) if args and args.context == "final_scoring_step" and MP.is_layer_active("glass_cannon") then local base_mult = nu_mult or args.mult diff --git a/layers/mutator_stubs.lua b/layers/mutator_stubs.lua index 94c2356b3..2034ee0b7 100644 --- a/layers/mutator_stubs.lua +++ b/layers/mutator_stubs.lua @@ -1,22 +1,8 @@ --- Stubbed mutator layers (yellow knobs — registered but inert). --- --- These need real hooks, not just data. They're registered so they exist in the --- layer system and can be composed/selected, but they currently do nothing. --- Each note records the intended behavior and the seam it'll hook. - --- PvP REWARD DRAFT ⭐ --- Beat a PvP blind -> pick 1 of N rewards (joker / tarot / $ / voucher). --- Local-only effect (opponent's deck isn't simulated locally), so MP-safe; seed --- the offered choices off (lobby seed + ante + player) for reproducible fairness. --- Seam: the endPvP / PvP-blind-win path in networking + ui/game. +-- PvP REWARD DRAFT ⭐ Beat a PvP blind -> pick 1 of N rewards (joker / tarot / $ / voucher). MP.Layer("pvp_reward_draft", {}) --- RUBBER-BAND --- Losing a life grants an escalating buff (comeback mechanic for casual lobbies). --- Seam: the life-loss path (action_set_lives / ease_lives). +-- RUBBER-BAND - losing a life grants an escalating buff MP.Layer("rubber_band", {}) -- SCORE TAX --- Every hand you play nudges your opponent's target up slightly. Pure pressure. --- Seam: rides the existing playHand / enemy-score sync. MP.Layer("score_tax", {}) From f55f0546aab12009d2a9fdc5725d2ccdf8e90a92 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Thu, 18 Jun 2026 18:57:00 +0900 Subject: [PATCH 12/12] Add credit badges to mutator wall tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/main_menu/play_button/_mutators_wall.lua | 111 ++++++++++++++++++-- 1 file changed, 100 insertions(+), 11 deletions(-) diff --git a/ui/main_menu/play_button/_mutators_wall.lua b/ui/main_menu/play_button/_mutators_wall.lua index 23cce9b9c..7e51f2774 100644 --- a/ui/main_menu/play_button/_mutators_wall.lua +++ b/ui/main_menu/play_button/_mutators_wall.lua @@ -92,6 +92,7 @@ local MUTATOR_WALL = { label = "Inflation", desc = { "Shop prices creep up $1 with", "every card you buy." }, soon = true, + credits = { idea = { "Kars" } }, }, { key = "no_interest", @@ -105,6 +106,7 @@ local MUTATOR_WALL = { 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", @@ -140,6 +142,7 @@ local MUTATOR_WALL = { key = "score_instability", label = "Instability", desc = { "After scoring, chips and mult", "are dragged toward each other." }, + credits = { code = { "Toneblock" } }, }, { key = "rubber_band", @@ -153,7 +156,12 @@ local MUTATOR_WALL = { 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." } }, @@ -184,6 +192,7 @@ local MUTATOR_WALL = { 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 }, { @@ -197,7 +206,12 @@ local MUTATOR_WALL = { 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." } }, + { + key = "eeeee", + label = "Eeeee", + desc = { "~40% of random rolls lock to", "a fixed value each ante." }, + credits = { code = { "Tonebleee" } }, + }, }, }, } @@ -247,11 +261,89 @@ 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 (built fresh so we never mutate the shared cell.desc table). + -- Disabled cells keep their real description with a "(Coming soon)" line appended local tooltip_text = cell.desc if disabled then tooltip_text = {} @@ -260,6 +352,9 @@ local function mutator_cell(cell, colour, disabled) 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 }, @@ -279,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 = tooltip_text }, + tooltip = tooltip, }, nodes = { { @@ -310,10 +405,6 @@ local function mutator_column(cat) 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", @@ -388,8 +479,6 @@ 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