Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Multiplayer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"priority": 10000000,
"badge_colour": "AC3232",
"badge_text_colour": "FFFFFF",
"version": "0.4.3",
"version": "0.5.0",
"dependencies": [
"Steamodded (>=1.0.0~BETA-1221a)",
"Lovely (>=0.8)",
Expand Down
6 changes: 6 additions & 0 deletions core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
20 changes: 5 additions & 15 deletions layers/economy_mutators.lua
Original file line number Diff line number Diff line change
@@ -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 = { <id> = value }` is
-- written to G.GAME.modifiers[<id>]; `starting_params = { <id> = value }` to
-- G.GAME.starting_params[<id>] (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 },
})
Expand Down
23 changes: 23 additions & 0 deletions layers/eeeee.lua
Original file line number Diff line number Diff line change
@@ -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
26 changes: 0 additions & 26 deletions layers/esoteric_mutators.lua
Original file line number Diff line number Diff line change
@@ -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 },
})
25 changes: 7 additions & 18 deletions layers/glass_cannon.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 2 additions & 16 deletions layers/mutator_stubs.lua
Original file line number Diff line number Diff line change
@@ -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", {})
21 changes: 21 additions & 0 deletions layers/no_red_seals.lua
Original file line number Diff line number Diff line change
@@ -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
145 changes: 145 additions & 0 deletions layers/polymorph_spam.lua
Original file line number Diff line number Diff line change
@@ -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
Loading