.
diff --git a/Multiplayer.json b/Multiplayer.json
new file mode 100644
index 00000000..b7dda7cc
--- /dev/null
+++ b/Multiplayer.json
@@ -0,0 +1,28 @@
+{
+ "id": "Multiplayer",
+ "name": "Multiplayer",
+ "display_name": "Multiplayer",
+ "author": [
+ "Virtualized",
+ "see credits"
+ ],
+ "description": "Allows players to compete with their friends!",
+ "prefix": "mp",
+ "main_file": "core.lua",
+ "priority": 10000000,
+ "badge_colour": "AC3232",
+ "badge_text_colour": "FFFFFF",
+ "version": "0.3.4~DEV",
+ "dependencies": [
+ "Steamodded (>=1.0.0~BETA-1221a)",
+ "Lovely (>=0.8)",
+ "Balatro (>=1.0.1o)"
+ ],
+ "conflicts": [
+ "SystemClock (<<1.6.3)",
+ "Cryptid (<<0.5.4)",
+ "Talisman (<=2.0.2)",
+ "VirtualizedMultiplayer",
+ "JokerDisplay (<<1.9.6)"
+ ]
+}
\ No newline at end of file
diff --git a/Multiplayer/Blind.lua b/Multiplayer/Blind.lua
deleted file mode 100644
index 3f164d85..00000000
--- a/Multiplayer/Blind.lua
+++ /dev/null
@@ -1,67 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD BLIND-------------------------
-
-local bl_pvp = {
- name = 'Your Nemesis',
- defeated = false,
- order = 0,
- dollars = 5,
- mult = 0,
- vars = {},
- debuff = {},
- pos = {x=0, y=25},
- boss_colour = HEX('ac3232'),
- boss = {min = 1, max = 10},
- key = 'bl_pvp'
-}
-
-G.P_BLINDS['bl_pvp'] = bl_pvp
-
-local get_new_boss_ref = get_new_boss
-function get_new_boss()
- if Lobby.code then
- return 'bl_pvp'
- else
- local boss = get_new_boss_ref()
- while boss == 'bl_pvp' do
- boss = get_new_boss_ref()
- end
- return boss
- end
-end
-
-local localize_ref = localize
-function localize(args, misc_cat)
- if type(args) == 'table' and args.key == 'bl_pvp' and args.set == 'Blind' then
- if args.type == 'name_text' then
- return 'Your Nemesis'
- elseif args.type == 'raw_descriptions' then
- return {
- 'Face another player,', 'most chips wins'
- }
- end
- end
- return localize_ref(args, misc_cat)
-end
-
-local create_UIBox_your_collection_blinds_ref = create_UIBox_your_collection_blinds
-function create_UIBox_your_collection_blinds(exit)
- G.P_BLINDS['bl_pvp'] = nil
- local res = create_UIBox_your_collection_blinds_ref(exit)
- G.P_BLINDS['bl_pvp'] = bl_pvp
- return res
-end
-
-local set_discover_tallies_ref = set_discover_tallies
-function set_discover_tallies()
- G.P_BLINDS['bl_pvp'] = nil
- local res = set_discover_tallies_ref()
- G.P_BLINDS['bl_pvp'] = bl_pvp
- return res
-end
-
-----------------------------------------------
-------------MOD BLIND END---------------------
\ No newline at end of file
diff --git a/Multiplayer/Core.lua b/Multiplayer/Core.lua
deleted file mode 100644
index f0867c42..00000000
--- a/Multiplayer/Core.lua
+++ /dev/null
@@ -1,41 +0,0 @@
---- STEAMODDED HEADER
---- MOD_NAME: Multiplayer
---- MOD_ID: VirtualizedMultiplayer
---- MOD_AUTHOR: [virtualized]
---- MOD_DESCRIPTION: Allows players to compete with their friends! Contact @virtualized on discord for mod assistance.
-
-----------------------------------------------
-------------MOD CORE--------------------------
-
--- Credit to Nyoxide for this custom loader
-local moduleCache = {}
-local function customLoader(moduleName)
- local filename = moduleName:gsub("%.", "/") .. ".lua"
- if moduleCache[filename] then
- return moduleCache[filename]
- end
-
- local filePath = "Mods/Multiplayer/" .. filename
- local fileContent = love.filesystem.read(filePath)
- if fileContent then
- local moduleFunc = assert(load(fileContent, "@"..filePath))
- moduleCache[filename] = moduleFunc
- return moduleFunc
- end
-
- return "\nNo module found: " .. moduleName
-end
-
-function SMODS.INIT.VirtualizedMultiplayer()
- table.insert(package.loaders, 1, customLoader)
- require "Blind"
- require "Deck"
- require "Main_Menu"
- require "Utils".get_username()
- Networking.authorize()
- require "Mod_Description".load_description_gui()
- require "Game_UI"
-end
-
-----------------------------------------------
-------------MOD CORE END----------------------
\ No newline at end of file
diff --git a/Multiplayer/Deck.lua b/Multiplayer/Deck.lua
deleted file mode 100644
index 3e5cdf91..00000000
--- a/Multiplayer/Deck.lua
+++ /dev/null
@@ -1,73 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD DECK--------------------------
-
-local c_multiplayer_1 = {
- name = 'Multiplayer Deck',
- id = 'c_multiplayer_1',
- rules = {
- custom = {
- },
- modifiers = {
- }
- },
- jokers = {
- },
- consumeables = {
- },
- vouchers = {
- },
- deck = {
- type = 'Challenge Deck'
- },
- restrictions = {
- banned_cards = {
- {id = 'j_diet_cola'}, -- Intention to disable skipping
- {id = 'j_mr_bones'},
- {id = 'v_hieroglyph'},
- {id = 'v_petroglyph'},
- },
- banned_tags = {
- },
- banned_other = {
- }
- }
-}
-
-G.CHALLENGES[21] = c_multiplayer_1
-
-local localize_ref = localize
-function localize(args, misc_cat)
- if args == 'c_multiplayer_1' and misc_cat == 'challenge_names' then
- return 'Multiplayer'
- end
- return localize_ref(args, misc_cat)
-end
-
-local set_discover_tallies_ref = set_discover_tallies
-function set_discover_tallies()
- G.CHALLENGES[21] = nil
- local res = set_discover_tallies_ref()
- G.CHALLENGES[21] = c_multiplayer_1
- return res
-end
-
-local challenge_list_ref = G.FUNCS.challenge_list
-G.FUNCS.challenge_list = function(e)
- G.CHALLENGES[21] = nil
- challenge_list_ref(e)
- G.CHALLENGES[21] = c_multiplayer_1
-end
-
-local challenges_ref = G.UIDEF.challenges
-function G.UIDEF.challenges(from_game_over)
- G.CHALLENGES[21] = nil
- local res = challenges_ref(from_game_over)
- G.CHALLENGES[21] = c_multiplayer_1
- return res
-end
-
-----------------------------------------------
-------------MOD DECK END----------------------
\ No newline at end of file
diff --git a/Multiplayer/Game_UI.lua b/Multiplayer/Game_UI.lua
deleted file mode 100644
index 83433c26..00000000
--- a/Multiplayer/Game_UI.lua
+++ /dev/null
@@ -1,279 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD GAME UI-----------------------
-
-local Lobby = require "Lobby"
-
-local Game_UI = {}
-
-local create_UIBox_options_ref = create_UIBox_options
-function create_UIBox_options()
- if Lobby.code then
- local current_seed = nil
- local main_menu = nil
- local your_collection = nil
- local credits = nil
-
- G.E_MANAGER:add_event(Event({
- blockable = false,
- func = function()
- G.REFRESH_ALERTS = true
- return true
- end
- }))
-
- if G.STAGE == G.STAGES.RUN then
- main_menu = UIBox_button{ label = {'Return to Lobby'}, button = "go_to_menu", minw = 5}
- your_collection = UIBox_button{ label = {localize('b_collection')}, button = "your_collection", minw = 5, id = 'your_collection'}
- current_seed = {n=G.UIT.R, config={align = "cm", padding = 0.05}, nodes={
- {n=G.UIT.C, config={align = "cm", padding = 0}, nodes={
- {n=G.UIT.T, config={text = localize('b_seed')..": ", scale = 0.4, colour = G.C.WHITE}}
- }},
- {n=G.UIT.C, config={align = "cm", padding = 0, minh = 0.8}, nodes={
- {n=G.UIT.C, config={align = "cm", padding = 0, minh = 0.8}, nodes={
- {n=G.UIT.R, config={align = "cm", r = 0.1, colour = G.GAME.seeded and G.C.RED or G.C.BLACK, minw = 1.8, minh = 0.5, padding = 0.1, emboss = 0.05}, nodes={
- {n=G.UIT.C, config={align = "cm"}, nodes={
- {n=G.UIT.T, config={ text = tostring(G.GAME.pseudorandom.seed), scale = 0.43, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
- }}
- }}
- }}
- }},
- UIBox_button({col = true, button = 'copy_seed', label = {localize('b_copy')}, colour = G.C.BLUE, scale = 0.3, minw = 1.3, minh = 0.5,}),
- }}
- end
- if G.STAGE == G.STAGES.MAIN_MENU then
- credits = UIBox_button{ label = {localize('b_credits')}, button = "show_credits", minw = 5}
- end
-
- local settings = UIBox_button({button = 'settings', label = {localize('b_settings')}, minw = 5, focus_args = {snap_to = true}})
- local high_scores = UIBox_button{ label = {localize('b_stats')}, button = "high_scores", minw = 5}
-
- local t = create_UIBox_generic_options({ contents = {
- settings,
- G.GAME.seeded and current_seed or nil,
- main_menu,
- high_scores,
- your_collection,
- credits
- }})
- return t
- else
- return create_UIBox_options_ref()
- end
-end
-
-local create_UIBox_blind_choice_ref = create_UIBox_blind_choice
-function create_UIBox_blind_choice(type, run_info)
- if Lobby.code then
- if not G.GAME.blind_on_deck then
- G.GAME.blind_on_deck = 'Small'
- end
- if not run_info then G.GAME.round_resets.blind_states[G.GAME.blind_on_deck] = 'Select' end
-
- local disabled = false
- type = type or 'Small'
-
- local blind_choice = {
- config = G.P_BLINDS[G.GAME.round_resets.blind_choices[type]],
- }
-
- blind_choice.animation = AnimatedSprite(0,0, 1.4, 1.4, G.ANIMATION_ATLAS['blind_chips'], blind_choice.config.pos)
- blind_choice.animation:define_draw_steps({
- {shader = 'dissolve', shadow_height = 0.05},
- {shader = 'dissolve'}
- })
- local extras = nil
- local stake_sprite = get_stake_sprite(G.GAME.stake or 1, 0.5)
-
- G.GAME.orbital_choices = G.GAME.orbital_choices or {}
- G.GAME.orbital_choices[G.GAME.round_resets.ante] = G.GAME.orbital_choices[G.GAME.round_resets.ante] or {}
-
- if not G.GAME.orbital_choices[G.GAME.round_resets.ante][type] then
- local _poker_hands = {}
- for k, v in pairs(G.GAME.hands) do
- if v.visible then _poker_hands[#_poker_hands+1] = k end
- end
-
- G.GAME.orbital_choices[G.GAME.round_resets.ante][type] = pseudorandom_element(_poker_hands, pseudoseed('orbital'))
- end
-
- if type == 'Small' then
- extras = nil
- elseif type == 'Big' then
- extras = nil
- elseif not run_info then
- local dt1 = DynaText({string = {{string = 'LIFE', colour = G.C.FILTER}}, colours = {G.C.BLACK}, scale = 0.55, silent = true, pop_delay = 4.5, shadow = true, bump = true, maxw = 3})
- local dt2 = DynaText({string = {{string = 'or', colour = G.C.WHITE}},colours = {G.C.CHANCE}, scale = 0.35, silent = true, pop_delay = 4.5, shadow = true, maxw = 3})
- local dt3 = DynaText({string = {{string = 'DEATH', colour = G.C.FILTER}}, colours = {G.C.BLACK}, scale = 0.55, silent = true, pop_delay = 4.5, shadow = true, bump = true, maxw = 3})
- extras =
- {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.R, config={align = "cm", padding = 0.07, r = 0.1, colour = {0,0,0,0.12}, minw = 2.9}, nodes={
- {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.O, config={object = dt1}},
- }},
- {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.O, config={object = dt2}},
- }},
- {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.O, config={object = dt3}},
- }},
- }},
- }}
- end
- G.GAME.round_resets.blind_ante = G.GAME.round_resets.blind_ante or G.GAME.round_resets.ante
-
- local loc_target = localize{type = 'raw_descriptions', key = blind_choice.config.key, set = 'Blind', vars = {localize(G.GAME.current_round.most_played_poker_hand, 'poker_hands')}}
- local loc_name = localize{type = 'name_text', key = blind_choice.config.key, set = 'Blind'}
- local blind_col = get_blind_main_colour(type)
- local blind_amt = get_blind_amount(G.GAME.round_resets.blind_ante)*blind_choice.config.mult*G.GAME.starting_params.ante_scaling
-
- if G.GAME.round_resets.blind_choices[type] == 'bl_pvp' then
- blind_amt = '????'
- end
-
- local text_table = loc_target
-
- local blind_state = G.GAME.round_resets.blind_states[type]
- local _reward = true
- if G.GAME.modifiers.no_blind_reward and G.GAME.modifiers.no_blind_reward[type] then _reward = nil end
- if blind_state == 'Select' then blind_state = 'Current' end
- local run_info_colour = run_info and (blind_state == 'Defeated' and G.C.GREY or blind_state == 'Skipped' and G.C.BLUE or blind_state == 'Upcoming' and G.C.ORANGE or blind_state == 'Current' and G.C.RED or G.C.GOLD)
- local t =
- {n=G.UIT.R, config={id = type, align = "tm", func = 'blind_choice_handler', minh = not run_info and 10 or nil, ref_table = {deck = nil, run_info = run_info}, r = 0.1, padding = 0.05}, nodes={
- {n=G.UIT.R, config={align = "cm", colour = mix_colours(G.C.BLACK, G.C.L_BLACK, 0.5), r = 0.1, outline = 1, outline_colour = G.C.L_BLACK}, nodes={
- {n=G.UIT.R, config={align = "cm", padding = 0.2}, nodes={
- not run_info and {n=G.UIT.R, config={id = 'select_blind_button', align = "cm", ref_table = blind_choice.config, colour = disabled and G.C.UI.BACKGROUND_INACTIVE or G.C.ORANGE, minh = 0.6, minw = 2.7, padding = 0.07, r = 0.1, shadow = true, hover = true, one_press = true, button = 'select_blind'}, nodes={
- {n=G.UIT.T, config={ref_table = G.GAME.round_resets.loc_blind_states, ref_value = type, scale = 0.45, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.UI.TEXT_LIGHT, shadow = not disabled}}
- }} or
- {n=G.UIT.R, config={id = 'select_blind_button', align = "cm", ref_table = blind_choice.config, colour = run_info_colour, minh = 0.6, minw = 2.7, padding = 0.07, r = 0.1, emboss = 0.08}, nodes={
- {n=G.UIT.T, config={text = localize(blind_state, 'blind_states'), scale = 0.45, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
- }}
- }},
- {n=G.UIT.R, config={id = 'blind_name',align = "cm", padding = 0.07}, nodes={
- {n=G.UIT.R, config={align = "cm", r = 0.1, outline = 1, outline_colour = blind_col, colour = darken(blind_col, 0.3), minw = 2.9, emboss = 0.1, padding = 0.07, line_emboss = 1}, nodes={
- {n=G.UIT.O, config={object = DynaText({string = loc_name, colours = {disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE}, shadow = not disabled, float = not disabled, y_offset = -4, scale = 0.45, maxw =2.8})}},
- }},
- }},
- {n=G.UIT.R, config={align = "cm", padding = 0.05}, nodes={
- {n=G.UIT.R, config={id = 'blind_desc', align = "cm", padding = 0.05}, nodes={
- {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.R, config={align = "cm", minh = 1.5}, nodes={
- {n=G.UIT.O, config={object = blind_choice.animation}},
- }},
- text_table[1] and {n=G.UIT.R, config={align = "cm", minh = 0.7, padding = 0.05, minw = 2.9}, nodes={
- text_table[1] and {n=G.UIT.R, config={align = "cm", maxw = 2.8}, nodes={
- {n=G.UIT.T, config={id = blind_choice.config.key, ref_table = {val = ''}, ref_value = 'val', scale = 0.32, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE, shadow = not disabled, func = 'HUD_blind_debuff_prefix'}},
- {n=G.UIT.T, config={text = text_table[1] or '-', scale = 0.32, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE, shadow = not disabled}}
- }} or nil,
- text_table[2] and {n=G.UIT.R, config={align = "cm", maxw = 2.8}, nodes={
- {n=G.UIT.T, config={text = text_table[2] or '-', scale = 0.32, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE, shadow = not disabled}}
- }} or nil,
- }} or nil,
- }},
- {n=G.UIT.R, config={align = "cm",r = 0.1, padding = 0.05, minw = 3.1, colour = G.C.BLACK, emboss = 0.05}, nodes={
- {n=G.UIT.R, config={align = "cm", maxw = 3}, nodes={
- {n=G.UIT.T, config={text = localize('ph_blind_score_at_least'), scale = 0.3, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE, shadow = not disabled}}
- }},
- {n=G.UIT.R, config={align = "cm", minh = 0.6}, nodes={
- {n=G.UIT.O, config={w=0.5,h=0.5, colour = G.C.BLUE, object = stake_sprite, hover = true, can_collide = false}},
- {n=G.UIT.B, config={h=0.1,w=0.1}},
- {n=G.UIT.T, config={text = number_format(blind_amt), scale = score_number_scale(0.9, blind_amt), colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.RED, shadow = not disabled}}
- }},
- _reward and {n=G.UIT.R, config={align = "cm"}, nodes={
- {n=G.UIT.T, config={text = localize('ph_blind_reward'), scale = 0.35, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE, shadow = not disabled}},
- {n=G.UIT.T, config={text = string.rep(localize("$"), blind_choice.config.dollars)..'+', scale = 0.35, colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.MONEY, shadow = not disabled}}
- }} or nil,
- }},
- }},
- }},
- }},
- {n=G.UIT.R, config={id = 'blind_extras', align = "cm"}, nodes={
- extras,
- }}
-
- }}
- return t
- else
- return create_UIBox_blind_choice_ref(type, run_info)
- end
-end
-
-function Game_UI.update_enemy()
- if Lobby.code then
- G.HUD_blind.alignment.offset.y = -10
- G.E_MANAGER:add_event(Event({
- trigger = 'after',
- delay = 0.3,
- blockable = false,
- func = function()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_table = Lobby.enemy
- G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_value = 'score'
- G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[1].children[1].config.text = 'Current enemy score'
- G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[3].children[1].config.text = 'Enemy hands left: '
- G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object.config.string = {{ref_table = Lobby.enemy, ref_value = 'hands'}}
- G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object:update_text()
- G.HUD_blind.alignment.offset.y = 0
- return true
- end
- }))
- end
-end
-
-function Game_UI.reset_blind_HUD()
- if Lobby.code then
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = {{ref_table = G.GAME.blind, ref_value = 'loc_name'}}
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_table = G.GAME.blind
- G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_value = 'chip_text'
- G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[1].children[1].config.text = localize('ph_blind_score_at_least')
- G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[3].children[1].config.text = localize('ph_blind_reward')
- G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object.config.string = {{ref_table = G.GAME.current_round, ref_value = 'dollars_to_be_earned'}}
- G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object:update_text()
- end
-end
-
-local update_draw_to_hand_ref = Game.update_draw_to_hand
-function Game:update_draw_to_hand(dt)
- if Lobby.code then
- if not G.STATE_COMPLETE and G.GAME.current_round.hands_played == 0 and
- G.GAME.current_round.discards_used == 0 and
- G.GAME.facing_blind then
- if G.GAME.blind.name == 'Your Nemesis' then
- G.E_MANAGER:add_event(Event({
- trigger = 'after',
- delay = 1,
- blockable = false,
- func = function()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_out(0)
- Game_UI.update_enemy()
- G.E_MANAGER:add_event(Event({
- trigger = 'after',
- delay = 0.45,
- blockable = false,
- func = function()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = {{ref_table = Lobby.enemy, ref_value = 'username'}}
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
- G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_in(0)
- return true
- end
- }))
- return true
- end
- }))
- end
- end
- end
- update_draw_to_hand_ref(self,dt)
-end
-
-local blind_defeat_ref = Blind.defeat
-function Blind:defeat(silent)
- blind_defeat_ref(self, silent)
- Game_UI.reset_blind_HUD()
-end
-
-return Game_UI
-----------------------------------------------
-------------MOD GAME UI END-------------------
\ No newline at end of file
diff --git a/Multiplayer/Lobby.lua b/Multiplayer/Lobby.lua
deleted file mode 100644
index 1380a480..00000000
--- a/Multiplayer/Lobby.lua
+++ /dev/null
@@ -1,420 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD LOBBY-------------------------
-
-Lobby = {
- connected = false,
- temp_code = '',
- code = nil,
- type = "",
- config = {},
- username = "Guest",
- enemy = {
- username = "dude_crusher69",
- score = 0,
- hands = 4
- },
- players = {}
-}
-
-Connection_Status_UI = nil
-
-local function get_connection_status_ui()
- return UIBox({
- definition = {
- n = G.UIT.ROOT,
- config = {
- align = "cm",
- colour = G.C.UI.TRANSPARENT_DARK
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- scale = 0.3,
- text = (Lobby.code and 'In Lobby') or (Lobby.connected and 'Connected to Service') or 'WARN: Cannot Find Multiplayer Service',
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- config = {
- align = "tri",
- bond = "Weak",
- offset = {
- x = 0,
- y = 0.9
- },
- major = G.ROOM_ATTACH
- }
- })
-end
-
-function Lobby.update_connection_status()
- if Connection_Status_UI then
- Connection_Status_UI:remove()
- end
- Connection_Status_UI = get_connection_status_ui()
-end
-
-local gameMainMenuRef = Game.main_menu
-function Game.main_menu(arg_280_0, arg_280_1)
- Connection_Status_UI = get_connection_status_ui()
- gameMainMenuRef(arg_280_0, arg_280_1)
-end
-
-function G.FUNCS.copy_to_clipboard(arg_736_0)
- Utils.copy_to_clipboard(Lobby.code)
-end
-
-function create_UIBox_view_code()
- local var_495_0 = 0.75
-
- return (create_UIBox_generic_options({
- contents = {
- {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Lobby.code,
- shadow = true,
- scale = var_495_0 * 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- UIBox_button({
- label = {"Copy to Clipboard"},
- colour = G.C.BLUE,
- button = "copy_to_clipboard",
- minw = 5,
- })
- }
- }
- }
- }
- }
- }))
-end
-
-function G.FUNCS.lobby_setup_run(arg_736_0)
- G.FUNCS.start_run(arg_736_0, {
- stake = 1,
- challenge = {
- name = 'Multiplayer Deck',
- id = 'c_multiplayer_1',
- rules = {
- custom = {
- },
- modifiers = {
- }
- },
- jokers = {
- },
- consumeables = {
- },
- vouchers = {
- },
- deck = {
- type = 'Challenge Deck'
- },
- restrictions = {
- banned_cards = {
- {id = 'j_diet_cola'}, -- Intention to disable skipping
- {id = 'j_mr_bones'},
- {id = 'v_hieroglyph'},
- {id = 'v_petroglyph'},
- },
- banned_tags = {
- },
- banned_other = {
- }
- }
- }
- })
-end
-
-function G.FUNCS.lobby_options(arg_736_0)
- G.FUNCS.overlay_menu({
- definition = create_UIBox_generic_options({
- contents = {
- {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = 'Not Implemented Yet',
- shadow = true,
- scale = 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- }
- }
- }
- }
- })
- })
-end
-
-function G.FUNCS.view_code(arg_736_0)
- G.FUNCS.overlay_menu({
- definition = create_UIBox_view_code()
- })
-end
-
-function G.FUNCS.lobby_leave(arg_736_0)
- Lobby.code = nil
- Networking.leave_lobby()
- Lobby.update_connection_status()
-end
-
-local function create_UIBox_lobby_menu()
- local text_scale = 0.45
-
- local t = {
- n = G.UIT.ROOT,
- config = {
- align = "cm",
- colour = G.C.CLEAR
- },
- nodes = {
- {
- n = G.UIT.C,
- config = {
- align = "bm"
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- align = "cm",
- padding = 0.2,
- r = 0.1,
- emboss = 0.1,
- colour = G.C.L_BLACK,
- mid = true
- },
- nodes = {
- UIBox_button({
- id = 'lobby_menu_start',
- button = "lobby_setup_run",
- colour = G.C.BLUE,
- minw = 3.65,
- minh = 1.55,
- label = {'START'},
- scale = text_scale*2,
- col = true
- }),
- {
- n = G.UIT.C,
- config = {
- align = "cm"
- },
- nodes = {
- UIBox_button{
- button = 'lobby_options',
- colour = G.C.ORANGE,
- minw = 3.15,
- minh = 1.35,
- label = {'LOBBY OPTIONS'},
- scale = text_scale * 1.2,
- col = true
- },
- {
- n = G.UIT.C,
- config = {
- align = "cm",
- minw = 0.2
- },
- nodes = {}
- },
- {
- n = G.UIT.C,
- config = {
- align = "tm",
- minw = 2.65
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- padding = 0.2,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = 'Connected Players:',
- shadow = true,
- scale = text_scale * 0.8,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- },
- },
- Lobby.players[1] and {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Lobby.players[1].username,
- shadow = true,
- scale = text_scale * 0.8,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- } or nil,
- Lobby.players[2] and {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Lobby.players[2].username,
- shadow = true,
- scale = text_scale * 0.8,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- } or nil
- }
- },
- {
- n = G.UIT.C,
- config = {
- align = "cm",
- minw = 0.2
- },
- nodes = {}
- },
- UIBox_button{
- button = 'view_code',
- colour = G.C.PALE_GREEN,
- minw = 3.15,
- minh = 1.35,
- label = {'VIEW CODE'},
- scale = text_scale * 1.2,
- col = true
- },
- }
- },
- UIBox_button{
- id = 'lobby_menu_leave',
- button = "lobby_leave",
- colour = G.C.RED,
- minw = 3.65,
- minh = 1.55,
- label = {'LEAVE'},
- scale = text_scale*1.5,
- col = true
- },
- }
- },
- }},
- }}
- return t
-end
-
-local function get_lobby_main_menu_UI()
- return UIBox({
- definition = create_UIBox_lobby_menu(),
- config = {
- align="bmi",
- offset = {
- x = 0,
- y = 10
- },
- major = G.ROOM_ATTACH,
- bond = 'Weak'
- }
- })
-end
-
-function display_lobby_main_menu_UI()
- G.MAIN_MENU_UI = get_lobby_main_menu_UI()
- G.MAIN_MENU_UI.alignment.offset.y = 0
- G.MAIN_MENU_UI:align_to_major()
-
- G.CONTROLLER:snap_to{node = G.MAIN_MENU_UI:get_UIE_by_ID('lobby_menu_start')}
-end
-
-function Lobby.update_player_usernames()
- if Lobby.code then
- G.MAIN_MENU_UI:remove()
- display_lobby_main_menu_UI()
- end
-end
-
-local setMainMenuUIRef = set_main_menu_UI
-function set_main_menu_UI()
- if Lobby.code then
- display_lobby_main_menu_UI()
- else
- setMainMenuUIRef()
- end
-end
-
-local in_lobby = false
-local gameUpdateRef = Game.update
-function Game:update(arg_298_1)
- if (Lobby.code and not in_lobby) or (not Lobby.code and in_lobby) then
- in_lobby = not in_lobby
- G.F_NO_SAVING = in_lobby
- self.FUNCS.go_to_menu()
- end
- gameUpdateRef(self, arg_298_1)
-end
-
-return Lobby
-
-----------------------------------------------
-------------MOD LOBBY END---------------------
\ No newline at end of file
diff --git a/Multiplayer/Main_Menu.lua b/Multiplayer/Main_Menu.lua
deleted file mode 100644
index bbf80fc6..00000000
--- a/Multiplayer/Main_Menu.lua
+++ /dev/null
@@ -1,385 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD MAIN MENU---------------------
-
-local Utils = require "Utils"
-local Lobby = require "Lobby"
-local Networking = require "Networking"
-
-MULTIPLAYER_VERSION = "0.1.0-MULTIPLAYER"
-
-local gameMainMenuRef = Game.main_menu
-function Game.main_menu(arg_280_0, arg_280_1)
- gameMainMenuRef(arg_280_0, arg_280_1)
- UIBox({
- definition = {
- n = G.UIT.ROOT,
- config = {
- align = "cm",
- colour = G.C.UI.TRANSPARENT_DARK
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- scale = 0.3,
- text = MULTIPLAYER_VERSION,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- config = {
- align = "tri",
- bond = "Weak",
- offset = {
- x = 0,
- y = 0.6
- },
- major = G.ROOM_ATTACH
- }
- })
-end
-
-function create_UIBox_create_lobby_button()
- local var_495_0 = 0.75
-
- return (create_UIBox_generic_options({
- back_func = "play_options",
- contents = {
- {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm"
- },
- nodes = {
- create_tabs({
- snap_to_nav = true,
- colour = G.C.BOOSTER,
- tabs = {
- {
- label = "Attrition (1v1)",
- chosen = true,
- tab_definition_function = function()
- return {
- n = G.UIT.ROOT,
- config = {
- emboss = 0.05,
- minh = 6,
- r = 0.1,
- minw = 10,
- align = "tm",
- padding = 0.2,
- colour = G.C.BLACK
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- align = "tm",
- padding = 0.05,
- minw = 4,
- minh = 1.5
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Utils.wrapText("Both players start with 4 lives, every boss round is a competition between players where the player with the lower score loses a life.", 50),
- shadow = true,
- scale = var_495_0 * 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- create_toggle({label = "Lose lives on round loss", ref_table = Lobby.config, ref_value = 'death_on_round_loss'}),
- create_toggle({label = "Different seeds", ref_table = Lobby.config, ref_value = 'different_seeds'}),
- UIBox_button({
- label = {"Start Lobby"},
- colour = G.C.RED,
- button = "start_lobby",
- minw = 5,
- })
- }
- }
- end
- },
- {
- label = "Draft (1v1)",
- tab_definition_function = function()
- return {
- n = G.UIT.ROOT,
- config = {
- emboss = 0.05,
- minh = 6,
- r = 0.1,
- minw = 10,
- align = "tm",
- padding = 0.2,
- colour = G.C.BLACK
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- align = "tm",
- padding = 0.05,
- minw = 4,
- minh = 2.5
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Utils.wrapText("Both players play a set amount of antes simultaneously, then they play an ante where every round the player with the higher scorer wins, player with the most round wins in the final ante is the victor.", 50),
- shadow = true,
- scale = var_495_0 * 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- UIBox_button({
- label = {"Coming Soon!"},
- colour = G.C.RED,
- minw = 5,
- })
- }
- }
- end
- },
- {
- label = "Heads Up (1v1)",
- tab_definition_function = function()
- return {
- n = G.UIT.ROOT,
- config = {
- emboss = 0.05,
- minh = 6,
- r = 0.1,
- minw = 10,
- align = "tm",
- padding = 0.2,
- colour = G.C.BLACK
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- align = "tm",
- padding = 0.05,
- minw = 4,
- minh = 1
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Utils.wrapText("Both players play the first ante, then must keep beating the opponents previous score or lose.", 50),
- shadow = true,
- scale = var_495_0 * 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- UIBox_button({
- label = {"Coming Soon!"},
- colour = G.C.RED,
- minw = 5,
- })
- }
- }
- end
- },
- {
- label = "Battle Royale (8p)",
- tab_definition_function = function()
- return {
- n = G.UIT.ROOT,
- config = {
- emboss = 0.05,
- minh = 6,
- r = 0.1,
- minw = 10,
- align = "Tm",
- padding = 0.2,
- colour = G.C.BLACK
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- align = "tm",
- padding = 0.05,
- minw = 4,
- minh = 1
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- text = Utils.wrapText("Draft, except there are up to 8 players and every player only has 1 life.", 50),
- shadow = true,
- scale = var_495_0 * 0.6,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- UIBox_button({
- label = {"Coming Soon!"},
- colour = G.C.RED,
- minw = 5,
- })
- }
- }
- end
- }
- }
- })
- }
- }
- }
- }))
-
-end
-
-function create_UIBox_join_lobby_button()
- return (create_UIBox_generic_options({
- back_func = "play_options",
- contents = {
- {
- n = G.UIT.R,
- config = {
- padding = 0,
- align = "cm",
- },
- nodes = {
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- scale = 0.6,
- shadow = true,
- text = 'Lobby Code:',
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- },
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm"
- },
- nodes = {
- create_text_input({
- w = 4,
- h = 1,
- max_length = 5,
- prompt_text = "Enter Lobby Code",
- ref_table = Lobby,
- ref_value = 'temp_code',
- extended_corpus = false,
- keyboard_offset = 1,
- minw = 5,
- callback = function(val)
- Networking.join_lobby(Lobby.temp_code)
- end,
- })
- }
- },
- UIBox_button({
- label = {"Paste From Clipboard"},
- colour = G.C.RED,
- button = "join_from_clipboard",
- minw = 5,
- }),
- }
- }
- }
- }))
-end
-
-function override_main_menu_play_button()
- return (create_UIBox_generic_options({
- contents = {
- UIBox_button({
- label = {"Singleplayer"},
- colour = G.C.BLUE,
- button = "setup_run",
- minw = 5,
- }),
- UIBox_button({
- label = {"Create Lobby"},
- colour = G.C.GREEN,
- button = "create_lobby",
- minw = 5,
- }),
- UIBox_button({
- label = {"Join Lobby"},
- colour = G.C.RED,
- button = "join_lobby",
- minw = 5,
- }),
- }
- }))
-end
-
-function G.FUNCS.play_options(arg_736_0)
- G.SETTINGS.paused = true
-
- G.FUNCS.overlay_menu({
- definition = override_main_menu_play_button()
- })
-end
-
-function G.FUNCS.create_lobby(arg_736_0)
- G.SETTINGS.paused = true
-
- G.FUNCS.overlay_menu({
- definition = create_UIBox_create_lobby_button()
- })
-end
-
-function G.FUNCS.join_lobby(arg_736_0)
- G.SETTINGS.paused = true
-
- G.FUNCS.overlay_menu({
- definition = create_UIBox_join_lobby_button()
- })
-end
-
-function G.FUNCS.join_from_clipboard(arg_736_0)
- Lobby.temp_code = Utils.get_from_clipboard()
- Networking.join_lobby(Lobby.temp_code)
-end
-
-function G.FUNCS.start_lobby(arg_736_0)
- G.SETTINGS.paused = false
-
- Networking.create_lobby()
-end
-
--- Modify play button to take you to mode select first
-local create_UIBox_main_menu_buttonsRef = create_UIBox_main_menu_buttons
-function create_UIBox_main_menu_buttons()
- local menu = create_UIBox_main_menu_buttonsRef()
- menu.nodes[1].nodes[1].nodes[1].nodes[1].config.button = "play_options"
- return(menu)
-end
-
-----------------------------------------------
-------------MOD MAIN MENU END-----------------
\ No newline at end of file
diff --git a/Multiplayer/Mod_Description.lua b/Multiplayer/Mod_Description.lua
deleted file mode 100644
index 233e27ad..00000000
--- a/Multiplayer/Mod_Description.lua
+++ /dev/null
@@ -1,56 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD DESCRIPTION-------------------
-local Utils = require "Utils"
-local Lobby = require "Lobby"
-
-Description = {}
-
-function Description.load_description_gui()
- SMODS.registerUIElement("VirtualizedMultiplayer", {
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm"
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- scale = 0.6,
- text = 'Username:',
- colour = G.C.UI.TEXT_LIGHT
- }
- },
- create_text_input({
- w = 4,
- max_length = 25,
- prompt_text = "Enter Username",
- ref_table = Lobby,
- ref_value = 'username',
- extended_corpus = true,
- keyboard_offset = 1,
- callback = function(val)
- Utils.save_username(Lobby.username)
- end
- }),
- {
- n = G.UIT.T,
- config = {
- scale = 0.3,
- text = 'Press enter to save',
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- }
- })
-end
-
-return Description
-
-----------------------------------------------
-------------MOD DESCRIPTION END---------------
\ No newline at end of file
diff --git a/Multiplayer/Networking.lua b/Multiplayer/Networking.lua
deleted file mode 100644
index 16e63f6c..00000000
--- a/Multiplayer/Networking.lua
+++ /dev/null
@@ -1,107 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD NETWORKING--------------------
-local Lobby = require "Lobby"
-local Config = require "Config"
-local socket = require "socket"
-
-Networking = {}
-
-function string_to_table(str)
- local tbl = {}
- for key, value in string.gmatch(str, '([^,]+):([^,]+)') do
- tbl[key] = value
- end
- return tbl
-end
-
-function Networking.set_username(username)
- Lobby.username = username or 'Guest'
- if Lobby.connected then
- Networking.Client:send('action:username,username:'..Lobby.username)
- end
-end
-
-local function action_connected()
- sendDebugMessage("Client connected to multiplayer server")
- Lobby.connected = true
- Lobby.update_connection_status()
- Networking.Client:send('action:username,username:'..Lobby.username)
-end
-
-local function action_joinedLobby(code)
- sendDebugMessage("Joining lobby " .. code)
- Lobby.code = code
- Lobby.update_connection_status()
- Networking.lobby_info()
-end
-
-local function action_lobbyInfo(host, guest)
- Lobby.players = {}
- table.insert(Lobby.players, { username = host })
- if guest ~= nil then
- table.insert(Lobby.players, { username = guest })
- end
- Lobby.update_player_usernames()
-end
-
-local function action_error(message)
- sendDebugMessage(message)
-
- Utils.overlay_message(message)
-end
-
-local game_update_ref = Game.update
-function Game.update(arg_298_0, arg_298_1)
- if Networking.Client then
- repeat
- local data, error, partial = Networking.Client:receive()
- if data then
- local t = string_to_table(data)
-
- sendDebugMessage('Client got ' .. t.action .. ' message')
-
- if t.action == 'connected' then
- action_connected()
- elseif t.action == 'joinedLobby' then
- action_joinedLobby(t.code)
- elseif t.action == 'lobbyInfo' then
- action_lobbyInfo(t.host, t.guest)
- elseif t.action == 'error' then
- action_error(t.message)
- end
- end
- until not data
- end
-
- game_update_ref(arg_298_0, arg_298_1)
-end
-
-function Networking.authorize()
- Networking.Client = socket.tcp()
- Networking.Client:settimeout(0)
- Networking.Client:connect(Config.URL, Config.PORT) -- Not sure if I want to make these values public yet
-end
-
-function Networking.create_lobby()
- Networking.Client:send('action:createLobby')
-end
-
-function Networking.join_lobby(code)
- Networking.Client:send('action:joinLobby,code:' .. code)
-end
-
-function Networking.lobby_info()
- Networking.Client:send('action:lobbyInfo')
-end
-
-function Networking.leave_lobby()
- Networking.Client:send('action:leaveLobby')
-end
-
-return Networking
-
-----------------------------------------------
-------------MOD NETWORKING END----------------
\ No newline at end of file
diff --git a/Multiplayer/Saved/username.txt b/Multiplayer/Saved/username.txt
deleted file mode 100644
index ace166ce..00000000
--- a/Multiplayer/Saved/username.txt
+++ /dev/null
@@ -1 +0,0 @@
-Guest
\ No newline at end of file
diff --git a/Multiplayer/Utils.lua b/Multiplayer/Utils.lua
deleted file mode 100644
index ba58d692..00000000
--- a/Multiplayer/Utils.lua
+++ /dev/null
@@ -1,140 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD DEBUG-------------------------
-
-local Networking = require "Networking"
-
-Utils = {}
-
-local localize_ref = localize
-function localize(args, misc_cat)
- if args == nil then
- sendDebugMessage("Caught nil localize args, misc_cat: " .. misc_cat)
- return nil
- end
- return localize_ref(args, misc_cat)
-end
-
--- Credit to Henrik Ilgen (https://stackoverflow.com/a/6081639)
-function Utils.serialize_table(val, name, skipnewlines, depth)
- skipnewlines = skipnewlines or false
- depth = depth or 0
-
- local tmp = string.rep(" ", depth)
-
- if name then tmp = tmp .. name .. " = " end
-
- if type(val) == "table" then
- tmp = tmp .. "{" .. (not skipnewlines and "\n" or "")
-
- for k, v in pairs(val) do
- tmp = tmp .. Utils.serialize_table(v, k, skipnewlines, depth + 1) .. "," .. (not skipnewlines and "\n" or "")
- end
-
- tmp = tmp .. string.rep(" ", depth) .. "}"
- elseif type(val) == "number" then
- tmp = tmp .. tostring(val)
- elseif type(val) == "string" then
- tmp = tmp .. string.format("%q", val)
- elseif type(val) == "boolean" then
- tmp = tmp .. (val and "true" or "false")
- else
- tmp = tmp .. "\"[inserializeable datatype:" .. type(val) .. "]\""
- end
-
- return tmp
-end
-
--- Credit to Steamo (https://github.com/Steamopollys/Steamodded/blob/main/core/core.lua)
-function Utils.wrapText(text, maxChars)
- local wrappedText = ""
- local currentLineLength = 0
-
- for word in text:gmatch("%S+") do
- if currentLineLength + #word <= maxChars then
- wrappedText = wrappedText .. word .. ' '
- currentLineLength = currentLineLength + #word + 1
- else
- wrappedText = wrappedText .. '\n' .. word .. ' '
- currentLineLength = #word + 1
- end
- end
-
- return wrappedText
-end
-
-local usernameFilePath = "Mods/Multiplayer/Saved/username.txt"
-function Utils.save_username(text)
- Networking.set_username(text)
- love.filesystem.write(usernameFilePath, text)
-end
-
-function Utils.get_username()
- local fileContent = love.filesystem.read(usernameFilePath)
- if not fileContent then return end
- Lobby.username = fileContent
-end
-
-function Utils.string_split(inputstr, sep)
- if sep == nil then
- sep = "%s"
- end
- local t={}
- for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
- table.insert(t, str)
- end
- return t
-end
-
-function Utils.copy_to_clipboard(text)
- if G.F_LOCAL_CLIPBOARD then
- G.CLIPBOARD = text
- else
- love.system.setClipboardText(text)
- end
-end
-
-function Utils.get_from_clipboard()
- if G.F_LOCAL_CLIPBOARD then
- return G.F_LOCAL_CLIPBOARD
- else
- return love.system.getClipboardText()
- end
-end
-
-
-function Utils.overlay_message(message)
- G.SETTINGS.paused = true
-
- G.FUNCS.overlay_menu({
- definition = create_UIBox_generic_options({
- contents = {
- {
- n = G.UIT.R,
- config = {
- padding = 0.5,
- align = "cm",
- },
- nodes = {
- {
- n = G.UIT.T,
- config = {
- scale = 0.6,
- shadow = true,
- text = message,
- colour = G.C.UI.TEXT_LIGHT
- }
- }
- }
- }
- }
- })
- })
-end
-
-return Utils
-
-----------------------------------------------
-------------MOD DEBUG END---------------------
\ No newline at end of file
diff --git a/Multiplayer/example.Config.lua b/Multiplayer/example.Config.lua
deleted file mode 100644
index cf757768..00000000
--- a/Multiplayer/example.Config.lua
+++ /dev/null
@@ -1,15 +0,0 @@
---- STEAMODDED HEADER
---- STEAMODDED SECONDARY FILE
-
-----------------------------------------------
-------------MOD CONFIG------------------------
-
-Config = {}
-
-Config.URL = 'localhost'
-Config.PORT = 8788
-
-return Config
-
-----------------------------------------------
-------------MOD CONFIG END--------------------
\ No newline at end of file
diff --git a/README.md b/README.md
index 6fb181c5..c4ea2090 100644
--- a/README.md
+++ b/README.md
@@ -1,84 +1,67 @@
-# A Balatro Multiplayer Mod
+# Balatro Multiplayer Mod
-This is an WIP Balatro multiplayer mod developed by virtualized.
+
-**Discord:** virtualized
+A multiplayer mod for Balatro, allowing players to compete with each other.
-**Twitter:** @v_rtualized
+## 📥 Installation
-This project will remain free and open source. It will also be continuously maintained, at least within the near future.
+Detailed installation instructions are available on our website:
+[https://balatromp.com/docs/getting-started/installation](https://balatromp.com/docs/getting-started/installation)
-If you make a video or stream Balatro using this mod then feel free to send me a DM on either platform above with a link, I would love to take a look :)
+*Requires [Steamodded](https://github.com/Steamodded/smods) (>=1.0.0~BETA-1221a) and [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) (>=0.8)
-## Goals for First Release
+Quick installation steps:
-- Some form of public server available, or a user friendly method to create a server
- - This was originally to us Steam servers but that doesn't seem to be an options unfortunately
-- At least 2 out of 4 planned game modes implemented
+1. Download the latest release from the [Releases page](https://github.com/Balatro-Multiplayer/BalatroMultiplayer/releases)
+2. Extract the files into a new folder in your Balatro mods directory
+3. Run the game
-## Planned Gamemodes
+## 🎲 Usage
-- Attrition (1v1)
- - Both players start with 4 lives, every boss round is a competition between players where the player with the lower score loses a life.
-- Draft (1v1)
- - Both players play a set amount of antes simultaneously, then they play an ante where every round the player with the higher scorer wins, player with the most round wins in the final ante is the victor.
-- Heads Up (1v1)
- - Both players play the first ante, then must keep beating the opponents previous score or lose.
-- Battle Royale (8p)
- - Draft, except there are up to 8 players and every player only has 1 life.
+1. Launch Balatro with the multiplayer mod enabled
+2. From the main menu, select "Play", then "Create Lobby"
+3. Select a ruleset/gamemode
+4. Press "View Code" and send the code to the person you want to play with
+5. The other player will select "Play", then "Join Lobby" and enter the code
+6. Press "Start" to start the game!
+
+## 🤝 Contributing
-\*Gamemode names and descriptions subject to change. 8p gamemodes will not be focused on until 1v1 gamemodes are stable.
+We welcome contributions to the Balatro Multiplayer Mod! Here's how you can help:
-## Installation
+### How to Contribute
-\*These steps won't work out of the box at the moment, as there is no public server to connect to. If you would like to host your own server to test the current progress then follow the steps below to
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
-### 1. Install [Steamodded](https://github.com/Steamopollys/Steamodded/tree/main)
+### Contribution Guidelines
-- Follow instructions to intall at that link
-- Currently Windows Defender recognizes Steamodded as a Trojan, you should always do your own research and not just randomly trust me to tell you it isn't a Trojan, but it isn't
- - If this is scary, you can alternatively run the source code by running steamodded_injector.py instead of the exe file in releases
+- Follow the existing code style and conventions
+- Write clear, descriptive commit messages
+- Clearly explain the feature you have implemented in the pull request
+- Ensure to properly test the feature and provide example seeds where the feature can clearly be seen working (if relevant)
-### 2. Download the "Multiplayer" folder into your Balatro Mods folder
+Contributions that make content changes like modifying how the base game works, adding cards or blinds, or adding gamemodes will likely not be accepted. We are currently trying to maintain the competitive integrity of the mod and these types of changes need to be decided on by our team before being added.
-(I may add a release to make this more straight forward but for now)
-- Click the green "Code" button > "Download Zip"
-- Unzip and move the "Multiplayer" folder inside to your Balatro mods folder
- - Steamodded creates this folder
- - Default location is `%appdata%/Balatro/Mods on Windows`
+### Looking to contribute but don't have a feature in mind?
-### 3. Set `Config.lua`
+Check the [issues](https://github.com/Balatro-Multiplayer/BalatroMultiplayer/issues), there are usually issues with the "Help Wanted" tag that are just looking for someone to work on them! I (Virtualized) try and be very clear about what the problem is and what the expected solution is in these issues so hopefully there is little confusion, but feel free to DM or ping `virtualized` on discord for clarification.
-- If there is no `Config.lua` file in the Multiplayer folder, then there is no public server, you need to either [Create a Dedicated Server](#creating-a-dedicated-server) or have a friend that does.
-- The `example.Config.lua` file does nothing and is just there for you to use as a Config template
- - ie. when you have a dedicated server to connect to, rename this file to `Config.lua` and change the values
+## 📜 License
-### 4. Launch Balatro
+This project is licensed under the GNU General Public License v3.0 - see the [LICENSE.md](https://github.com/V-rtualized/balatro-multiplayer/blob/main/LICENSE.md) file for details.
-- You can confirm that the mod is working if you see the "x.x.x-Multiplayer" version tag in the top right of the main menu
- - Note, this does not confirm whether you are successfully connected to the server, just whether the mod is installed properly
-- Alternatively you can click Steamodded's "Mods" button in the main menu and "Multiplayer by Virtualized" should be listed there
+## 👏 Acknowledgements
-## Creating a Dedicated Server
+- The LocalThunk for creating such an amazing game
+- [All the contributors](https://github.com/Balatro-Multiplayer/BalatroMultiplayer/graphs/contributors) for their hard work
+- Our Discord community for feedback, testing, and support
-*This will not be required on full release, there will be a public server available to anyone
+---
-*Dedicated is a bit missleading, there is no matchmaking or server browser, it is just a seperate program from the base mod
-
-*These steps assume you have solid understanding of computers and a bit of programming knowledge
-
-### 1. Install [Docker Compose](https://docs.docker.com/compose/install/)
-
-### 2. Download the "Server" folder
-
-### 3. Run the `Server/docker-compose.yml` file with Docker Compose
-
-- Use Docker Desktop for GUI or the `docker compose up` command in terminal/cmd
-
-- Port 8788 needs to be forwarded for anyone else to connect, this port can be configured by changing this line in `docker-compose.yml`:
-```
-ports:
- - "your_port_here:8080"
-```
-
-- All clients that you want to connect to the server needs to modify their `Multiplayer/Config.lua` to have your ip as the value for `Config.URL` and your port (if you changed it) for the value of `Config.PORT`
\ No newline at end of file
+Join our [Discord server](https://discord.gg/balatromp) for support, to report bugs, or just to chat!
+[Website](https://balatromp.com) | [GitHub](https://github.com/Balatro-Multiplayer/balatro-multiplayer)
diff --git a/Server/Dockerfile b/Server/Dockerfile
deleted file mode 100644
index fdaf2dc1..00000000
--- a/Server/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM node:21
-
-WORKDIR /app
-
-COPY package*.json ./
-
-RUN npm install
-
-COPY . .
-
-EXPOSE 8080
-
-CMD [ "node", "src/main.js" ]
diff --git a/Server/README.md b/Server/README.md
deleted file mode 100644
index a5de3a77..00000000
--- a/Server/README.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Server API Documentation
-
-This server is written using good faith, there is no verification of data sent from the client, we have to assume there has been no tampering. That being said, it would be possible to maliciously manipulate other's games if you can impersonate another socket connection, though I am not sure how possible/easy this is. I would love to implement TLS but at this moment I don't think it is possible for me to add lua packages (such as an SSL package) without including the full source code. A security-based C module for the mod might be nessesary in the future.
-
-For ease of parsing in lua, communications between the client and server are in CSV, where the "action" column is manditory with every socket message. The rest of the columns are action-specific. Columns do not need to be in any particular order.
-
-## Actions
-
-example_action_name: param1, param2?
-- description of action
-- param1: description of param1
-- param2?: description of optional param2
-
-### Server to Client
-
-connected
-- Client successfully connected
-
----
-
-error: message
-- An error, this should only be used when needed since it is very intrusive
-
----
-
-joinedLobby: code
-- Client should act as if in a lobby with given code
-- code: 5 letter code acting as a lobby ID
-
----
-
-lobbyInfo: host, guest?
-- Gives clients info on the lobby state
-- host: Lobby host's username
-- guest?: Lobby guest's username
-
-*This will obviously need reworking for 8 players but it is the simplest way of doing it for now
-
-### Client to Server
-
-username: username
-- Set the client's username
-- username: The value
-
----
-
-createLobby
-- Request to make a lobby and be given a code. Response should be a 'joinedLobby' action
-
----
-
-joinLobby: code
-- Request to join an existing lobby, by given code. Response should be a 'joinedLobby' action, or 'error' if the lobby cannot be joined
-- code: 5 letter code acting as a lobby ID
-
----
-
-leaveLobby
-- Leave the joined lobby, this is also called on client connection destruction so it needs to be functional without providing a code
-
----
-
-lobbyInfo
-- Request for an accurate 'lobbyInfo' response, for the lobby the client is connected to
\ No newline at end of file
diff --git a/Server/docker-compose.yml b/Server/docker-compose.yml
deleted file mode 100644
index 17af5ec7..00000000
--- a/Server/docker-compose.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-version: '3.8'
-services:
- socket:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "8788:8080"
- environment:
- NODE_ENV: development
- restart: unless-stopped
\ No newline at end of file
diff --git a/Server/package-lock.json b/Server/package-lock.json
deleted file mode 100644
index 97490a8c..00000000
--- a/Server/package-lock.json
+++ /dev/null
@@ -1,766 +0,0 @@
-{
- "name": "Server",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "dependencies": {
- "body-parser": "^1.20.2",
- "cors": "^2.8.5",
- "express": "^4.18.3",
- "express-ws": "^5.0.2",
- "net": "^1.0.2",
- "uuid": "^9.0.1"
- }
- },
- "node_modules/accepts": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
- "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
- "dependencies": {
- "mime-types": "~2.1.34",
- "negotiator": "0.6.3"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/array-flatten": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
- },
- "node_modules/body-parser": {
- "version": "1.20.2",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
- "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
- "dependencies": {
- "bytes": "3.1.2",
- "content-type": "~1.0.5",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "on-finished": "2.4.1",
- "qs": "6.11.0",
- "raw-body": "2.5.2",
- "type-is": "~1.6.18",
- "unpipe": "1.0.0"
- },
- "engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
- "node_modules/bytes": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/call-bind": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
- "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
- "dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
- "get-intrinsic": "^1.2.4",
- "set-function-length": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/content-disposition": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
- "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dependencies": {
- "safe-buffer": "5.2.1"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie-signature": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
- "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
- },
- "node_modules/cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
- "dependencies": {
- "object-assign": "^4",
- "vary": "^1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/define-data-property": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
- "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
- "dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "gopd": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/depd": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/destroy": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
- "engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
- "node_modules/ee-first": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
- },
- "node_modules/encodeurl": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
- "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
- "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
- "dependencies": {
- "get-intrinsic": "^1.2.4"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/escape-html": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
- },
- "node_modules/etag": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/express": {
- "version": "4.18.3",
- "resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz",
- "integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==",
- "dependencies": {
- "accepts": "~1.3.8",
- "array-flatten": "1.1.1",
- "body-parser": "1.20.2",
- "content-disposition": "0.5.4",
- "content-type": "~1.0.4",
- "cookie": "0.5.0",
- "cookie-signature": "1.0.6",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "finalhandler": "1.2.0",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "merge-descriptors": "1.0.1",
- "methods": "~1.1.2",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "path-to-regexp": "0.1.7",
- "proxy-addr": "~2.0.7",
- "qs": "6.11.0",
- "range-parser": "~1.2.1",
- "safe-buffer": "5.2.1",
- "send": "0.18.0",
- "serve-static": "1.15.0",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "type-is": "~1.6.18",
- "utils-merge": "1.0.1",
- "vary": "~1.1.2"
- },
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/express-ws": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz",
- "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==",
- "dependencies": {
- "ws": "^7.4.6"
- },
- "engines": {
- "node": ">=4.5.0"
- },
- "peerDependencies": {
- "express": "^4.0.0 || ^5.0.0-alpha.1"
- }
- },
- "node_modules/express-ws/node_modules/ws": {
- "version": "7.5.9",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
- "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
- "engines": {
- "node": ">=8.3.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": "^5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/finalhandler": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
- "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dependencies": {
- "debug": "2.6.9",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "statuses": "2.0.1",
- "unpipe": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/forwarded": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/fresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
- "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "hasown": "^2.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/gopd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
- "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
- "dependencies": {
- "get-intrinsic": "^1.1.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-property-descriptors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
- "dependencies": {
- "es-define-property": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-proto": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
- "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz",
- "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/http-errors": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
- "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
- "dependencies": {
- "depd": "2.0.0",
- "inherits": "2.0.4",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "toidentifier": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/iconv-lite": {
- "version": "0.4.24",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
- "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
- },
- "node_modules/ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/media-typer": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/merge-descriptors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
- },
- "node_modules/methods": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
- "bin": {
- "mime": "cli.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/negotiator": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
- "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/net": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz",
- "integrity": "sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ=="
- },
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz",
- "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
- "dependencies": {
- "ee-first": "1.1.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/parseurl": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/path-to-regexp": {
- "version": "0.1.7",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
- },
- "node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dependencies": {
- "forwarded": "0.2.0",
- "ipaddr.js": "1.9.1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/qs": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
- "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
- "dependencies": {
- "side-channel": "^1.0.4"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/range-parser": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/raw-body": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
- "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
- "dependencies": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "unpipe": "1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
- },
- "node_modules/send": {
- "version": "0.18.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
- "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
- "dependencies": {
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "mime": "1.6.0",
- "ms": "2.1.3",
- "on-finished": "2.4.1",
- "range-parser": "~1.2.1",
- "statuses": "2.0.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/send/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
- "node_modules/serve-static": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
- "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
- "dependencies": {
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "parseurl": "~1.3.3",
- "send": "0.18.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/set-function-length": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz",
- "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==",
- "dependencies": {
- "define-data-property": "^1.1.2",
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
- "get-intrinsic": "^1.2.3",
- "gopd": "^1.0.1",
- "has-property-descriptors": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/setprototypeof": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
- },
- "node_modules/side-channel": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
- "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
- "dependencies": {
- "call-bind": "^1.0.7",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.4",
- "object-inspect": "^1.13.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/toidentifier": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
- "engines": {
- "node": ">=0.6"
- }
- },
- "node_modules/type-is": {
- "version": "1.6.18",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
- "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dependencies": {
- "media-typer": "0.3.0",
- "mime-types": "~2.1.24"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/unpipe": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/utils-merge": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/vary": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
- "engines": {
- "node": ">= 0.8"
- }
- }
- }
-}
diff --git a/Server/package.json b/Server/package.json
deleted file mode 100644
index a92e0153..00000000
--- a/Server/package.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "dependencies": {
- "net": "^1.0.2",
- "uuid": "^9.0.1"
- },
- "type": "module"
-}
diff --git a/Server/src/Client.js b/Server/src/Client.js
deleted file mode 100644
index 18902b3e..00000000
--- a/Server/src/Client.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { v4 as uuidv4 } from 'uuid'
-
-class Client {
- constructor(send) {
- this.id = uuidv4()
- this.lobby = null
- this.username = 'Guest'
- this.send = send
- }
-
- setUsername = (username) => {
- this.username = username
- this.lobby?.broadcast()
- }
-
- setLobby = (lobby) => {
- this.lobby = lobby
- }
-}
-
-export default Client
\ No newline at end of file
diff --git a/Server/src/Lobby.js b/Server/src/Lobby.js
deleted file mode 100644
index 89f35247..00000000
--- a/Server/src/Lobby.js
+++ /dev/null
@@ -1,65 +0,0 @@
-const Lobbies = new Map()
-
-const generateUniqueLobbyCode = () => {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
- let result = ''
- for (let i = 0; i < 5; i++) {
- result += chars.charAt(Math.floor(Math.random() * chars.length))
- }
- return Lobbies.get(result) ? generateUniqueLobbyCode() : result
-}
-
-class Lobby {
- constructor(host) {
- do {
- this.code = generateUniqueLobbyCode()
- } while (Lobbies.get(this.code))
- Lobbies.set(this.code, this)
- this.host = host
- this.guest = null
- host.setLobby(this)
- host.send(`action:joinedLobby,code:${this.code}`)
- }
-
- static get = (code) => {
- return Lobbies.get(code)
- }
-
- leave = (client) => {
- if (this.host.id === client.id) {
- this.host = this.guest
- this.guest = null
- }
- if (this.guest?.id === client.id) {
- this.guest = null
- }
- client.setLobby(null)
- if (this.host === null) {
- Lobbies.delete(this.code)
- } else {
- this.broadcast()
- }
- }
-
- join = (client) => {
- if (this.guest) {
- client.send('action:error,message:Lobby is full or does not exist.')
- return
- }
- this.guest = client
- client.setLobby(this)
- client.send(`action:joinedLobby,code:${this.code}`)
- this.broadcast()
- }
-
- broadcast = () => {
- let message = `action:lobbyInfo,host:${this.host.username}`
- if (this.guest?.username) {
- message += `,guest:${this.guest.username}`
- this.guest.send(message)
- }
- this.host.send(message)
- }
-}
-
-export default Lobby
\ No newline at end of file
diff --git a/Server/src/main.js b/Server/src/main.js
deleted file mode 100644
index 01e5b386..00000000
--- a/Server/src/main.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import net from 'net'
-import Client from './Client.js'
-import Lobby from './Lobby.js'
-
-const PORT = 8080
-
-const stringToJson = (str) => {
- const obj = {}
- str.split(',').forEach(part => {
- const [key, value] = part.split(':')
- obj[key] = value
- })
- return obj
-}
-
-const sendToSocket = (socket) => (data) => {
- //console.log('Responding with ' + data)
- if (!socket) {
- //console.log('Socket is undefined')
- return
- }
- socket.write(data + '\n')
-}
-
-const usernameAction = (client, username) => {
- client.setUsername(username)
-}
-
-const createLobbyAction = (client) => {
- new Lobby(client)
-}
-
-const joinLobbyAction = (client, code) => {
- const newLobby = Lobby.get(code)
- if (!newLobby) {
- client.send('action:error,message:Lobby is full or does not exist.')
- return
- }
- newLobby.join(client)
-}
-
-const leaveLobbyAction = (client) => {
- client.lobby?.leave(client)
-}
-
-const lobbyInfoAction = (client) => {
- client.lobby?.broadcast()
-}
-
-const server = net.createServer((socket) => {
- const client = new Client(sendToSocket(socket))
- //console.log('Client connected')
- client.send(`action:connected`)
-
- socket.on('data', (data) => {
- const messages = data.toString().split('\n')
- messages.forEach((msg) => {
- if (!msg) return
- //console.log('Recieved message ' + msg)
- try {
- const message = stringToJson(msg)
- switch (message.action) {
- case 'username':
- usernameAction(client, message.username)
- break
- case 'createLobby':
- createLobbyAction(client)
- break
- case 'joinLobby':
- joinLobbyAction(client, message.code)
- break
- case 'leaveLobby':
- leaveLobbyAction(client)
- break
- case 'lobbyInfo':
- lobbyInfoAction(client)
- break
- }
- } catch (error) {
- console.error('Failed to parse message', error)
- client.send(socket, `action:error,message:Failed to parse message`)
- }
- })
- })
-
- socket.on('end', () => {
- //console.log('Client disconnected')
- leaveLobbyAction(client)
- })
-
- socket.on('error', (err) => {
- if (err.code === 'ECONNRESET') {
- console.warn('TCP connection reset by peer (client).')
- } else {
- console.error('An unexpected error occurred:', err)
- }
- leaveLobbyAction(client)
- })
-})
-
-server.listen(PORT, '0.0.0.0', () => {
- //console.log(`Server listening on port ${PORT}`);
-})
\ No newline at end of file
diff --git a/Testing/main.js b/Testing/main.js
deleted file mode 100644
index 7eed5fcc..00000000
--- a/Testing/main.js
+++ /dev/null
@@ -1,87 +0,0 @@
-const net = require('net');
-
-const TOTAL_CLIENTS = 10000; // Adjust based on your system's capability
-const PORT = 8788;
-const HOST = 'virtualized.dev'; // Change to your server's IP address if not local
-
-let clients = 0
-let latency = []
-
-const responseToJson = (response) => {
- const jsonObj = {};
- response.split(',').forEach(part => {
- const [key, value] = part.split(':');
- jsonObj[key.trim()] = value?.trim();
- });
- return jsonObj;
-}
-
-const connectClient = (code) => {
- const socket = new net.Socket()
- let start
-
- socket.connect(PORT, HOST, () => {
- clients++
- });
-
- socket.on('data', (data) => {
- const dataJson = responseToJson(data.toString().trim())
- if (dataJson.action === 'connected') {
- socket.write('action:username,username:123\n')
- }
- if (dataJson.action === 'confirmed') {
- start = Date.now()
- if (!code) {
- socket.write('action:createLobby\n')
- } else {
- socket.write('action:joinLobby,code:' + code + '\n')
- }
- }
- if (dataJson.action === 'joinedLobby') {
- if (!code) {
- connectClient(dataJson.code)
- }
- if (start) {
- latency.push(Date.now() - start)
- }
- setTimeout(() => {
- socket.write('action:leaveLobby\n')
- setTimeout(() => {
- socket.end()
- }, 5000)
- }, 10000)
- }
- if (dataJson.action === 'error') {
- console.log(dataJson.message)
- }
- });
-
- socket.on('error', (err) => {
- console.error(`Client (${clients}) error: ${err.message}`);
- socket.destroy();
- });
-
- socket.on('close', () => {
- clients--;
- if (clients === 0) {
- console.log(`All clients have disconnected.`);
- console.log(`Failed clients: ${TOTAL_CLIENTS - latency.length}`)
- console.log(`First 10 latency:`);
- for (let i = 0; i < 10; i++) {
- console.log(`${latency.shift()}`);
- }
- console.log(`Last 10 latency:`);
- for (let i = 0; i < 10; i++) {
- console.log(`${latency.pop()}`);
- }
- console.log(`Average latency: ${latency.reduce((acc, curr, _, { length }) => acc + curr / length, 0)}`);
- }
- });
-};
-
-for (let i = 0; i < TOTAL_CLIENTS / 2; i++) {
- connectClient(null);
- if (i === TOTAL_CLIENTS / 2) console.log('Last initialized')
-}
-
-console.log(`All clients have connected.`);
\ No newline at end of file
diff --git a/Testing/package-lock.json b/Testing/package-lock.json
deleted file mode 100644
index 085d0458..00000000
--- a/Testing/package-lock.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "Testing",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "dependencies": {
- "net": "^1.0.2"
- }
- },
- "node_modules/net": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz",
- "integrity": "sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ=="
- }
- }
-}
diff --git a/Testing/package.json b/Testing/package.json
deleted file mode 100644
index d7363ab5..00000000
--- a/Testing/package.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "dependencies": {
- "net": "^1.0.2"
- }
-}
diff --git a/assets/1x/alt_mp_stakes.png b/assets/1x/alt_mp_stakes.png
new file mode 100644
index 00000000..ab8f9700
Binary files /dev/null and b/assets/1x/alt_mp_stakes.png differ
diff --git a/assets/1x/alt_stickers.png b/assets/1x/alt_stickers.png
new file mode 100644
index 00000000..ac96ed27
Binary files /dev/null and b/assets/1x/alt_stickers.png differ
diff --git a/assets/1x/b_heidelberg.png b/assets/1x/b_heidelberg.png
new file mode 100644
index 00000000..d3b7c6e7
Binary files /dev/null and b/assets/1x/b_heidelberg.png differ
diff --git a/assets/1x/blind_col.png b/assets/1x/blind_col.png
new file mode 100644
index 00000000..f465cb88
Binary files /dev/null and b/assets/1x/blind_col.png differ
diff --git a/assets/1x/c_asteroid.png b/assets/1x/c_asteroid.png
new file mode 100644
index 00000000..2b888ddf
Binary files /dev/null and b/assets/1x/c_asteroid.png differ
diff --git a/assets/1x/c_asteroid_ru.png b/assets/1x/c_asteroid_ru.png
new file mode 100644
index 00000000..71916fa4
Binary files /dev/null and b/assets/1x/c_asteroid_ru.png differ
diff --git a/assets/1x/c_ouija_2.png b/assets/1x/c_ouija_2.png
new file mode 100644
index 00000000..8377c5a2
Binary files /dev/null and b/assets/1x/c_ouija_2.png differ
diff --git a/assets/1x/deck_stickers.png b/assets/1x/deck_stickers.png
new file mode 100644
index 00000000..4ac8c4a7
Binary files /dev/null and b/assets/1x/deck_stickers.png differ
diff --git a/assets/1x/decks.png b/assets/1x/decks.png
new file mode 100644
index 00000000..29626e26
Binary files /dev/null and b/assets/1x/decks.png differ
diff --git a/assets/1x/ec_jokers_sandbox.png b/assets/1x/ec_jokers_sandbox.png
new file mode 100644
index 00000000..c87c97bb
Binary files /dev/null and b/assets/1x/ec_jokers_sandbox.png differ
diff --git a/assets/1x/ec_other_sandbox.png b/assets/1x/ec_other_sandbox.png
new file mode 100644
index 00000000..3e261dca
Binary files /dev/null and b/assets/1x/ec_other_sandbox.png differ
diff --git a/assets/1x/j_ERROR_sandbox.png b/assets/1x/j_ERROR_sandbox.png
new file mode 100644
index 00000000..ca7cf7b6
Binary files /dev/null and b/assets/1x/j_ERROR_sandbox.png differ
diff --git a/assets/1x/j_baseball_sandbox.png b/assets/1x/j_baseball_sandbox.png
new file mode 100644
index 00000000..702d022b
Binary files /dev/null and b/assets/1x/j_baseball_sandbox.png differ
diff --git a/assets/1x/j_bloodstone_sandbox.png b/assets/1x/j_bloodstone_sandbox.png
new file mode 100644
index 00000000..78ac20a2
Binary files /dev/null and b/assets/1x/j_bloodstone_sandbox.png differ
diff --git a/assets/1x/j_castle_sandbox.png b/assets/1x/j_castle_sandbox.png
new file mode 100644
index 00000000..f8dc4198
Binary files /dev/null and b/assets/1x/j_castle_sandbox.png differ
diff --git a/assets/1x/j_cloud_9_sandbox.png b/assets/1x/j_cloud_9_sandbox.png
new file mode 100644
index 00000000..33cb0fdf
Binary files /dev/null and b/assets/1x/j_cloud_9_sandbox.png differ
diff --git a/assets/1x/j_conjoined_joker.png b/assets/1x/j_conjoined_joker.png
new file mode 100644
index 00000000..fac612ea
Binary files /dev/null and b/assets/1x/j_conjoined_joker.png differ
diff --git a/assets/1x/j_constellation_sandbox.png b/assets/1x/j_constellation_sandbox.png
new file mode 100644
index 00000000..61438dcc
Binary files /dev/null and b/assets/1x/j_constellation_sandbox.png differ
diff --git a/assets/1x/j_copycat.png b/assets/1x/j_copycat.png
new file mode 100644
index 00000000..f5a1bac1
Binary files /dev/null and b/assets/1x/j_copycat.png differ
diff --git a/assets/1x/j_defensive_joker.png b/assets/1x/j_defensive_joker.png
new file mode 100644
index 00000000..e6781474
Binary files /dev/null and b/assets/1x/j_defensive_joker.png differ
diff --git a/assets/1x/j_faceless_sandbox.png b/assets/1x/j_faceless_sandbox.png
new file mode 100644
index 00000000..e348ee04
Binary files /dev/null and b/assets/1x/j_faceless_sandbox.png differ
diff --git a/assets/1x/j_hit_the_road_sandbox.png b/assets/1x/j_hit_the_road_sandbox.png
new file mode 100644
index 00000000..9cbee583
Binary files /dev/null and b/assets/1x/j_hit_the_road_sandbox.png differ
diff --git a/assets/1x/j_idol_sandbox_bw.png b/assets/1x/j_idol_sandbox_bw.png
new file mode 100644
index 00000000..6f6fdb91
Binary files /dev/null and b/assets/1x/j_idol_sandbox_bw.png differ
diff --git a/assets/1x/j_idol_sandbox_color.png b/assets/1x/j_idol_sandbox_color.png
new file mode 100644
index 00000000..f70996ca
Binary files /dev/null and b/assets/1x/j_idol_sandbox_color.png differ
diff --git a/assets/1x/j_juggler_sandbox.png b/assets/1x/j_juggler_sandbox.png
new file mode 100644
index 00000000..70607ae7
Binary files /dev/null and b/assets/1x/j_juggler_sandbox.png differ
diff --git a/assets/1x/j_lets_go_gambling.png b/assets/1x/j_lets_go_gambling.png
new file mode 100644
index 00000000..7dc8db96
Binary files /dev/null and b/assets/1x/j_lets_go_gambling.png differ
diff --git a/assets/1x/j_loyalty_card_sandbox.png b/assets/1x/j_loyalty_card_sandbox.png
new file mode 100644
index 00000000..431425d1
Binary files /dev/null and b/assets/1x/j_loyalty_card_sandbox.png differ
diff --git a/assets/1x/j_lucky_cat_sandbox.png b/assets/1x/j_lucky_cat_sandbox.png
new file mode 100644
index 00000000..de4c375a
Binary files /dev/null and b/assets/1x/j_lucky_cat_sandbox.png differ
diff --git a/assets/1x/j_magnet.png b/assets/1x/j_magnet.png
new file mode 100644
index 00000000..fa2cec0e
Binary files /dev/null and b/assets/1x/j_magnet.png differ
diff --git a/assets/1x/j_mail_sandbox.png b/assets/1x/j_mail_sandbox.png
new file mode 100644
index 00000000..9574e22f
Binary files /dev/null and b/assets/1x/j_mail_sandbox.png differ
diff --git a/assets/1x/j_misprint_sandbox.png b/assets/1x/j_misprint_sandbox.png
new file mode 100644
index 00000000..6fc28401
Binary files /dev/null and b/assets/1x/j_misprint_sandbox.png differ
diff --git a/assets/1x/j_order_sandbox.png b/assets/1x/j_order_sandbox.png
new file mode 100644
index 00000000..43e24ee6
Binary files /dev/null and b/assets/1x/j_order_sandbox.png differ
diff --git a/assets/1x/j_pacifist.png b/assets/1x/j_pacifist.png
new file mode 100644
index 00000000..14b018ef
Binary files /dev/null and b/assets/1x/j_pacifist.png differ
diff --git a/assets/1x/j_penny_pincher.png b/assets/1x/j_penny_pincher.png
new file mode 100644
index 00000000..0263af27
Binary files /dev/null and b/assets/1x/j_penny_pincher.png differ
diff --git a/assets/1x/j_photograph_sandbox.png b/assets/1x/j_photograph_sandbox.png
new file mode 100644
index 00000000..d9ae605a
Binary files /dev/null and b/assets/1x/j_photograph_sandbox.png differ
diff --git a/assets/1x/j_pizza.png b/assets/1x/j_pizza.png
new file mode 100644
index 00000000..58f4e985
Binary files /dev/null and b/assets/1x/j_pizza.png differ
diff --git a/assets/1x/j_ride_the_bus_sandbox.png b/assets/1x/j_ride_the_bus_sandbox.png
new file mode 100644
index 00000000..169f5061
Binary files /dev/null and b/assets/1x/j_ride_the_bus_sandbox.png differ
diff --git a/assets/1x/j_runner_sandbox.png b/assets/1x/j_runner_sandbox.png
new file mode 100644
index 00000000..b384f04e
Binary files /dev/null and b/assets/1x/j_runner_sandbox.png differ
diff --git a/assets/1x/j_satellite_sandbox.png b/assets/1x/j_satellite_sandbox.png
new file mode 100644
index 00000000..847b1239
Binary files /dev/null and b/assets/1x/j_satellite_sandbox.png differ
diff --git a/assets/1x/j_skip_off.png b/assets/1x/j_skip_off.png
new file mode 100644
index 00000000..2f2dd8d0
Binary files /dev/null and b/assets/1x/j_skip_off.png differ
diff --git a/assets/1x/j_speedrun.png b/assets/1x/j_speedrun.png
new file mode 100644
index 00000000..3784e546
Binary files /dev/null and b/assets/1x/j_speedrun.png differ
diff --git a/assets/1x/j_square_sandbox.png b/assets/1x/j_square_sandbox.png
new file mode 100644
index 00000000..1352ac54
Binary files /dev/null and b/assets/1x/j_square_sandbox.png differ
diff --git a/assets/1x/j_steel_joker_sandbox.png b/assets/1x/j_steel_joker_sandbox.png
new file mode 100644
index 00000000..dfa06652
Binary files /dev/null and b/assets/1x/j_steel_joker_sandbox.png differ
diff --git a/assets/1x/j_taxes.png b/assets/1x/j_taxes.png
new file mode 100644
index 00000000..69beb6a1
Binary files /dev/null and b/assets/1x/j_taxes.png differ
diff --git a/assets/1x/j_throwback_sandbox.png b/assets/1x/j_throwback_sandbox.png
new file mode 100644
index 00000000..f43eaf00
Binary files /dev/null and b/assets/1x/j_throwback_sandbox.png differ
diff --git a/assets/1x/j_vampire_sandbox.png b/assets/1x/j_vampire_sandbox.png
new file mode 100644
index 00000000..72f245d6
Binary files /dev/null and b/assets/1x/j_vampire_sandbox.png differ
diff --git a/assets/1x/modicon.png b/assets/1x/modicon.png
new file mode 100644
index 00000000..4e6e73bb
Binary files /dev/null and b/assets/1x/modicon.png differ
diff --git a/assets/1x/player_blind_row.png b/assets/1x/player_blind_row.png
new file mode 100644
index 00000000..f29bfc3a
Binary files /dev/null and b/assets/1x/player_blind_row.png differ
diff --git a/assets/1x/stakes-chips.png b/assets/1x/stakes-chips.png
new file mode 100644
index 00000000..aa180a80
Binary files /dev/null and b/assets/1x/stakes-chips.png differ
diff --git a/assets/1x/standard_giga.png b/assets/1x/standard_giga.png
new file mode 100644
index 00000000..fda44c8a
Binary files /dev/null and b/assets/1x/standard_giga.png differ
diff --git a/assets/1x/sticker_balanced.png b/assets/1x/sticker_balanced.png
new file mode 100644
index 00000000..2a86239d
Binary files /dev/null and b/assets/1x/sticker_balanced.png differ
diff --git a/assets/1x/sticker_nemesis.png b/assets/1x/sticker_nemesis.png
new file mode 100644
index 00000000..3a4fc015
Binary files /dev/null and b/assets/1x/sticker_nemesis.png differ
diff --git a/assets/1x/stickers.png b/assets/1x/stickers.png
new file mode 100644
index 00000000..ef1c1c63
Binary files /dev/null and b/assets/1x/stickers.png differ
diff --git a/assets/1x/tag_gambling_sandbox.png b/assets/1x/tag_gambling_sandbox.png
new file mode 100644
index 00000000..9defb0d7
Binary files /dev/null and b/assets/1x/tag_gambling_sandbox.png differ
diff --git a/assets/2x/alt_mp_stakes.png b/assets/2x/alt_mp_stakes.png
new file mode 100644
index 00000000..bd806c3f
Binary files /dev/null and b/assets/2x/alt_mp_stakes.png differ
diff --git a/assets/2x/alt_stickers.png b/assets/2x/alt_stickers.png
new file mode 100644
index 00000000..f2d7ba3b
Binary files /dev/null and b/assets/2x/alt_stickers.png differ
diff --git a/assets/2x/b_heidelberg.png b/assets/2x/b_heidelberg.png
new file mode 100644
index 00000000..7b8d21e4
Binary files /dev/null and b/assets/2x/b_heidelberg.png differ
diff --git a/assets/2x/blind_col.png b/assets/2x/blind_col.png
new file mode 100644
index 00000000..f6948cef
Binary files /dev/null and b/assets/2x/blind_col.png differ
diff --git a/assets/2x/c_asteroid.png b/assets/2x/c_asteroid.png
new file mode 100644
index 00000000..5968cf7c
Binary files /dev/null and b/assets/2x/c_asteroid.png differ
diff --git a/assets/2x/c_asteroid_ru.png b/assets/2x/c_asteroid_ru.png
new file mode 100644
index 00000000..97eca048
Binary files /dev/null and b/assets/2x/c_asteroid_ru.png differ
diff --git a/assets/2x/c_ouija_2.png b/assets/2x/c_ouija_2.png
new file mode 100644
index 00000000..a56f02bb
Binary files /dev/null and b/assets/2x/c_ouija_2.png differ
diff --git a/assets/2x/deck_stickers.png b/assets/2x/deck_stickers.png
new file mode 100644
index 00000000..bb35173c
Binary files /dev/null and b/assets/2x/deck_stickers.png differ
diff --git a/assets/2x/decks.png b/assets/2x/decks.png
new file mode 100644
index 00000000..7de3ace7
Binary files /dev/null and b/assets/2x/decks.png differ
diff --git a/assets/2x/ec_jokers_sandbox.png b/assets/2x/ec_jokers_sandbox.png
new file mode 100644
index 00000000..ba77f538
Binary files /dev/null and b/assets/2x/ec_jokers_sandbox.png differ
diff --git a/assets/2x/ec_other_sandbox.png b/assets/2x/ec_other_sandbox.png
new file mode 100644
index 00000000..6c6b093f
Binary files /dev/null and b/assets/2x/ec_other_sandbox.png differ
diff --git a/assets/2x/j_ERROR_sandbox.png b/assets/2x/j_ERROR_sandbox.png
new file mode 100644
index 00000000..eca3bdc7
Binary files /dev/null and b/assets/2x/j_ERROR_sandbox.png differ
diff --git a/assets/2x/j_baseball_sandbox.png b/assets/2x/j_baseball_sandbox.png
new file mode 100644
index 00000000..aaa5b0ec
Binary files /dev/null and b/assets/2x/j_baseball_sandbox.png differ
diff --git a/assets/2x/j_bloodstone_sandbox.png b/assets/2x/j_bloodstone_sandbox.png
new file mode 100644
index 00000000..9f889065
Binary files /dev/null and b/assets/2x/j_bloodstone_sandbox.png differ
diff --git a/assets/2x/j_castle_sandbox.png b/assets/2x/j_castle_sandbox.png
new file mode 100644
index 00000000..b9e8f89e
Binary files /dev/null and b/assets/2x/j_castle_sandbox.png differ
diff --git a/assets/2x/j_cloud_9_sandbox.png b/assets/2x/j_cloud_9_sandbox.png
new file mode 100644
index 00000000..1804d93f
Binary files /dev/null and b/assets/2x/j_cloud_9_sandbox.png differ
diff --git a/assets/2x/j_conjoined_joker.png b/assets/2x/j_conjoined_joker.png
new file mode 100644
index 00000000..d989935c
Binary files /dev/null and b/assets/2x/j_conjoined_joker.png differ
diff --git a/assets/2x/j_constellation_sandbox.png b/assets/2x/j_constellation_sandbox.png
new file mode 100644
index 00000000..fd293219
Binary files /dev/null and b/assets/2x/j_constellation_sandbox.png differ
diff --git a/assets/2x/j_copycat.png b/assets/2x/j_copycat.png
new file mode 100644
index 00000000..a4e83285
Binary files /dev/null and b/assets/2x/j_copycat.png differ
diff --git a/assets/2x/j_defensive_joker.png b/assets/2x/j_defensive_joker.png
new file mode 100644
index 00000000..7bcc78e9
Binary files /dev/null and b/assets/2x/j_defensive_joker.png differ
diff --git a/assets/2x/j_faceless_sandbox.png b/assets/2x/j_faceless_sandbox.png
new file mode 100644
index 00000000..3a52ac1f
Binary files /dev/null and b/assets/2x/j_faceless_sandbox.png differ
diff --git a/assets/2x/j_hit_the_road_sandbox.png b/assets/2x/j_hit_the_road_sandbox.png
new file mode 100644
index 00000000..1bdf1287
Binary files /dev/null and b/assets/2x/j_hit_the_road_sandbox.png differ
diff --git a/assets/2x/j_idol_sandbox_bw.png b/assets/2x/j_idol_sandbox_bw.png
new file mode 100644
index 00000000..01a8a500
Binary files /dev/null and b/assets/2x/j_idol_sandbox_bw.png differ
diff --git a/assets/2x/j_idol_sandbox_color.png b/assets/2x/j_idol_sandbox_color.png
new file mode 100644
index 00000000..024fa502
Binary files /dev/null and b/assets/2x/j_idol_sandbox_color.png differ
diff --git a/assets/2x/j_juggler_sandbox.png b/assets/2x/j_juggler_sandbox.png
new file mode 100644
index 00000000..f9b0aadc
Binary files /dev/null and b/assets/2x/j_juggler_sandbox.png differ
diff --git a/assets/2x/j_lets_go_gambling.png b/assets/2x/j_lets_go_gambling.png
new file mode 100644
index 00000000..44c1b5e4
Binary files /dev/null and b/assets/2x/j_lets_go_gambling.png differ
diff --git a/assets/2x/j_loyalty_card_sandbox.png b/assets/2x/j_loyalty_card_sandbox.png
new file mode 100644
index 00000000..a924f56f
Binary files /dev/null and b/assets/2x/j_loyalty_card_sandbox.png differ
diff --git a/assets/2x/j_lucky_cat_sandbox.png b/assets/2x/j_lucky_cat_sandbox.png
new file mode 100644
index 00000000..6d09cb3e
Binary files /dev/null and b/assets/2x/j_lucky_cat_sandbox.png differ
diff --git a/assets/2x/j_magnet.png b/assets/2x/j_magnet.png
new file mode 100644
index 00000000..1d8e127d
Binary files /dev/null and b/assets/2x/j_magnet.png differ
diff --git a/assets/2x/j_mail_sandbox.png b/assets/2x/j_mail_sandbox.png
new file mode 100644
index 00000000..a4c4d3dd
Binary files /dev/null and b/assets/2x/j_mail_sandbox.png differ
diff --git a/assets/2x/j_misprint_sandbox.png b/assets/2x/j_misprint_sandbox.png
new file mode 100644
index 00000000..558141ee
Binary files /dev/null and b/assets/2x/j_misprint_sandbox.png differ
diff --git a/assets/2x/j_order_sandbox.png b/assets/2x/j_order_sandbox.png
new file mode 100644
index 00000000..bfe7130d
Binary files /dev/null and b/assets/2x/j_order_sandbox.png differ
diff --git a/assets/2x/j_pacifist.png b/assets/2x/j_pacifist.png
new file mode 100644
index 00000000..c190e362
Binary files /dev/null and b/assets/2x/j_pacifist.png differ
diff --git a/assets/2x/j_penny_pincher.png b/assets/2x/j_penny_pincher.png
new file mode 100644
index 00000000..61d4a093
Binary files /dev/null and b/assets/2x/j_penny_pincher.png differ
diff --git a/assets/2x/j_photograph_sandbox.png b/assets/2x/j_photograph_sandbox.png
new file mode 100644
index 00000000..5df9af6e
Binary files /dev/null and b/assets/2x/j_photograph_sandbox.png differ
diff --git a/assets/2x/j_pizza.png b/assets/2x/j_pizza.png
new file mode 100644
index 00000000..f6566d30
Binary files /dev/null and b/assets/2x/j_pizza.png differ
diff --git a/assets/2x/j_ride_the_bus_sandbox.png b/assets/2x/j_ride_the_bus_sandbox.png
new file mode 100644
index 00000000..ba857a39
Binary files /dev/null and b/assets/2x/j_ride_the_bus_sandbox.png differ
diff --git a/assets/2x/j_runner_sandbox.png b/assets/2x/j_runner_sandbox.png
new file mode 100644
index 00000000..d2917f10
Binary files /dev/null and b/assets/2x/j_runner_sandbox.png differ
diff --git a/assets/2x/j_satellite_sandbox.png b/assets/2x/j_satellite_sandbox.png
new file mode 100644
index 00000000..639b4732
Binary files /dev/null and b/assets/2x/j_satellite_sandbox.png differ
diff --git a/assets/2x/j_skip_off.png b/assets/2x/j_skip_off.png
new file mode 100644
index 00000000..8b076517
Binary files /dev/null and b/assets/2x/j_skip_off.png differ
diff --git a/assets/2x/j_speedrun.png b/assets/2x/j_speedrun.png
new file mode 100644
index 00000000..f160814c
Binary files /dev/null and b/assets/2x/j_speedrun.png differ
diff --git a/assets/2x/j_square_sandbox.png b/assets/2x/j_square_sandbox.png
new file mode 100644
index 00000000..060ecc50
Binary files /dev/null and b/assets/2x/j_square_sandbox.png differ
diff --git a/assets/2x/j_steel_joker_sandbox.png b/assets/2x/j_steel_joker_sandbox.png
new file mode 100644
index 00000000..d6ac8009
Binary files /dev/null and b/assets/2x/j_steel_joker_sandbox.png differ
diff --git a/assets/2x/j_taxes.png b/assets/2x/j_taxes.png
new file mode 100644
index 00000000..5d34df8d
Binary files /dev/null and b/assets/2x/j_taxes.png differ
diff --git a/assets/2x/j_throwback_sandbox.png b/assets/2x/j_throwback_sandbox.png
new file mode 100644
index 00000000..7e0ba00c
Binary files /dev/null and b/assets/2x/j_throwback_sandbox.png differ
diff --git a/assets/2x/j_vampire_sandbox.png b/assets/2x/j_vampire_sandbox.png
new file mode 100644
index 00000000..f858c16d
Binary files /dev/null and b/assets/2x/j_vampire_sandbox.png differ
diff --git a/assets/2x/modicon.png b/assets/2x/modicon.png
new file mode 100644
index 00000000..ae809cb8
Binary files /dev/null and b/assets/2x/modicon.png differ
diff --git a/assets/2x/player_blind_row.png b/assets/2x/player_blind_row.png
new file mode 100644
index 00000000..2aceb15e
Binary files /dev/null and b/assets/2x/player_blind_row.png differ
diff --git a/assets/2x/stakes-chips.png b/assets/2x/stakes-chips.png
new file mode 100644
index 00000000..c551d634
Binary files /dev/null and b/assets/2x/stakes-chips.png differ
diff --git a/assets/2x/standard_giga.png b/assets/2x/standard_giga.png
new file mode 100644
index 00000000..c8355cd0
Binary files /dev/null and b/assets/2x/standard_giga.png differ
diff --git a/assets/2x/sticker_balanced.png b/assets/2x/sticker_balanced.png
new file mode 100644
index 00000000..2382a928
Binary files /dev/null and b/assets/2x/sticker_balanced.png differ
diff --git a/assets/2x/sticker_nemesis.png b/assets/2x/sticker_nemesis.png
new file mode 100644
index 00000000..b74a4617
Binary files /dev/null and b/assets/2x/sticker_nemesis.png differ
diff --git a/assets/2x/tag_gambling_sandbox.png b/assets/2x/tag_gambling_sandbox.png
new file mode 100644
index 00000000..d43825ab
Binary files /dev/null and b/assets/2x/tag_gambling_sandbox.png differ
diff --git a/compatibility/AntePreview.lua b/compatibility/AntePreview.lua
new file mode 100644
index 00000000..fc1de0fc
--- /dev/null
+++ b/compatibility/AntePreview.lua
@@ -0,0 +1,11 @@
+if next(SMODS.find_mod("AntePreview")) then
+ sendDebugMessage("Next Ante Preview compatibility detected", "MULTIPLAYER")
+ local predict_next_ante_ref = predict_next_ante
+ function predict_next_ante()
+ local predictions = predict_next_ante_ref()
+ if MP.LOBBY.code then
+ if G.GAME.round_resets.ante > 1 then predictions.Boss.blind = "bl_mp_nemesis" end
+ end
+ return predictions
+ end
+end
diff --git a/compatibility/CodexArcanum.lua b/compatibility/CodexArcanum.lua
new file mode 100644
index 00000000..87123da6
--- /dev/null
+++ b/compatibility/CodexArcanum.lua
@@ -0,0 +1,5 @@
+if SMODS.Mods["CodexArcanum"] and SMODS.Mods["CodexArcanum"].can_load then
+ sendDebugMessage("Codex Arcanum compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_breaking_bozo")
+ MP.DECK.ban_card("c_alchemy_terra")
+end
diff --git a/compatibility/Cryptid.lua b/compatibility/Cryptid.lua
new file mode 100644
index 00000000..4839e7d1
--- /dev/null
+++ b/compatibility/Cryptid.lua
@@ -0,0 +1,61 @@
+if SMODS.Mods["Cryptid"] and SMODS.Mods["Cryptid"].can_load then
+ sendDebugMessage("Cryptid compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_cry_fleshpanopticon")
+ MP.DECK.ban_card("j_cry_candy_sticks")
+ MP.DECK.ban_card("j_cry_redeo")
+ MP.DECK.ban_card("j_cry_chocolate_dice")
+ MP.DECK.ban_card("j_cry_carved_pumpkin")
+ MP.DECK.ban_card("j_cry_pumpkin")
+ MP.DECK.ban_card("v_cry_asteroglyph")
+ MP.DECK.ban_card("c_cry_semicolon")
+ MP.DECK.ban_card("c_cry_crash")
+ MP.DECK.ban_card("c_cry_revert")
+ MP.DECK.ban_card("c_cry_analog")
+ MP.DECK.ban_card("c_cry_reboot")
+ MP.DECK.ban_blind("bl_cry_joke")
+
+ local defeat_ref = Blind.defeat
+ function Blind:defeat(silent)
+ if self.config.blind.key == nil then self.config.blind.key = "bl_nil" end
+ defeat_ref(self, silent)
+ end
+
+ local save_run_ref = save_run
+ function save_run()
+ if G.F_NO_SAVING then return end
+ save_run_ref()
+ end
+
+ function wheel_of_fortune_the_title_card()
+ return true
+ end
+
+ local get_random_consumable_ref = get_random_consumable
+ function get_random_consumable(seed, excluded_flags, banned_card, pool, no_undiscovered)
+ if not MP.LOBBY.code then
+ return get_random_consumable_ref(seed, excluded_flags, banned_card, pool, no_undiscovered)
+ end
+ local tries = 5
+ local card = nil
+ repeat
+ card = get_random_consumable_ref(seed, excluded_flags, banned_card, pool, no_undiscovered)
+ local is_banned = false
+
+ for _, banned in ipairs(MP.DECK.BANNED_CARDS) do
+ if card.key == banned.id then
+ sendWarnMessage("Attempted to create banned card: " .. card.key .. ", trying again", "MULTIPLAYER")
+ tries = tries - 1
+ is_banned = true
+ if tries <= 0 then
+ sendWarnMessage("Attempted to create banned cards too many times, giving up.", "MULTIPLAYER")
+ return card
+ end
+ break
+ end
+ end
+ until not is_banned
+ return card
+ end
+
+ MP.set_max_stake("stake_cry_emerald")
+end
diff --git a/compatibility/Distro.lua b/compatibility/Distro.lua
new file mode 100644
index 00000000..22916afe
--- /dev/null
+++ b/compatibility/Distro.lua
@@ -0,0 +1,105 @@
+if SMODS.Mods["Distro"] and SMODS.Mods["Distro"].can_load then
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ no_delete = true,
+ blockable = false,
+ blocking = false,
+ timer = "REAL",
+ func = function()
+ if DiscordIPC and DiscordIPC.send_activity then
+ local send_activity_ref = DiscordIPC.send_activity
+ DiscordIPC.send_activity = function(bypass_block)
+ if MP.LOBBY.code and not bypass_block then return end
+ send_activity_ref()
+ end
+ return true
+ end
+ end,
+ }))
+
+ function get_multiplayer_details()
+ local enemy_username = MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username
+
+ return "Multiplayer Versus " .. enemy_username .. " | " .. tostring(MP.GAME.lives) .. " Lives Left"
+ end
+
+ local start_run_ref = Game.start_run
+ function Game:start_run(args)
+ start_run_ref(self, args)
+
+ if MP.LOBBY.code then
+ local back_key, back_name = Distro.get_back_name()
+ local stake_key, stake_name = Distro.get_stake_name()
+
+ DiscordIPC.activity = {
+ details = get_multiplayer_details(),
+ state = "Selecting Blind",
+ timestamps = {
+ start = os.time() * 1000,
+ },
+ assets = {
+ large_image = back_key,
+ large_text = back_name,
+ small_image = stake_key,
+ small_text = stake_name,
+ },
+ }
+
+ DiscordIPC.send_activity(true)
+ end
+ end
+
+ local update_selecting_hand_ref = Game.update_selecting_hand
+ function Game:update_selecting_hand(dt)
+ if not G.STATE_COMPLETE then
+ if MP.LOBBY.code then
+ DiscordIPC.activity.details = get_multiplayer_details()
+ DiscordIPC.activity.state = G.GAME.current_round.hands_left
+ .. " Hands, "
+ .. G.GAME.current_round.discards_left
+ .. " Discards left"
+ DiscordIPC.send_activity(true)
+ end
+ end
+
+ update_selecting_hand_ref(self, dt)
+ end
+
+ local update_shop_ref = Game.update_shop
+ function Game:update_shop(dt)
+ if not G.STATE_COMPLETE then
+ if MP.LOBBY.code then
+ DiscordIPC.activity.details = get_multiplayer_details()
+ DiscordIPC.activity.state = "Shopping"
+ DiscordIPC.send_activity(true)
+ end
+ end
+
+ update_shop_ref(self, dt)
+ end
+
+ local main_menu_ref = Game.main_menu
+ function Game:main_menu(change_context)
+ main_menu_ref(self, change_context)
+
+ if MP.LOBBY.code then
+ local enemy_username = nil
+ if MP.LOBBY.is_host then
+ if MP.LOBBY.guest then enemy_username = MP.LOBBY.guest.username end
+ else
+ enemy_username = MP.LOBBY.host.username
+ end
+
+ DiscordIPC.activity = {
+ details = enemy_username and "In Multiplayer Lobby with " .. enemy_username or "In Multiplayer Lobby",
+ timestamps = {
+ start = os.time() * 1000,
+ },
+ assets = {
+ large_image = "default",
+ },
+ }
+ DiscordIPC.send_activity(true)
+ end
+ end
+end
diff --git a/compatibility/ExtraCredit.lua b/compatibility/ExtraCredit.lua
new file mode 100644
index 00000000..d3f4aa89
--- /dev/null
+++ b/compatibility/ExtraCredit.lua
@@ -0,0 +1,4 @@
+if SMODS.Mods["ExtraCredit"] and SMODS.Mods["ExtraCredit"].can_load then
+ sendDebugMessage("ExtraCredit compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_ExtraCredit_permanentmarker")
+end
diff --git a/compatibility/Handy.lua b/compatibility/Handy.lua
new file mode 100644
index 00000000..7b1ac9eb
--- /dev/null
+++ b/compatibility/Handy.lua
@@ -0,0 +1,176 @@
+if Handy then
+ -- In this version all checks included in mod, but since I care about older versions, keep all patches
+ if not (Handy.meta and Handy.meta["1.5.1a_multiplayer_check"]) then
+ -- Patch fake events check and execute
+ if not (Handy.meta and Handy.meta["1.4.1b_patched_select_blind_and_skip"]) then
+ local fake_events_check_ref = Handy.fake_events.check
+ function Handy.fake_events.check(arg)
+ if type(arg.func) == "function" and arg.node then
+ arg.func(arg.node)
+ return arg.node.config.button ~= nil, arg.node.config.button
+ end
+ return fake_events_check_ref(arg)
+ end
+ local fake_events_execute_ref = Handy.fake_events.execute
+ function Handy.fake_events.execute(arg)
+ if type(arg.func) == "function" and arg.node then
+ arg.func(arg.node)
+ else
+ fake_events_execute_ref(arg)
+ end
+ end
+ end
+
+ -- Disable all dangerous controls
+ if type(Handy.is_dangerous_actions_active) == "function" then
+ -- Updated version
+ function Handy.is_dangerous_actions_active()
+ return false
+ end
+ elseif Handy.dangerous_actions then
+ -- Older versions, just in case
+ function Handy.dangerous_actions.can_execute()
+ return false
+ end
+ function Handy.dangerous_actions.can_execute_tag()
+ return false
+ end
+ function Handy.dangerous_actions.update_state_panel()
+ return false
+ end
+ end
+
+ -- Disable game speed, Animation skip and Nopeus interaction
+ if type(Handy.get_module_override) == "function" then
+ -- Updater version
+ local func_ref = Handy.get_module_override
+ function Handy.get_module_override(module)
+ if
+ module
+ and (
+ module == Handy.cc.speed_multiplier
+ or module == Handy.cc.nopeus_interaction
+ or module == Handy.cc.animation_skip
+ )
+ then
+ return { enabled = false }
+ else
+ return func_ref(module)
+ end
+ end
+ else
+ -- Older versions, just in case
+ if Handy.speed_multiplier then
+ function Handy.speed_multiplier.can_execute()
+ return false
+ end
+ function Handy.speed_multiplier.get_actions()
+ return {
+ multiply = false,
+ divide = false,
+ }
+ end
+ end
+ if Handy.nopeus_interaction then
+ function Handy.nopeus_interaction.can_execute()
+ return false
+ end
+ function Handy.nopeus_interaction.get_actions()
+ return {
+ increase = false,
+ decrease = false,
+ }
+ end
+ end
+ end
+
+ -- Patch "Select blind" keybinds to prevent softlocks
+ if Handy.regular_keybinds then
+ if not (Handy.meta and Handy.meta["1.4.1b_patched_select_blind_and_skip"]) then
+ function Handy.regular_keybinds.can_select_blind(key)
+ if
+ not (
+ Handy.controller.is_module_key(Handy.config.current.regular_keybinds.select_blind, key)
+ and G.GAME.blind_on_deck
+ and G.GAME.round_resets.blind_choices[G.GAME.blind_on_deck]
+ )
+ then
+ return false
+ end
+
+ local success, button = pcall(function()
+ return G.blind_select_opts[string.lower(G.GAME.blind_on_deck)]:get_UIE_by_ID(
+ "select_blind_button"
+ )
+ end)
+ if not success or not button then return false end
+ if button.config and button.config.func then
+ return Handy.fake_events.check({
+ func = G.FUNCS[button.config.func],
+ node = button,
+ })
+ else
+ return true
+ end
+ end
+ function Handy.regular_keybinds.select_blind()
+ local success, button = pcall(function()
+ return G.blind_select_opts[string.lower(G.GAME.blind_on_deck)]:get_UIE_by_ID(
+ "select_blind_button"
+ )
+ end)
+
+ if success and button and button.config and button.config.button then
+ Handy.fake_events.execute({
+ func = G.FUNCS[button.config.button],
+ node = button,
+ })
+ end
+ end
+ end
+ end
+ end
+
+ -- Notify about successfully patched mod in button label and settings page
+ if Handy.UI then
+ if type(Handy.UI.get_options_button) == "function" then
+ local get_options_button_ref = Handy.UI.get_options_button
+ function Handy.UI.get_options_button(...)
+ if type(Handy.is_in_multiplayer) ~= "function" or Handy.is_in_multiplayer() then
+ return UIBox_button({
+ label = { "Handy [MP Patched]" },
+ button = "handy_open_options",
+ minw = 5,
+ colour = G.C.CHIPS,
+ })
+ end
+ return get_options_button_ref(...)
+ end
+ end
+ if type(Handy.UI.get_config_tab_overall) == "function" then
+ local func_ref = Handy.UI.get_config_tab_overall
+ function Handy.UI.get_config_tab_overall(...)
+ local result = func_ref(...)
+ if type(Handy.is_in_multiplayer) ~= "function" or Handy.is_in_multiplayer() then
+ table.insert(result, { n = G.UIT.R, config = { minh = 0.2 } })
+ table.insert(result, {
+ n = G.UIT.R,
+ config = { padding = 0.1, align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "Patched by Multiplayer mod: Speed multiplayer, Animation skip, Nopeus interaction and Dangerous controls are disabled.",
+ scale = 0.3,
+ colour = G.C.MULT,
+ align = "cm",
+ },
+ },
+ },
+ })
+ end
+ return result
+ end
+ end
+ end
+end
diff --git a/compatibility/HotPotato.lua b/compatibility/HotPotato.lua
new file mode 100644
index 00000000..f5892c9c
--- /dev/null
+++ b/compatibility/HotPotato.lua
@@ -0,0 +1,123 @@
+if SMODS.Mods["HotPotato"] and SMODS.Mods["HotPotato"].can_load then
+ sendDebugMessage("HotPotato compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_hpot_antidsestablishmentarianism") -- sic
+ MP.DECK.ban_card("j_hpot_brainfuck")
+ MP.DECK.ban_card("j_hpot_goldenchicot")
+ MP.DECK.ban_card("j_hpot_lockin")
+ MP.DECK.ban_card("j_joker")
+ MP.DECK.ban_card("j_hpot_lotus")
+ MP.DECK.ban_card("j_hpot_c_sharp")
+
+ MP.DECK.ban_card("j_hpot_goblin_tinkerer") -- too easy to infinite
+
+ -- essentially we're just hooking a bunch of functions to separate and normalise rng
+ -- i was gonna hook more but it ended up only being 2 so whatever
+
+ local hooks = {
+ { tbl = _G, str = "hotpot_delivery_refresh_card" },
+ { tbl = _G, str = "hotpot_jtem_generate_special_deals" },
+ }
+
+ local function hook(orig, ante)
+ return function(...)
+ local temp_ante = G.GAME.round_resets.ante
+ G.GAME.round_resets.ante = 89
+
+ local temp_used_jokers = G.GAME.used_jokers
+ G.GAME.used_jokers = {}
+
+ local temp_should_use_the_order = MP.should_use_the_order
+ MP.should_use_the_order = function()
+ return false
+ end
+
+ local results = orig(...)
+
+ G.GAME.round_resets.ante = temp_ante
+ G.GAME.used_jokers = temp_used_jokers
+ MP.should_use_the_order = temp_should_use_the_order
+
+ return results
+ end
+ end
+ for i, v in pairs(hooks) do
+ local orig = v.tbl[v.str]
+ v.tbl[v.str] = hook(orig)
+ end
+ local grant_wheel_reward_ref = grant_wheel_reward
+ function grant_wheel_reward(card)
+ if not card then
+ print("this should never happen")
+ card = G.wheel_rewards.cards[1]
+ end
+ if card.ability.set ~= "bottlecap" then
+ G.GAME.used_jokers[card.config.center.key] = true -- if there's no room, card will be removed so this is safe
+ end
+ return grant_wheel_reward_ref(card)
+ end
+ local generate_wheel_rewards_ref = generate_wheel_rewards
+ function generate_wheel_rewards(_amount)
+ -- randomise rotation
+ local rot = pseudorandom("hpot_wheel_rotation") * 2 * math.pi
+ G.wheel_arrow.cards[1].T.r = rot
+ G.GAME.keep_rotation = rot
+
+ -- constants from experimentation
+ -- this range encompasses an entire wheel spin, making every endpos equally likely
+ local min = 0.486225001705432
+ local max = 0.502020498677871
+ Wheel.starting_accel = (pseudorandom("hpot_wheel_starting_accel") * (max - min)) + min
+
+ -- nullify any vval (idk what this does exactly but it's annoying)
+ G.GAME.vval = 0
+ G.GAME.winning_vval = (G.GAME.vval / 10)
+ Wheel.KeepVval = G.GAME.vvals
+
+ -- basic rng isolation stuff
+ local temp_used_jokers = G.GAME.used_jokers
+ G.GAME.used_jokers = {}
+
+ local temp_ante = G.GAME.round_resets.ante
+ G.GAME.round_resets.ante = 78
+
+ local temp_should_use_the_order = MP.should_use_the_order
+ MP.should_use_the_order = function()
+ return false
+ end
+
+ local ret = generate_wheel_rewards_ref(_amount)
+
+ G.GAME.round_resets.ante = temp_ante
+ G.GAME.used_jokers = temp_used_jokers
+ MP.should_use_the_order = temp_should_use_the_order
+
+ return ret
+ end
+ local spin_wheel_ref = spin_wheel
+ function spin_wheel(...) -- this function name sucks
+ local ret = spin_wheel_ref(...)
+ Wheel.accel = Wheel.starting_accel
+ return ret
+ end
+ local set_ability_ref = Card.set_ability
+ function Card:set_ability(center, initial, delay_sprites)
+ if not G.OVERLAY_MENU then
+ if
+ (center.mp_include and type(center.mp_include) == "function" and not center:mp_include())
+ or G.GAME.banned_keys[center.key]
+ then
+ local swap = false
+ local done = false
+ while not done do
+ for i, v in ipairs(G.P_CENTER_POOLS[center.set]) do
+ if swap then
+ return self:set_ability(v, initial, delay_sprites) -- do not return ref
+ end
+ if v == center then swap = true end
+ end
+ end
+ end
+ end
+ return set_ability_ref(self, center, initial, delay_sprites)
+ end
+end
diff --git a/compatibility/Jen.lua b/compatibility/Jen.lua
new file mode 100644
index 00000000..09f235a7
--- /dev/null
+++ b/compatibility/Jen.lua
@@ -0,0 +1,7 @@
+if SMODS.Mods["jen"] and SMODS.Mods["jen"].can_load then
+ sendDebugMessage("Jen's compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_jen_hydrangea")
+ MP.DECK.ban_card("j_jen_gamingchair")
+ MP.DECK.ban_card("j_jen_kosmos")
+ MP.DECK.ban_card("c_jen_entropy")
+end
diff --git a/compatibility/JokerDisplay.lua b/compatibility/JokerDisplay.lua
new file mode 100644
index 00000000..af6a3b18
--- /dev/null
+++ b/compatibility/JokerDisplay.lua
@@ -0,0 +1,267 @@
+if SMODS.Mods["JokerDisplay"] and SMODS.Mods["JokerDisplay"].can_load then
+ if JokerDisplay then
+ local jd_def = JokerDisplay.Definitions
+ jd_def["j_mp_conjoined_joker"] = {
+ text = {
+ {
+ border_nodes = {
+ { text = "X" },
+ { ref_table = "card.joker_display_values", ref_value = "x_mult" },
+ },
+ },
+ },
+ calc_function = function(card)
+ card.joker_display_values.x_mult = MP.is_pvp_boss() and card.ability.extra.x_mult or 1
+ end,
+ }
+ jd_def["j_mp_defensive_joker"] = {
+ text = {
+ { text = "+" },
+ { ref_table = "card.joker_display_values", ref_value = "chips", retrigger_type = "mult" },
+ },
+ text_config = { colour = G.C.CHIPS },
+ calc_function = function(card)
+ card.joker_display_values.chips = card.ability.t_chips
+ end,
+ }
+ jd_def["j_mp_lets_go_gambling"] = {
+ text = {
+ {
+ border_nodes = {
+ { text = "X" },
+ { ref_table = "card.ability.extra", ref_value = "xmult" },
+ },
+ },
+ { text = " " },
+ { text = "$", colour = G.C.GOLD },
+ { ref_table = "card.ability.extra", ref_value = "dollars", colour = G.C.GOLD },
+ },
+ extra = {
+ {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "odds" },
+ { text = ")" },
+ },
+ },
+ extra_config = { colour = G.C.GREEN, scale = 0.3 },
+ calc_function = function(card)
+ card.joker_display_values.odds = localize({
+ type = "variable",
+ key = "jdis_odds",
+ vars = { (G.GAME and G.GAME.probabilities.normal or 1), card.ability.extra.odds },
+ })
+ end,
+ }
+ jd_def["j_mp_magnet_sandbox"] = {
+ reminder_text = {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "active" },
+ { text = ")" },
+ },
+ calc_function = function(card)
+ card.joker_display_values.active = card.ability.extra.current_rounds >= card.ability.extra.rounds
+ and localize("k_active")
+ or (card.ability.extra.current_rounds .. "/" .. card.ability.extra.rounds)
+ end,
+ }
+ jd_def["j_mp_pacifist"] = {
+ text = {
+ {
+ border_nodes = {
+ { text = "X" },
+ { ref_table = "card.joker_display_values", ref_value = "x_mult" },
+ },
+ },
+ },
+ calc_function = function(card)
+ card.joker_display_values.x_mult = not MP.is_pvp_boss() and card.ability.extra.x_mult or 1
+ end,
+ }
+ jd_def["j_mp_pizza"] = {
+ text = {
+ { text = "+", colour = G.C.RED },
+ { ref_table = "card.ability.extra", ref_value = "discards", colour = G.C.RED },
+ },
+ }
+ jd_def["j_mp_skip_off"] = {
+ text = {
+ { text = "+", colour = G.C.BLUE },
+ { ref_table = "card.ability.extra", ref_value = "hands", colour = G.C.BLUE },
+ { text = " " },
+ { text = "+", colour = G.C.RED },
+ { ref_table = "card.ability.extra", ref_value = "discards", colour = G.C.RED },
+ },
+ extra = {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "skip_diff" },
+ { text = ")" },
+ },
+ calc_function = function(card)
+ card.joker_display_values.skip_diff = G.GAME.skips ~= nil
+ and MP.GAME.enemy.skips ~= nil
+ and localize({
+ type = "variable",
+ key = MP.GAME.enemy.skips > G.GAME.skips and "a_mp_skips_behind"
+ or MP.GAME.enemy.skips == G.GAME.skips and "a_mp_skips_tied"
+ or "a_mp_skips_ahead",
+ vars = { math.abs(MP.GAME.enemy.skips - G.GAME.skips) },
+ })[1]
+ or ""
+ end,
+ }
+ jd_def["j_mp_taxes"] = {
+ text = {
+ { text = "+", colour = G.C.RED },
+ { ref_table = "card.ability.extra", ref_value = "mult", colour = G.C.RED, retrigger_type = "mult" },
+ },
+ }
+ jd_def["j_mp_hanging_chad"] = {
+ retrigger_function = function(playing_card, scoring_hand, held_in_hand, joker_card)
+ if held_in_hand then return 0 end
+ local sorted_cards = JokerDisplay.sort_cards(scoring_hand)
+ local first_card = sorted_cards and sorted_cards[1]
+ local second_card = sorted_cards and sorted_cards[2]
+ local retriggers = (
+ (
+ first_card
+ and playing_card == first_card
+ and joker_card.ability.extra * JokerDisplay.calculate_joker_triggers(joker_card)
+ ) or 0
+ )
+ + (
+ (
+ second_card
+ and playing_card == second_card
+ and joker_card.ability.extra * JokerDisplay.calculate_joker_triggers(joker_card)
+ ) or 0
+ )
+ return retriggers
+ end,
+ }
+ jd_def["j_mp_ticket"] = {
+ text = {
+ { text = "+$" },
+ { ref_table = "card.joker_display_values", ref_value = "dollars", retrigger_type = "mult" },
+ },
+ text_config = { colour = G.C.GOLD },
+ reminder_text = {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "localized_text", colour = G.C.ORANGE },
+ { text = ")" },
+ },
+ calc_function = function(card)
+ local dollars = 0
+ local text, _, scoring_hand = JokerDisplay.evaluate_hand()
+ if text ~= "Unknown" then
+ for _, scoring_card in pairs(scoring_hand) do
+ if SMODS.has_enhancement(scoring_card, "m_gold") then
+ dollars = dollars
+ + card.ability.extra.dollars
+ * JokerDisplay.calculate_card_triggers(scoring_card, scoring_hand)
+ end
+ end
+ end
+ card.joker_display_values.dollars = dollars
+ card.joker_display_values.localized_text = localize("k_gold")
+ end,
+ }
+ jd_def["j_mp_seltzer"] = {
+ reminder_text = {
+ { text = "(" },
+ { ref_table = "card.ability.extra", ref_value = "hands_left" },
+ { text = "/" },
+ { ref_table = "card.joker_display_values", ref_value = "start_count" },
+ { text = ")" },
+ },
+ calc_function = function(card)
+ card.joker_display_values.start_count = card.joker_display_values.start_count or card.ability.extra.hands_left
+ end,
+ style_function = function(card, text, reminder_text, extra)
+ local children = reminder_text and reminder_text.children
+ if not children then return end
+ local colour = (card.ability.extra.hands_left == 1) and G.C.RED or G.C.UI.TEXT_INACTIVE
+ for i = 2, 4 do
+ local child = children[i]
+ if child then child.config.colour = colour end
+ end
+ end,
+ retrigger_function = function(playing_card, scoring_hand, held_in_hand, joker_card)
+ if held_in_hand then return 0 end
+ return JokerDisplay.in_scoring(playing_card, scoring_hand) and JokerDisplay.calculate_joker_triggers(joker_card)
+ end,
+ }
+ jd_def["j_mp_turtle_bean"] = {
+ reminder_text = {
+ { text = "(" },
+ { ref_table = "card.ability.extra", ref_value = "h_size" },
+ { text = "/" },
+ { ref_table = "card.joker_display_values", ref_value = "start_count" },
+ { text = ")" },
+ },
+ reminder_text_config = { scale = 0.35 },
+ calc_function = function(card)
+ card.joker_display_values.start_count = card.joker_display_values.start_count or card.ability.extra.h_size
+ end,
+ style_function = function(card, text, reminder_text, extra)
+ local children = reminder_text and reminder_text.children
+ if not children then return end
+ local colour = (card.ability.extra.h_size == 1) and G.C.RED or G.C.UI.TEXT_INACTIVE
+ for i = 2, 4 do
+ local child = children[i]
+ if child then child.config.colour = colour end
+ end
+ end,
+ }
+ jd_def["j_mp_bloodstone"] = {
+ text = {
+ { ref_table = "card.joker_display_values", ref_value = "count", retrigger_type = "mult" },
+ { text = "x", scale = 0.35 },
+ {
+ border_nodes = {
+ { text = "X" },
+ { ref_table = "card.ability.extra", ref_value = "Xmult" },
+ },
+ },
+ },
+ reminder_text = {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "localized_text" },
+ { text = ")" },
+ },
+ extra = {
+ {
+ { text = "(" },
+ { ref_table = "card.joker_display_values", ref_value = "odds" },
+ { text = ")" },
+ },
+ },
+ extra_config = { colour = G.C.GREEN, scale = 0.3 },
+ calc_function = function(card)
+ local text, _, scoring_hand = JokerDisplay.evaluate_hand()
+ local count = 0
+ if text ~= "Unknown" then
+ for _, scoring_card in pairs(scoring_hand) do
+ if scoring_card:is_suit("Hearts") then
+ count = count + JokerDisplay.calculate_card_triggers(scoring_card, scoring_hand)
+ end
+ end
+ end
+ card.joker_display_values.count = count
+ local numerator, denominator = 1, card.ability.extra.odds
+ if SMODS then
+ numerator, denominator = SMODS.get_probability_vars(card, numerator, denominator, "bloodstone")
+ end
+ card.joker_display_values.odds = localize({
+ type = "variable",
+ key = "jdis_odds",
+ vars = { numerator, denominator },
+ })
+ card.joker_display_values.localized_text = localize("Hearts", "suits_plural")
+ end,
+ style_function = function(card, text, reminder_text, extra)
+ local suit_node = reminder_text and reminder_text.children and reminder_text.children[2]
+ if suit_node then suit_node.config.colour = lighten(G.C.SUITS["Hearts"], 0.35) end
+ end,
+ }
+ end
+end
diff --git a/compatibility/Ortalab.lua b/compatibility/Ortalab.lua
new file mode 100644
index 00000000..9858322d
--- /dev/null
+++ b/compatibility/Ortalab.lua
@@ -0,0 +1,7 @@
+if SMODS.Mods["ortalab"] and SMODS.Mods["ortalab"].can_load then
+ sendDebugMessage("Ortalab compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_ortalab_miracle_cure")
+ MP.DECK.ban_card("j_ortalab_grave_digger")
+ MP.DECK.ban_card("v_ortalab_abacus")
+ MP.DECK.ban_card("v_ortalab_calculator")
+end
diff --git a/compatibility/Pokermon.lua b/compatibility/Pokermon.lua
new file mode 100644
index 00000000..cd7bab99
--- /dev/null
+++ b/compatibility/Pokermon.lua
@@ -0,0 +1,6 @@
+if SMODS.Mods["Pokermon"] and SMODS.Mods["Pokermon"].can_load then
+ sendDebugMessage("Pokermon compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_poke_koffing")
+ MP.DECK.ban_card("j_poke_weezing")
+ MP.DECK.ban_card("j_poke_mimikyu")
+end
diff --git a/compatibility/Preview/CorePreview.lua b/compatibility/Preview/CorePreview.lua
new file mode 100644
index 00000000..d95e3deb
--- /dev/null
+++ b/compatibility/Preview/CorePreview.lua
@@ -0,0 +1,263 @@
+-- The functions responsible for running the simulation at appropriate times;
+-- ie. whenever the player modifies card selection or card order.
+
+function FN.PRE.simulate()
+ -- Guard against simulating in redundant places:
+ if FN.PRE.five_second_coroutine and coroutine.status(FN.PRE.five_second_coroutine) == "suspended" then
+ coroutine.resume(FN.PRE.five_second_coroutine)
+ end
+ if
+ not (G.STATE == G.STATES.SELECTING_HAND or G.STATE == G.STATES.DRAW_TO_HAND or G.STATE == G.STATES.PLAY_TAROT)
+ then
+ return { score = { min = 0, exact = 0, max = 0 }, dollars = { min = 0, exact = 0, max = 0 } }
+ end
+
+ if G.SETTINGS.FN.hide_face_down then
+ for _, card in ipairs(G.hand.highlighted) do
+ if card.facing == "back" then return nil end
+ end
+ if #G.hand.highlighted ~= 0 then
+ for _, joker in ipairs(G.jokers.cards) do
+ if joker.facing == "back" then return nil end
+ end
+ end
+ end
+
+ return FN.SIM.run()
+end
+
+-- SIMULATION UPDATE ADVICE:
+
+function FN.PRE.add_update_event(trigger)
+ function sim_func()
+ FN.PRE.data = FN.PRE.simulate()
+ return true
+ end
+ if FN.PRE.enabled() then
+ G.E_MANAGER:add_event(Event({ trigger = trigger, blockable = false, blocking = false, func = sim_func }))
+ end
+end
+
+-- Update simulation after a consumable (eg. Tarot, Planet) is used:
+local orig_use = Card.use_consumeable
+function Card:use_consumeable(area, copier)
+ orig_use(self, area, copier)
+ if not MP.INTEGRATIONS.Preview then return end
+ FN.PRE.add_update_event("immediate")
+end
+
+-- Update simulation after card selection changed:
+local orig_hl = CardArea.parse_highlighted
+function CardArea:parse_highlighted()
+ orig_hl(self)
+ if not MP.INTEGRATIONS.Preview then return end
+
+ if not FN.PRE.lock_updates and FN.PRE.show_preview then FN.PRE.show_preview = false end
+ FN.PRE.add_update_event("immediate")
+end
+
+-- Update simulation after joker sold:
+local orig_card_remove = Card.remove_from_area
+function Card:remove_from_area()
+ orig_card_remove(self)
+ if not MP.INTEGRATIONS.Preview then return end
+
+ if self.config.type == "joker" then FN.PRE.add_update_event("immediate") end
+end
+
+-- Update simulation after joker reordering:
+local orig_update = CardArea.update
+function CardArea:update(dt)
+ orig_update(self, dt)
+ if not MP.INTEGRATIONS.Preview then return end
+
+ FN.PRE.update_on_card_order_change(self)
+end
+
+function FN.PRE.update_on_card_order_change(cardarea)
+ if
+ #cardarea.cards == 0
+ or not (
+ G.STATE == G.STATES.SELECTING_HAND
+ or G.STATE == G.STATES.DRAW_TO_HAND
+ or G.STATE == G.STATES.PLAY_TAROT
+ )
+ then
+ return
+ end
+ -- Important not to update on G.STATES.HAND_PLAYED, because it would reset the preview text!
+ if G.STATE == G.STATES.HAND_PLAYED then return end
+
+ local prev_order = nil
+ if cardarea.config.type == "joker" and cardarea.cards[1].ability.set == "Joker" then
+ if cardarea.cards[1].edition and cardarea.cards[1].edition.mp_phantom then return end
+ -- Note that the consumables cardarea also has type 'joker' so must verify by checking first card.
+ prev_order = FN.PRE.joker_order
+ elseif cardarea.config.type == "hand" then
+ prev_order = FN.PRE.hand_order
+ else
+ return
+ end
+
+ -- Go through stored card IDs and check against current card IDs, in-order.
+ -- If any mismatch occurs, toggle flag and update name for next time.
+ local should_update = false
+ if #cardarea.cards ~= #prev_order then prev_order = {} end
+ for i, c in ipairs(cardarea.cards) do
+ if c.sort_id ~= prev_order[i] then
+ prev_order[i] = c.sort_id
+ should_update = true
+ end
+ end
+
+ if should_update then
+ if cardarea.config.type == "joker" or cardarea.cards[1].ability.set == "Joker" then
+ FN.PRE.joker_order = prev_order
+ elseif cardarea.config.type == "hand" then
+ FN.PRE.hand_order = prev_order
+ end
+ if FN.PRE.show_preview and not FN.PRE.lock_updates then FN.PRE.show_preview = false end
+ FN.PRE.add_update_event("immediate")
+ end
+end
+
+-- SIMULATION RESET ADVICE:
+
+function FN.PRE.add_reset_event(trigger)
+ function reset_func()
+ FN.PRE.data = { score = { min = 0, exact = 0, max = 0 }, dollars = { min = 0, exact = 0, max = 0 } }
+ return true
+ end
+ if FN.PRE.enabled() then G.E_MANAGER:add_event(Event({ trigger = trigger, func = reset_func })) end
+end
+
+local orig_eval = G.FUNCS.evaluate_play
+function G.FUNCS.evaluate_play(e)
+ orig_eval(e)
+
+ if not MP.INTEGRATIONS.Preview then return end
+ FN.PRE.add_reset_event("after")
+end
+
+local orig_discard = G.FUNCS.discard_cards_from_highlighted
+function G.FUNCS.discard_cards_from_highlighted(e, is_hook_blind)
+ orig_discard(e, is_hook_blind)
+
+ if not MP.INTEGRATIONS.Preview then return end
+ if not is_hook_blind then FN.PRE.add_reset_event("immediate") end
+end
+
+-- USER INTERFACE ADVICE:
+
+-- Add animation to preview text:
+function G.FUNCS.fn_pre_score_UI_set(e)
+ local new_preview_text = ""
+ local should_juice = false
+ if FN.PRE.lock_updates then
+ if e.config.id == "fn_pre_l" then
+ new_preview_text = " " .. MP.UTILS.get_preview_cfg("text") .. " "
+ should_juice = true
+ end
+ else
+ if FN.PRE.data then
+ if FN.PRE.show_preview and (FN.PRE.data.score.min ~= FN.PRE.data.score.max) then
+ -- Format as 'X - Y' :
+ if e.config.id == "fn_pre_l" then
+ new_preview_text = FN.PRE.format_number(FN.PRE.data.score.min) .. " - "
+ if FN.PRE.is_enough_to_win(FN.PRE.data.score.min) then should_juice = true end
+ elseif e.config.id == "fn_pre_r" then
+ new_preview_text = FN.PRE.format_number(FN.PRE.data.score.max)
+ if FN.PRE.is_enough_to_win(FN.PRE.data.score.max) then should_juice = true end
+ end
+ else
+ -- Format as single number:
+ if e.config.id == "fn_pre_l" then
+ if true then
+ -- Spaces around number necessary to distinguish Min/Max text from Exact text,
+ -- which is itself necessary to force a HUD update when switching between Min/Max and Exact.
+ if FN.PRE.show_preview then
+ new_preview_text = " " .. FN.PRE.format_number(FN.PRE.data.score.min) .. " "
+ if FN.PRE.is_enough_to_win(FN.PRE.data.score.min) then should_juice = true end
+ else
+ if FN.PRE.is_enough_to_win(FN.PRE.data.score.min) then
+ should_juice = true
+ new_preview_text = " "
+ else
+ if FN.PRE.is_enough_to_win(FN.PRE.data.score.max) then
+ new_preview_text = " "
+ should_juice = true
+ else
+ new_preview_text = " "
+ end
+ end
+ end
+ end
+ else
+ new_preview_text = ""
+ end
+ end
+ else
+ -- Spaces around number necessary to distinguish Min/Max text from Exact text, same as above ^
+ if e.config.id == "fn_pre_l" then
+ if true then
+ new_preview_text = " ?????? "
+ else
+ new_preview_text = "??????"
+ end
+ else
+ new_preview_text = ""
+ end
+ end
+ end
+
+ if (not FN.PRE.text.score[e.config.id:sub(-1)]) or new_preview_text ~= FN.PRE.text.score[e.config.id:sub(-1)] then
+ FN.PRE.text.score[e.config.id:sub(-1)] = new_preview_text
+ e.config.object:update_text()
+ -- Wobble:
+ if not G.TAROT_INTERRUPT_PULSE then
+ if should_juice then
+ G.FUNCS.text_super_juice(e, 5)
+ e.config.object.colours = { G.C.MONEY }
+ else
+ G.FUNCS.text_super_juice(e, 0)
+ e.config.object.colours = { G.C.UI.TEXT_LIGHT }
+ end
+ end
+ end
+end
+
+function G.FUNCS.fn_pre_dollars_UI_set(e)
+ local new_preview_text = ""
+ local new_colour = nil
+ if FN.PRE.data then
+ if true and (FN.PRE.data.dollars.min ~= FN.PRE.data.dollars.max) then
+ if e.config.id == "fn_pre_dollars_top" then
+ new_preview_text = " " .. FN.PRE.get_sign_str(FN.PRE.data.dollars.max) .. FN.PRE.data.dollars.max
+ new_colour = FN.PRE.get_dollar_colour(FN.PRE.data.dollars.max)
+ elseif e.config.id == "fn_pre_dollars_bot" then
+ new_preview_text = " " .. FN.PRE.get_sign_str(FN.PRE.data.dollars.min) .. FN.PRE.data.dollars.min
+ new_colour = FN.PRE.get_dollar_colour(FN.PRE.data.dollars.min)
+ end
+ else
+ if e.config.id == "fn_pre_dollars_top" then
+ local _data = G.SETTINGS.FN.show_min_max and FN.PRE.data.dollars.min or FN.PRE.data.dollars.exact
+
+ new_preview_text = " " .. FN.PRE.get_sign_str(_data) .. _data
+ new_colour = FN.PRE.get_dollar_colour(_data)
+ else
+ new_preview_text = ""
+ new_colour = FN.PRE.get_dollar_colour(0)
+ end
+ end
+ else
+ new_preview_text = " +??"
+ new_colour = FN.PRE.get_dollar_colour(0)
+ end
+
+ if not FN.PRE.text.dollars[e.config.id:sub(-3)] or new_preview_text ~= FN.PRE.text.dollars[e.config.id:sub(-3)] then
+ FN.PRE.text.dollars[e.config.id:sub(-3)] = new_preview_text
+ e.config.object.colours = { new_colour }
+ e.config.object:update_text()
+ if not G.TAROT_INTERRUPT_PULSE then e.config.object:pulse(0.25) end
+ end
+end
diff --git a/compatibility/Preview/EngineSimulate.lua b/compatibility/Preview/EngineSimulate.lua
new file mode 100644
index 00000000..c762668c
--- /dev/null
+++ b/compatibility/Preview/EngineSimulate.lua
@@ -0,0 +1,519 @@
+-- The heart of this library: it replicates the game's score evaluation.
+
+if not FN.SIM.run then
+ function FN.SIM.run()
+ local null_ret = { score = { min = 0, exact = 0, max = 0 }, dollars = { min = 0, exact = 0, max = 0 } }
+ if #G.hand.highlighted < 1 then return null_ret end
+
+ FN.SIM.init()
+
+ FN.SIM.manage_state("SAVE")
+ FN.SIM.update_state_variables()
+
+ if not FN.SIM.simulate_blind_debuffs() then
+ FN.SIM.simulate_joker_before_effects()
+ FN.SIM.add_base_chips_and_mult()
+ FN.SIM.simulate_blind_effects()
+ FN.SIM.simulate_scoring_cards()
+ FN.SIM.simulate_held_cards()
+ FN.SIM.simulate_joker_global_effects()
+ FN.SIM.simulate_consumable_effects()
+ FN.SIM.simulate_deck_effects()
+ else -- Only Matador at this point:
+ FN.SIM.simulate_all_jokers(G.jokers, { debuffed_hand = true })
+ end
+
+ FN.SIM.manage_state("RESTORE")
+
+ return FN.SIM.get_results()
+ end
+
+ function FN.SIM.init()
+ -- Reset:
+ FN.SIM.running = {
+ min = { chips = 0, mult = 0, dollars = 0 },
+ exact = { chips = 0, mult = 0, dollars = 0 },
+ max = { chips = 0, mult = 0, dollars = 0 },
+ reps = 0,
+ }
+
+ -- Fetch metadata about simulated play:
+ local hand_name, _, poker_hands, scoring_hand, _ = G.FUNCS.get_poker_hand_info(G.hand.highlighted)
+ FN.SIM.env.scoring_name = hand_name
+
+ -- Identify played cards and extract necessary data:
+ FN.SIM.env.played_cards = {}
+ FN.SIM.env.scoring_cards = {}
+ local is_splash_joker = next(find_joker("Splash"))
+ table.sort(G.hand.highlighted, function(a, b)
+ return a.T.x < b.T.x
+ end) -- Sorts by positional x-value to mirror card order!
+ for _, card in ipairs(G.hand.highlighted) do
+ local is_scoring = false
+ for _, scoring_card in ipairs(scoring_hand) do
+ -- Either card is scoring because it's part of the scoring hand,
+ -- or there is Splash joker, or it's a Stone Card:
+ if card.sort_id == scoring_card.sort_id or is_splash_joker or card.ability.effect == "Stone Card" then
+ is_scoring = true
+ break
+ end
+ end
+
+ local card_data = FN.SIM.get_card_data(card)
+ table.insert(FN.SIM.env.played_cards, card_data)
+ if is_scoring then table.insert(FN.SIM.env.scoring_cards, card_data) end
+ end
+
+ -- Identify held cards and extract necessary data:
+ FN.SIM.env.held_cards = {}
+ for _, card in ipairs(G.hand.cards) do
+ -- Highlighted cards are simulated as played cards:
+ if not card.highlighted then
+ local card_data = FN.SIM.get_card_data(card)
+ table.insert(FN.SIM.env.held_cards, card_data)
+ end
+ end
+
+ -- Extract necessary joker data:
+ FN.SIM.env.jokers = {}
+ for _, joker in ipairs(G.jokers.cards) do
+ local joker_data = {
+ -- P_CENTER keys for jokers have the form j_NAME, get rid of j_
+ id = joker.config.center.key:sub(3, #joker.config.center.key),
+ ability = copy_table(joker.ability),
+ edition = copy_table(joker.edition),
+ rarity = joker.config.center.rarity,
+ debuff = joker.debuff,
+ }
+ table.insert(FN.SIM.env.jokers, joker_data)
+ end
+
+ -- Extract necessary consumable data:
+ FN.SIM.env.consumables = {}
+ for _, consumable in ipairs(G.consumeables.cards) do
+ local consumable_data = {
+ -- P_CENTER keys have the form x_NAME, get rid of x_
+ id = consumable.config.center.key:sub(3, #consumable.config.center.key),
+ ability = copy_table(consumable.ability),
+ }
+ table.insert(FN.SIM.env.consumables, consumable_data)
+ end
+
+ -- Set extensible context template:
+ FN.SIM.get_context = function(cardarea, args)
+ local context = {
+ cardarea = cardarea,
+ full_hand = FN.SIM.env.played_cards,
+ scoring_name = hand_name,
+ scoring_hand = FN.SIM.env.scoring_cards,
+ poker_hands = poker_hands,
+ }
+
+ for k, v in pairs(args) do
+ context[k] = v
+ end
+
+ return context
+ end
+ end
+
+ function FN.SIM.get_card_data(card_obj)
+ return {
+ rank = card_obj.base.id,
+ suit = card_obj.base.suit,
+ base_chips = card_obj.base.nominal,
+ ability = copy_table(card_obj.ability),
+ edition = copy_table(card_obj.edition),
+ seal = card_obj.seal,
+ debuff = card_obj.debuff,
+ lucky_trigger = {},
+ }
+ end
+
+ function FN.SIM.get_results()
+ local FNSR = FN.SIM.running
+
+ local min_score = math.floor(FNSR.min.chips * FNSR.min.mult)
+ local exact_score = math.floor(FNSR.exact.chips * FNSR.exact.mult)
+ local max_score = math.floor(FNSR.max.chips * FNSR.max.mult)
+
+ return {
+ score = { min = min_score, exact = exact_score, max = max_score },
+ dollars = { min = FNSR.min.dollars, exact = FNSR.exact.dollars, max = FNSR.max.dollars },
+ }
+ end
+
+ --
+ -- GAME STATE MANAGEMENT:
+ --
+
+ function FN.SIM.manage_state(save_or_restore)
+ local FNSO = FN.SIM.orig
+
+ if save_or_restore == "SAVE" then
+ FNSO.random_data = copy_table(G.GAME.pseudorandom)
+ FNSO.hand_data = copy_table(G.GAME.hands)
+ return
+ end
+
+ if save_or_restore == "RESTORE" then
+ G.GAME.pseudorandom = FNSO.random_data
+ G.GAME.hands = FNSO.hand_data
+ return
+ end
+ end
+
+ function FN.SIM.update_state_variables()
+ -- Increment poker hand played this run/round:
+ local hand_info = G.GAME.hands[FN.SIM.env.scoring_name]
+ hand_info.played = hand_info.played + 1
+ hand_info.played_this_round = hand_info.played_this_round + 1
+ end
+
+ --
+ -- MACRO LEVEL:
+ --
+
+ function FN.SIM.simulate_scoring_cards()
+ for _, scoring_card in ipairs(FN.SIM.env.scoring_cards) do
+ FN.SIM.simulate_card_in_context(scoring_card, G.play)
+ end
+ end
+
+ function FN.SIM.simulate_held_cards()
+ for _, held_card in ipairs(FN.SIM.env.held_cards) do
+ FN.SIM.simulate_card_in_context(held_card, G.hand)
+ end
+ end
+
+ function FN.SIM.simulate_joker_global_effects()
+ for _, joker in ipairs(FN.SIM.env.jokers) do
+ if joker.edition then -- Foil and Holo:
+ if joker.edition.chips then FN.SIM.add_chips(joker.edition.chips) end
+ if joker.edition.mult then FN.SIM.add_mult(joker.edition.mult) end
+ end
+
+ FN.SIM.simulate_joker(joker, FN.SIM.get_context(G.jokers, { global = true }))
+
+ -- Joker-on-joker effects (eg. Blueprint):
+ FN.SIM.simulate_all_jokers(G.jokers, { other_joker = joker })
+
+ if joker.edition then -- Poly:
+ if joker.edition.x_mult then FN.SIM.x_mult(joker.edition.x_mult) end
+ end
+ end
+ end
+
+ function FN.SIM.simulate_consumable_effects()
+ for _, consumable in ipairs(FN.SIM.env.consumables) do
+ if consumable.ability.set == "Planet" and not consumable.debuff then
+ if
+ G.GAME.used_vouchers.v_observatory
+ and consumable.ability.consumeable.hand_type == FN.SIM.env.scoring_name
+ then
+ FN.SIM.x_mult(G.P_CENTERS.v_observatory.config.extra)
+ end
+ end
+ end
+ end
+
+ function FN.SIM.add_base_chips_and_mult()
+ local played_hand_data = G.GAME.hands[FN.SIM.env.scoring_name]
+ FN.SIM.add_chips(played_hand_data.chips)
+ FN.SIM.add_mult(played_hand_data.mult)
+ end
+
+ function FN.SIM.simulate_joker_before_effects()
+ for _, joker in ipairs(FN.SIM.env.jokers) do
+ FN.SIM.simulate_joker(joker, FN.SIM.get_context(G.jokers, { before = true }))
+ end
+ end
+
+ function FN.SIM.simulate_joker_discard_effects(cards, card)
+ for _, joker in ipairs(FN.SIM.env.jokers) do
+ FN.SIM.simulate_joker(
+ joker,
+ FN.SIM.get_context(G.hand, { discard = true, cards = cards, other_card = card })
+ )
+ end
+ end
+
+ function FN.SIM.simulate_blind_effects()
+ if G.GAME.blind.disabled then return end
+
+ if G.GAME.blind.name == "The Flint" then
+ local function flint(data)
+ local half_chips = math.floor(data.chips / 2 + 0.5)
+ local half_mult = math.floor(data.mult / 2 + 0.5)
+ data.chips = FN.SIM.mod_chips(math.max(half_chips, 0))
+ data.mult = FN.SIM.mod_mult(math.max(half_mult, 1))
+ end
+
+ flint(FN.SIM.running.min)
+ flint(FN.SIM.running.exact)
+ flint(FN.SIM.running.max)
+ else
+ -- Other blinds do not impact scoring; refer to Blind:modify_hand(..)
+ end
+ end
+
+ function FN.SIM.simulate_deck_effects()
+ if FN.SIM.is_deck("b_plasma") then
+ local function plasma(data)
+ local sum = data.chips + data.mult
+ local half_sum = math.floor(sum / 2)
+ data.chips = FN.SIM.mod_chips(half_sum)
+ data.mult = FN.SIM.mod_mult(half_sum)
+ end
+
+ plasma(FN.SIM.running.min)
+ plasma(FN.SIM.running.exact)
+ plasma(FN.SIM.running.max)
+ elseif G.GAME.modifiers.mp_score_instability then
+ local function unplasma(data)
+ local diff = data.chips - data.mult
+ if diff > 0 then
+ diff = math.min(diff, data.mult - 1)
+ elseif diff < 0 then
+ diff = math.max(diff, -data.chips)
+ end
+ data.chips = FN.SIM.mod_chips(data.chips + diff)
+ data.mult = FN.SIM.mod_mult(data.mult - diff)
+ end
+
+ unplasma(FN.SIM.running.min)
+ unplasma(FN.SIM.running.exact)
+ unplasma(FN.SIM.running.max)
+ elseif FN.SIM.is_deck("b_mp_echodeck") then
+ -- Do something?
+ else
+ -- Other decks do not impact scoring; refer to Back:trigger_effect(..)
+ end
+ end
+
+ function FN.SIM.simulate_blind_debuffs()
+ local blind_obj = G.GAME.blind
+ if blind_obj.disabled then return false end
+
+ -- The following are part of Blind:press_play()
+
+ if blind_obj.name == "The Hook" then
+ blind_obj.triggered = true
+
+ local held = FN.SIM.env.held_cards
+ local n = #held
+ local combinations = {}
+
+ -- Generate all possible discard combinations
+ if n == 0 then
+ table.insert(combinations, {})
+ elseif n == 1 then
+ for a = 1, n do
+ table.insert(combinations, { a })
+ end
+ elseif n >= 2 then
+ for a = 1, n - 1 do
+ for b = a + 1, n do
+ table.insert(combinations, { a, b })
+ end
+ end
+ end
+
+ local min_score, max_score = math.huge, -math.huge
+ local min_dollars, max_dollars = math.huge, -math.huge
+
+ for _, discard_idxs in ipairs(combinations) do
+ -- Deep copy held cards
+ local held_copy = {}
+ local discarded = {}
+ for i, card in ipairs(held) do
+ held_copy[i] = copy_table(card)
+ end
+
+ -- Remove discard cards from held_copy
+ table.sort(discard_idxs, function(a, b)
+ return a > b
+ end)
+ for _, idx in ipairs(discard_idxs) do
+ discarded[#discarded + 1] = table.remove(held_copy, idx)
+ end
+
+ -- Backup and replace held cards and jokers temporarily
+ local backup_held = FN.SIM.env.held_cards
+ FN.SIM.env.held_cards = held_copy
+ local backup_jokers = copy_table(FN.SIM.env.jokers)
+
+ -- Reset sim state
+ FN.SIM.running.min = { chips = 0, mult = 0, dollars = 0 }
+ FN.SIM.running.exact = { chips = 0, mult = 0, dollars = 0 }
+ FN.SIM.running.max = { chips = 0, mult = 0, dollars = 0 }
+
+ for i = 1, #discarded do
+ FN.SIM.simulate_joker_discard_effects(discarded, discarded[i])
+ end
+
+ -- Simulate score
+ FN.SIM.simulate_joker_before_effects()
+ FN.SIM.add_base_chips_and_mult()
+ FN.SIM.simulate_blind_effects()
+ FN.SIM.simulate_scoring_cards()
+ FN.SIM.simulate_held_cards()
+ FN.SIM.simulate_joker_global_effects()
+ FN.SIM.simulate_consumable_effects()
+ FN.SIM.simulate_deck_effects()
+
+ -- Evaluate score
+ local res = FN.SIM.get_results()
+ min_score = math.min(min_score, res.score.min)
+ max_score = math.max(max_score, res.score.max)
+ min_dollars = math.min(min_dollars, res.dollars.min)
+ max_dollars = math.max(max_dollars, res.dollars.max)
+
+ -- Restore original held cards and jokers
+ FN.SIM.env.held_cards = backup_held
+ FN.SIM.env.jokers = backup_jokers
+ end
+
+ -- Overwrite final min/max range based on permutations
+ FN.SIM.running.min = { chips = min_score, mult = 1, dollars = min_dollars }
+ FN.SIM.running.max = { chips = max_score, mult = 1, dollars = max_dollars }
+
+ -- NOTE: FN.SIM.running.exact remains unset here; it's not relevant in this projection context
+ return true -- Prevent default simulation since we’ve replaced it entirely
+ end
+
+ if blind_obj.name == "The Tooth" then
+ blind_obj.triggered = true
+ FN.SIM.add_dollars(-1 * #FN.SIM.env.played_cards)
+ end
+
+ -- The following are part of Blind:debuff_hand(..)
+
+ if blind_obj.name == "The Arm" then
+ blind_obj.triggered = false
+
+ local played_hand_name = FN.SIM.env.scoring_name
+ if G.GAME.hands[played_hand_name].level > 1 then
+ blind_obj.triggered = true
+ -- NOTE: Important to save/restore G.GAME.hands here
+ -- NOTE: Implementation mirrors level_up_hand(..)
+ local played_hand_data = G.GAME.hands[played_hand_name]
+ played_hand_data.level = math.max(1, played_hand_data.level - 1)
+ played_hand_data.mult =
+ math.max(1, played_hand_data.s_mult + (played_hand_data.level - 1) * played_hand_data.l_mult)
+ played_hand_data.chips =
+ math.max(0, played_hand_data.s_chips + (played_hand_data.level - 1) * played_hand_data.l_chips)
+ end
+ return false -- IMPORTANT: Avoid duplicate effects from Blind:debuff_hand() below
+ end
+
+ if blind_obj.name == "The Ox" then
+ blind_obj.triggered = false
+
+ if FN.SIM.env.scoring_name == G.GAME.current_round.most_played_poker_hand then
+ blind_obj.triggered = true
+ FN.SIM.add_dollars(-G.GAME.dollars)
+ end
+ return false -- IMPORTANT: Avoid duplicate effects from Blind:debuff_hand() below
+ end
+
+ return blind_obj:debuff_hand(G.hand.highlighted, FN.SIM.env.poker_hands, FN.SIM.env.scoring_name, true)
+ end
+
+ --
+ -- MICRO LEVEL (CARDS):
+ --
+
+ function FN.SIM.simulate_card_in_context(card, cardarea)
+ -- Reset and collect repetitions:
+ FN.SIM.running.reps = 1
+ if card.seal == "Red" then FN.SIM.add_reps(1) end
+ if FN.SIM.is_deck("b_mp_echodeck") then FN.SIM.add_reps(1) end -- I guess this works?
+ FN.SIM.simulate_all_jokers(cardarea, { other_card = card, repetition = true })
+
+ -- Apply effects:
+ for _ = 1, FN.SIM.running.reps do
+ FN.SIM.simulate_card(card, FN.SIM.get_context(cardarea, {}))
+ FN.SIM.simulate_all_jokers(cardarea, { other_card = card, individual = true })
+ end
+ end
+
+ function FN.SIM.simulate_card(card_data, context)
+ -- Do nothing if debuffed:
+ if card_data.debuff then return end
+
+ if context.cardarea == G.play then
+ -- Chips:
+ if card_data.ability.effect == "Stone Card" then
+ FN.SIM.add_chips(card_data.ability.bonus + (card_data.ability.perma_bonus or 0))
+ else
+ FN.SIM.add_chips(card_data.base_chips + card_data.ability.bonus + (card_data.ability.perma_bonus or 0))
+ end
+
+ -- Mult:
+ if card_data.ability.effect == "Lucky Card" then
+ local exact_mult, min_mult, max_mult =
+ FN.SIM.get_probabilistic_extremes(pseudorandom("nope"), 5, card_data.ability.mult, 0)
+ FN.SIM.add_mult(exact_mult, min_mult, max_mult)
+ -- Careful not to overwrite `card_data.lucky_trigger` outright:
+ if exact_mult > 0 then card_data.lucky_trigger.exact = true end
+ if min_mult > 0 then card_data.lucky_trigger.min = true end
+ if max_mult > 0 then card_data.lucky_trigger.max = true end
+ else
+ FN.SIM.add_mult(card_data.ability.mult)
+ end
+
+ -- XMult:
+ if card_data.ability.x_mult > 1 then FN.SIM.x_mult(card_data.ability.x_mult) end
+
+ -- Dollars:
+ if card_data.seal == "Gold" then FN.SIM.add_dollars(3) end
+ if card_data.ability.p_dollars > 0 then
+ if card_data.ability.effect == "Lucky Card" then
+ local exact_dollars, min_dollars, max_dollars = FN.SIM.get_probabilistic_extremes(
+ pseudorandom("notthistime"),
+ 15,
+ card_data.ability.p_dollars,
+ 0
+ )
+ FN.SIM.add_dollars(exact_dollars, min_dollars, max_dollars)
+ -- Careful not to overwrite `card_data.lucky_trigger` outright:
+ if exact_dollars > 0 then card_data.lucky_trigger.exact = true end
+ if min_dollars > 0 then card_data.lucky_trigger.min = true end
+ if max_dollars > 0 then card_data.lucky_trigger.max = true end
+ else
+ FN.SIM.add_dollars(card_data.ability.p_dollars)
+ end
+ end
+
+ -- Edition:
+ if card_data.edition then
+ if card_data.edition.chips then FN.SIM.add_chips(card_data.edition.chips) end
+ if card_data.edition.mult then FN.SIM.add_mult(card_data.edition.mult) end
+ if card_data.edition.x_mult then FN.SIM.x_mult(card_data.edition.x_mult) end
+ end
+ elseif context.cardarea == G.hand then
+ if card_data.ability.h_mult > 0 then FN.SIM.add_mult(card_data.ability.h_mult) end
+
+ if card_data.ability.h_x_mult > 0 then FN.SIM.x_mult(card_data.ability.h_x_mult) end
+ end
+ end
+
+ --
+ -- MICRO LEVEL (JOKERS):
+ --
+
+ function FN.SIM.simulate_all_jokers(cardarea, context_args)
+ for _, joker in ipairs(FN.SIM.env.jokers) do
+ FN.SIM.simulate_joker(joker, FN.SIM.get_context(cardarea, context_args))
+ end
+ end
+
+ function FN.SIM.simulate_joker(joker_obj, context)
+ -- Do nothing if debuffed:
+ if joker_obj.debuff then return end
+
+ local joker_simulation_function = FN.SIM.JOKERS["simulate_" .. joker_obj.id]
+ if joker_simulation_function then joker_simulation_function(joker_obj, context) end
+ end
+end
diff --git a/compatibility/Preview/InitPreview.lua b/compatibility/Preview/InitPreview.lua
new file mode 100644
index 00000000..7944909e
--- /dev/null
+++ b/compatibility/Preview/InitPreview.lua
@@ -0,0 +1,88 @@
+-- Global values that must be present for the rest of this mod to work.
+
+if not FN then FN = {} end
+
+FN.PRE = {
+ data = {
+ score = { min = 0, exact = 0, max = 0 },
+ dollars = { min = 0, exact = 0, max = 0 },
+ },
+ text = {
+ score = { l = "", r = "" },
+ dollars = { top = "", bot = "" },
+ },
+ joker_order = {},
+ hand_order = {},
+ show_preview = false,
+ lock_updates = false,
+ on_startup = true,
+ five_second_coroutine = nil,
+}
+
+-- this coroutine nonsense is pissing me off so i'm doing events instead
+-- it's fine because a calc takes close to no computing time
+-- function name is the same, can't be bothered
+
+function FN.PRE.start_new_coroutine()
+ FN.PRE.lock_updates = true
+ FN.PRE.show_preview = true
+ FN.PRE.add_update_event("immediate") -- Force UI refresh
+ local delay = 0
+ if MP.LOBBY.code and not MP.is_pvp_boss() then delay = 5 * G.SETTINGS.GAMESPEED end
+ local func = function()
+ FN.PRE.simulate()
+ FN.PRE.lock_updates = false
+ FN.PRE.show_preview = true
+ FN.PRE.add_update_event("immediate") -- Refresh UI again
+ return true
+ end
+ G.E_MANAGER:add_event(Event({ trigger = "after", blockable = false, blocking = false, delay = delay, func = func }))
+end
+
+--[[
+function FN.PRE.start_new_coroutine()
+ if FN.PRE.five_second_coroutine and coroutine.status(FN.PRE.five_second_coroutine) ~= "dead" then
+ FN.PRE.five_second_coroutine = nil -- Reset the coroutine
+ end
+
+ -- Create and start a new coroutine
+ FN.PRE.five_second_coroutine = coroutine.create(function()
+ -- Show UI updates
+ FN.PRE.lock_updates = true
+ FN.PRE.show_preview = true
+ FN.PRE.add_update_event("immediate") -- Force UI refresh
+
+ local start_time = os.time()
+ if MP.LOBBY.code and not MP.is_pvp_boss() then
+ while os.time() - start_time < 5 do
+ FN.PRE.simulate() -- Force a simulation run
+ FN.PRE.add_update_event("immediate") -- Ensure UI updates
+ coroutine.yield() -- Allow game to continue running
+ end
+ end
+ -- Delay for 5 seconds
+ FN.PRE.lock_updates = false
+ FN.PRE.show_preview = true
+ FN.PRE.add_update_event("immediate") -- Refresh UI again
+ end)
+
+ coroutine.resume(FN.PRE.five_second_coroutine) -- Start it immediately
+end
+]]
+
+FN.PRE._start_up = Game.start_up
+function Game:start_up()
+ FN.PRE._start_up(self)
+
+ if not MP.INTEGRATIONS.Preview then return end
+
+ if not G.SETTINGS.FN then G.SETTINGS.FN = {} end
+ if not G.SETTINGS.FN.PRE then
+ G.SETTINGS.FN.PRE = true
+
+ G.SETTINGS.FN.preview_score = true
+ G.SETTINGS.FN.preview_dollars = true
+ G.SETTINGS.FN.hide_face_down = true
+ G.SETTINGS.FN.show_min_max = true
+ end
+end
diff --git a/compatibility/Preview/InitSimulate.lua b/compatibility/Preview/InitSimulate.lua
new file mode 100644
index 00000000..fc435f8d
--- /dev/null
+++ b/compatibility/Preview/InitSimulate.lua
@@ -0,0 +1,36 @@
+-- Global values that must be present for the rest of this mod to work.
+
+if not FN then FN = {} end
+
+FN.SIM = {
+ JOKERS = {},
+
+ running = {
+ --- Table to store workings (ie. running totals):
+ min = { chips = 0, mult = 0, dollars = 0 },
+ exact = { chips = 0, mult = 0, dollars = 0 },
+ max = { chips = 0, mult = 0, dollars = 0 },
+ reps = 0,
+ },
+
+ env = {
+ --- Table to store data about the simulated play:
+ jokers = {}, -- Derived from G.jokers.cards
+ played_cards = {}, -- Derived from G.hand.highlighted
+ scoring_cards = {}, -- Derived according to evaluate_play()
+ held_cards = {}, -- Derived from G.hand minus G.hand.highlighted
+ consumables = {}, -- Derived from G.consumeables.cards
+ scoring_name = "", -- Derived according to evaluate_play()
+ },
+
+ orig = {
+ --- Table to store game data that gets modified during simulation:
+ random_data = {}, -- G.GAME.pseudorandom
+ hand_data = {}, -- G.GAME.hands
+ },
+
+ misc = {
+ --- Table to store ancillary status variables:
+ next_stone_id = -1,
+ },
+}
diff --git a/compatibility/Preview/InterfacePreview.lua b/compatibility/Preview/InterfacePreview.lua
new file mode 100644
index 00000000..cba5eb94
--- /dev/null
+++ b/compatibility/Preview/InterfacePreview.lua
@@ -0,0 +1,176 @@
+-- The user interface components that display simulation results.
+
+-- Append node for preview text to the HUD:
+local orig_hud = create_UIBox_HUD
+function create_UIBox_HUD()
+ local contents = orig_hud()
+
+ if not MP.INTEGRATIONS.Preview then return contents end
+
+ -- Check if preview is disabled in lobby options
+ if MP.LOBBY.config and MP.LOBBY.config.preview_disabled then return contents end
+
+ local score_node_wrap =
+ { n = G.UIT.R, config = { id = "fn_pre_score_wrap", align = "cm", padding = 0.1 }, nodes = {} }
+ table.insert(score_node_wrap.nodes, FN.PRE.get_score_node())
+ local calculate_score_button_wrap =
+ { n = G.UIT.R, config = { id = "fn_calculate_score_button_wrap", align = "cm", padding = 0.1 }, nodes = {} }
+ table.insert(calculate_score_button_wrap.nodes, FN.PRE.get_calculate_score_button())
+ local score_wrap = {
+ n = G.UIT.R,
+ config = { id = "fn_real_wrap", align = "cm", padding = 0.0 },
+ nodes = { score_node_wrap, calculate_score_button_wrap },
+ }
+
+ table.insert(contents.nodes[1].nodes[1].nodes[4].nodes[1].nodes, score_wrap) -- you can only do one so we have to wrap both of them. do not ask. i have no idea why
+
+ return contents
+end
+
+function G.FUNCS.calculate_score_button()
+ FN.PRE.start_new_coroutine()
+end
+
+function FN.PRE.get_calculate_score_button()
+ return {
+ n = G.UIT.C,
+ config = {
+ id = "calculate_score_button",
+ button = "calculate_score_button",
+ align = "cm",
+ minh = 0.42,
+ padding = 0.05,
+ minw = 3,
+ r = 0.02,
+ colour = G.C.RED,
+ hover = true,
+ shadow = true,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = MP.UTILS.get_preview_cfg("button"),
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ scale = 0.36,
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function FN.PRE.get_score_node()
+ local text_scale = nil
+ if true then
+ text_scale = 0.5
+ else
+ text_scale = 0.75
+ end
+
+ return {
+ n = G.UIT.C,
+ config = { id = "fn_pre_score", align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ id = "fn_pre_l",
+ func = "fn_pre_score_UI_set",
+ object = DynaText({
+ string = { { ref_table = FN.PRE.text.score, ref_value = "l" } },
+ colours = { G.C.UI.TEXT_LIGHT },
+ shadow = true,
+ float = true,
+ scale = text_scale,
+ }),
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ id = "fn_pre_r",
+ func = "fn_pre_score_UI_set",
+ object = DynaText({
+ string = { { ref_table = FN.PRE.text.score, ref_value = "r" } },
+ colours = { G.C.UI.TEXT_LIGHT },
+ shadow = true,
+ float = true,
+ scale = text_scale,
+ }),
+ },
+ },
+ },
+ }
+end
+
+-- TODO Implement
+function FN.get_preview_settings_page()
+ local function preview_score_toggle_callback(e)
+ if not G.HUD then return end
+
+ if G.SETTINGS.FN.preview_score then
+ -- Preview was just enabled, so add preview node:
+ G.HUD:add_child(FN.PRE.get_score_node(), G.HUD:get_UIE_by_ID("fn_pre_score_wrap"))
+ FN.PRE.data = FN.PRE.simulate()
+ else
+ -- Preview was just disabled, so remove preview node:
+ G.HUD:get_UIE_by_ID("fn_pre_score").parent:remove()
+ end
+ G.HUD:recalculate()
+ end
+
+ local function preview_dollars_toggle_callback(_)
+ if not G.HUD then return end
+
+ if G.SETTINGS.FN.preview_dollars then
+ -- Preview was just enabled, so add preview node:
+ G.HUD:add_child(FN.PRE.get_dollars_node(), G.HUD:get_UIE_by_ID("fn_pre_dollars_wrap"))
+ FN.PRE.data = FN.PRE.simulate()
+ else
+ -- Preview was just disabled, so remove preview node:
+ G.HUD:get_UIE_by_ID("fn_pre_dollars").parent:remove()
+ end
+ G.HUD:recalculate()
+ end
+
+ local function face_down_toggle_callback(_)
+ if not G.HUD then return end
+
+ FN.PRE.data = FN.PRE.simulate()
+ G.HUD:recalculate()
+ end
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", padding = 0.05, colour = G.C.CLEAR },
+ nodes = {
+ create_toggle({
+ id = "score_toggle",
+ label = "Enable Score Preview",
+ ref_table = G.SETTINGS.FN,
+ ref_value = "preview_score",
+ callback = preview_score_toggle_callback,
+ }),
+ create_toggle({
+ id = "dollars_toggle",
+ label = "Enable Money Preview",
+ ref_table = G.SETTINGS.FN,
+ ref_value = "preview_dollars",
+ callback = preview_dollars_toggle_callback,
+ }),
+ create_toggle({
+ label = "Hide Preview if Any Card is Face-Down",
+ ref_table = G.SETTINGS.FN,
+ ref_value = "hide_face_down",
+ callback = face_down_toggle_callback,
+ }),
+ },
+ }
+end
diff --git a/compatibility/Preview/Jokers/Multiplayer.lua b/compatibility/Preview/Jokers/Multiplayer.lua
new file mode 100644
index 00000000..76c20cae
--- /dev/null
+++ b/compatibility/Preview/Jokers/Multiplayer.lua
@@ -0,0 +1,61 @@
+FNSJ.simulate_mp_defensive_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.t_chips) end
+end
+
+FNSJ.simulate_mp_taxes = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.extra.mult) end
+end
+
+FNSJ.simulate_mp_pacifist = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global and not MP.is_pvp_boss() then
+ FN.SIM.x_mult(joker_obj.ability.extra.x_mult)
+ end
+end
+
+FNSJ.simulate_mp_conjoined_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global and MP.is_pvp_boss() then
+ FN.SIM.x_mult(joker_obj.ability.extra.x_mult)
+ end
+end
+
+FNSJ.simulate_mp_hanging_chad = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then
+ if context.other_card == context.scoring_hand[1] and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ if context.other_card == context.scoring_hand[2] and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ end
+end
+
+FNSJ.simulate_mp_lets_go_gambling = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local rand = pseudorandom("gambling") -- Must reuse same pseudorandom value:
+ local exact_xmult, min_xmult, max_xmult =
+ FN.SIM.get_probabilistic_extremes(rand, joker_obj.ability.extra.odds, joker_obj.ability.extra.xmult, 1)
+ local exact_money, min_money, max_money =
+ FN.SIM.get_probabilistic_extremes(rand, joker_obj.ability.extra.odds, joker_obj.ability.extra.dollars, 0)
+
+ FN.SIM.add_dollars(exact_money, min_money, max_money)
+ FN.SIM.x_mult(exact_xmult, min_xmult, max_xmult)
+ end
+end
+
+FNSJ.simulate_mp_seltzer = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then FN.SIM.add_reps(1) end
+end
+
+FNSJ.simulate_mp_bloodstone = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, "Hearts") and not context.other_card.debuff then
+ local exact_xmult, min_xmult, max_xmult = FN.SIM.get_probabilistic_extremes(
+ pseudorandom("nopeagain"),
+ joker_obj.ability.extra.odds,
+ joker_obj.ability.extra.Xmult,
+ 1
+ )
+ FN.SIM.x_mult(exact_xmult, min_xmult, max_xmult)
+ end
+ end
+end
diff --git a/compatibility/Preview/Jokers/_Vanilla.lua b/compatibility/Preview/Jokers/_Vanilla.lua
new file mode 100644
index 00000000..c923f5d2
--- /dev/null
+++ b/compatibility/Preview/Jokers/_Vanilla.lua
@@ -0,0 +1,949 @@
+--- Divvy's Simulation for Balatro - _Vanilla.lua
+--
+-- The simulation functions for all of the vanilla Balatro jokers.
+
+local FNSJ = FN.SIM.JOKERS
+
+FNSJ.simulate_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_greedy_joker = function(joker_obj, context)
+ FN.SIM.JOKERS.add_suit_mult(joker_obj, context)
+end
+FNSJ.simulate_lusty_joker = function(joker_obj, context)
+ FN.SIM.JOKERS.add_suit_mult(joker_obj, context)
+end
+FNSJ.simulate_wrathful_joker = function(joker_obj, context)
+ FN.SIM.JOKERS.add_suit_mult(joker_obj, context)
+end
+FNSJ.simulate_gluttenous_joker = function(joker_obj, context)
+ FN.SIM.JOKERS.add_suit_mult(joker_obj, context)
+end
+FNSJ.simulate_jolly = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+end
+FNSJ.simulate_zany = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+end
+FNSJ.simulate_mad = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+end
+FNSJ.simulate_crazy = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+end
+FNSJ.simulate_droll = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+end
+FNSJ.simulate_sly = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+end
+FNSJ.simulate_wily = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+end
+FNSJ.simulate_clever = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+end
+FNSJ.simulate_devious = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+end
+FNSJ.simulate_crafty = function(joker_obj, context)
+ FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+end
+FNSJ.simulate_half = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if #context.full_hand <= joker_obj.ability.extra.size then FN.SIM.add_mult(joker_obj.ability.extra.mult) end
+ end
+end
+FNSJ.simulate_stencil = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local xmult = G.jokers.config.card_limit - #FN.SIM.env.jokers
+ for _, joker in ipairs(FN.SIM.env.jokers) do
+ if joker.ability.name == "Joker Stencil" then xmult = xmult + 1 end
+ end
+ if joker_obj.ability.x_mult > 1 then FN.SIM.x_mult(joker_obj.ability.x_mult) end
+ end
+end
+FNSJ.simulate_four_fingers = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_mime = function(joker_obj, context)
+ if context.cardarea == G.hand and context.repetition then FN.SIM.add_reps(joker_obj.ability.extra) end
+end
+FNSJ.simulate_credit_card = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_ceremonial = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_banner = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if G.GAME.current_round.discards_left > 0 then
+ local chips = G.GAME.current_round.discards_left * joker_obj.ability.extra
+ FN.SIM.add_chips(chips)
+ end
+ end
+end
+FNSJ.simulate_mystic_summit = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if G.GAME.current_round.discards_left == joker_obj.ability.extra.d_remaining then
+ FN.SIM.add_mult(joker_obj.ability.extra.mult)
+ end
+ end
+end
+FNSJ.simulate_marble = function(joker_obj, context)
+ -- Effect not relevant (Blind)
+end
+FNSJ.simulate_loyalty_card = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local loyalty_diff = G.GAME.hands_played - joker_obj.ability.hands_played_at_create
+ local loyalty_remaining = ((joker_obj.ability.extra.every - 1) - loyalty_diff)
+ % (joker_obj.ability.extra.every + 1)
+ if loyalty_remaining == joker_obj.ability.extra.every then FN.SIM.x_mult(joker_obj.ability.extra.Xmult) end
+ end
+end
+FNSJ.simulate_8_ball = function(joker_obj, context)
+ -- Effect might be relevant?
+end
+FNSJ.simulate_misprint = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local exact_mult = pseudorandom("nope", joker_obj.ability.extra.min, joker_obj.ability.extra.max)
+ FN.SIM.add_mult(exact_mult, joker_obj.ability.extra.min, joker_obj.ability.extra.max)
+ end
+end
+FNSJ.simulate_dusk = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then
+ -- Note: Checking against 1 is needed as hands_left is not decremented as part of simulation
+ if G.GAME.current_round.hands_left == 1 then FN.SIM.add_reps(joker_obj.ability.extra) end
+ end
+end
+FNSJ.simulate_raised_fist = function(joker_obj, context)
+ if context.cardarea == G.hand and context.individual then
+ local cur_mult, cur_rank = 15, 15
+ local raised_card = nil
+ for _, card in ipairs(FN.SIM.env.held_cards) do
+ if cur_rank >= card.rank and card.ability.effect ~= "Stone Card" then
+ cur_mult = card.base_chips
+ cur_rank = card.rank
+ raised_card = card
+ end
+ end
+ if raised_card == context.other_card and not context.other_card.debuff then FN.SIM.add_mult(2 * cur_mult) end
+ end
+end
+FNSJ.simulate_chaos = function(joker_obj, context)
+ -- Effect not relevant (Free Reroll)
+end
+FNSJ.simulate_fibonacci = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_rank(context.other_card, { 2, 3, 5, 8, 14 }) and not context.other_card.debuff then
+ FN.SIM.add_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_steel_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.x_mult(1 + joker_obj.ability.extra * joker_obj.ability.steel_tally)
+ end
+end
+FNSJ.simulate_scary_face = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_face(context.other_card) and not context.other_card.debuff then
+ FN.SIM.add_chips(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_abstract = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.add_mult(#FN.SIM.env.jokers * joker_obj.ability.extra)
+ end
+end
+FNSJ.simulate_delayed_grat = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_hack = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then
+ if not context.other_card.debuff and FN.SIM.is_rank(context.other_card, { 2, 3, 4, 5 }) then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_pareidolia = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_gros_michel = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.extra.mult) end
+end
+FNSJ.simulate_even_steven = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if not context.other_card.debuff and FN.SIM.check_rank_parity(context.other_card, true) then
+ FN.SIM.add_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_odd_todd = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if not context.other_card.debuff and FN.SIM.check_rank_parity(context.other_card, false) then
+ FN.SIM.add_chips(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_scholar = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_rank(context.other_card, 14) and not context.other_card.debuff then
+ FN.SIM.add_chips(joker_obj.ability.extra.chips)
+ FN.SIM.add_mult(joker_obj.ability.extra.mult)
+ end
+ end
+end
+FNSJ.simulate_business = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_face(context.other_card) and not context.other_card.debuff then
+ local exact_dollars, min_dollars, max_dollars =
+ FN.SIM.get_probabilistic_extremes(pseudorandom("false"), joker_obj.ability.extra, 2, 0)
+ FN.SIM.add_dollars(exact_dollars, min_dollars, max_dollars)
+ end
+ end
+end
+FNSJ.simulate_supernova = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.add_mult(G.GAME.hands[context.scoring_name].played)
+ end
+end
+FNSJ.simulate_ride_the_bus = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ local faces = false
+ for _, scoring_card in ipairs(context.scoring_hand) do
+ if FN.SIM.is_face(scoring_card) then faces = true end
+ end
+ if faces then
+ joker_obj.ability.mult = 0
+ else
+ joker_obj.ability.mult = joker_obj.ability.mult + joker_obj.ability.extra
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_space = function(joker_obj, context)
+ -- TODO: Verify
+ if context.cardarea == G.jokers and context.before then
+ local hand_data = G.GAME.hands[FN.SIM.env.scoring_name]
+
+ local rand = pseudorandom("bad") -- Must reuse same pseudorandom value:
+ local exact_chips, min_chips, max_chips =
+ FN.SIM.get_probabilistic_extremes(rand, joker_obj.ability.extra, hand_data.l_chips, 0)
+ local exact_mult, min_mult, max_mult =
+ FN.SIM.get_probabilistic_extremes(rand, joker_obj.ability.extra, hand_data.l_mult, 0)
+
+ FN.SIM.add_chips(exact_chips, min_chips, max_chips)
+ FN.SIM.add_mult(exact_mult, min_mult, max_mult)
+ end
+end
+FNSJ.simulate_egg = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_burglar = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_blackboard = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local black_suits, all_cards = 0, 0
+ for _, card in ipairs(FN.SIM.env.held_cards) do
+ all_cards = all_cards + 1
+ if FN.SIM.is_suit(card, "Clubs", true) or FN.SIM.is_suit(card, "Spades", true) then
+ black_suits = black_suits + 1
+ end
+ end
+ if black_suits == all_cards then FN.SIM.x_mult(joker_obj.ability.extra) end
+ end
+end
+FNSJ.simulate_runner = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ if next(context.poker_hands["Straight"]) then
+ joker_obj.ability.extra.chips = joker_obj.ability.extra.chips + joker_obj.ability.extra.chip_mod
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chips) end
+end
+FNSJ.simulate_ice_cream = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chips) end
+end
+FNSJ.simulate_dna = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before then
+ if G.GAME.current_round.hands_played == 0 and #context.full_hand == 1 then
+ local new_card = copy_table(context.full_hand[1])
+ table.insert(FN.SIM.env.held_cards, new_card)
+ end
+ end
+end
+FNSJ.simulate_splash = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_blue_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.add_chips(joker_obj.ability.extra * #G.deck.cards)
+ end
+end
+FNSJ.simulate_sixth_sense = function(joker_obj, context)
+ -- Effect might be relevant?
+end
+FNSJ.simulate_constellation = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_hiker = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if not context.other_card.debuff then
+ context.other_card.ability.perma_bonus = (context.other_card.ability.perma_bonus or 0)
+ + joker_obj.ability.extra
+ end
+ end
+end
+FNSJ.simulate_faceless = function(joker_obj, context)
+ -- Effect not relevant (Discard)
+end
+FNSJ.simulate_green_joker = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ joker_obj.ability.mult = joker_obj.ability.mult + joker_obj.ability.extra.hand_add
+ end
+ if
+ context.cardarea == G.hand
+ and context.discard
+ and context.other_card == context.cards[1]
+ and not context.blueprint
+ then
+ joker_obj.ability.mult = math.max(0, joker_obj.ability.mult - joker_obj.ability.extra.discard_sub)
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_superposition = function(joker_obj, context)
+ -- Effect might be relevant?
+end
+FNSJ.simulate_todo_list = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before then
+ if context.scoring_name == joker_obj.ability.to_do_poker_hand then
+ FN.SIM.add_dollars(joker_obj.ability.extra.dollars)
+ end
+ end
+end
+FNSJ.simulate_cavendish = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.x_mult(joker_obj.ability.extra.Xmult) end
+end
+FNSJ.simulate_card_sharp = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if G.GAME.hands[context.scoring_name] and G.GAME.hands[context.scoring_name].played_this_round > 1 then
+ FN.SIM.x_mult(joker_obj.ability.extra.Xmult)
+ end
+ end
+end
+FNSJ.simulate_red_card = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_madness = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_square = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ if #context.full_hand == 4 then
+ joker_obj.ability.extra.chips = joker_obj.ability.extra.chips + joker_obj.ability.extra.chip_mod
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chips) end
+end
+FNSJ.simulate_seance = function(joker_obj, context)
+ -- Effect might be relevant? (Consumable)
+end
+FNSJ.simulate_riff_raff = function(joker_obj, context)
+ -- Effect not relevant (Blind)
+end
+FNSJ.simulate_vampire = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ local num_enhanced = 0
+ for _, card in ipairs(context.scoring_hand) do
+ if card.ability.name ~= "Default Base" and not card.debuff then
+ num_enhanced = num_enhanced + 1
+ FN.SIM.set_ability(card, G.P_CENTERS.c_base)
+ end
+ end
+ if num_enhanced > 0 then
+ joker_obj.ability.x_mult = joker_obj.ability.x_mult + (joker_obj.ability.extra * num_enhanced)
+ end
+ end
+
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_shortcut = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_hologram = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_vagabond = function(joker_obj, context)
+ -- Effect might be relevant? (Consumable)
+end
+FNSJ.simulate_baron = function(joker_obj, context)
+ if context.cardarea == G.hand and context.individual then
+ if FN.SIM.is_rank(context.other_card, 13) and not context.other_card.debuff then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_cloud_9 = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_rocket = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_obelisk = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ local reset = true
+ local play_more_than = (G.GAME.hands[context.scoring_name].played or 0)
+ for hand_name, hand in pairs(G.GAME.hands) do
+ if hand_name ~= context.scoring_name and hand.played >= play_more_than and hand.visible then
+ reset = false
+ end
+ end
+ if reset then
+ joker_obj.ability.x_mult = 1
+ else
+ joker_obj.ability.x_mult = joker_obj.ability.x_mult + joker_obj.ability.extra
+ end
+ end
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_midas_mask = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ for _, card in ipairs(context.scoring_hand) do
+ if FN.SIM.is_face(card) then FN.SIM.set_ability(card, G.P_CENTERS.m_gold) end
+ end
+ end
+end
+FNSJ.simulate_luchador = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_photograph = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ local first_face = nil
+ for i = 1, #context.scoring_hand do
+ if FN.SIM.is_face(context.scoring_hand[i]) then
+ first_face = context.scoring_hand[i]
+ break
+ end
+ end
+ if context.other_card == first_face and not context.other_card.debuff then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_gift = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_turtle_bean = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_erosion = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local diff = G.GAME.starting_deck_size - #G.playing_cards
+ if diff > 0 then FN.SIM.add_mult(joker_obj.ability.extra * diff) end
+ end
+end
+FNSJ.simulate_reserved_parking = function(joker_obj, context)
+ if context.cardarea == G.hand and context.individual then
+ if FN.SIM.is_face(context.other_card) and not context.other_card.debuff then
+ local exact_dollars, min_dollars, max_dollars = FN.SIM.get_probabilistic_extremes(
+ pseudorandom("notthistime"),
+ joker_obj.ability.extra.odds,
+ joker_obj.ability.extra.dollars,
+ 0
+ )
+ FN.SIM.add_dollars(exact_dollars, min_dollars, max_dollars)
+ end
+ end
+end
+FNSJ.simulate_mail = function(joker_obj, context)
+ if context.cardarea == G.hand and context.discard then
+ if context.other_card.id == G.GAME.current_round.mail_card.id and not context.other_card.debuff then
+ FN.SIM.add_dollars(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_to_the_moon = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_hallucination = function(joker_obj, context)
+ -- Effect not relevant (Outside of Play)
+end
+FNSJ.simulate_fortune_teller = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if G.GAME.consumeable_usage_total and G.GAME.consumeable_usage_total.tarot then
+ FN.SIM.add_mult(G.GAME.consumeable_usage_total.tarot)
+ end
+ end
+end
+FNSJ.simulate_juggler = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_drunkard = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_stone = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.add_chips(joker_obj.ability.extra * joker_obj.ability.stone_tally)
+ end
+end
+FNSJ.simulate_golden = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_lucky_cat = function(joker_obj, context)
+ if not joker_obj.ability.x_mult_range then
+ joker_obj.ability.x_mult_range = {
+ min = joker_obj.ability.x_mult,
+ exact = joker_obj.ability.x_mult,
+ max = joker_obj.ability.x_mult,
+ }
+ end
+
+ if context.cardarea == G.play and context.individual and not context.blueprint then
+ local function lucky_cat(field)
+ if context.other_card.lucky_trigger and context.other_card.lucky_trigger[field] then
+ joker_obj.ability.x_mult_range[field] = joker_obj.ability.x_mult_range[field] + joker_obj.ability.extra
+ if joker_obj.ability.x_mult_range[field] < 1 then joker_obj.ability.x_mult_range[field] = 1 end -- Precaution
+ end
+ end
+ lucky_cat("min")
+ lucky_cat("exact")
+ lucky_cat("max")
+ end
+
+ if context.cardarea == G.jokers and context.global then
+ FN.SIM.x_mult(
+ joker_obj.ability.x_mult_range.exact,
+ joker_obj.ability.x_mult_range.min,
+ joker_obj.ability.x_mult_range.max
+ )
+ end
+end
+FNSJ.simulate_baseball = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.other_joker then
+ if context.other_joker.rarity == 2 and context.other_joker ~= joker_obj then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_bull = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local function bull(data)
+ return joker_obj.ability.extra * math.max(0, G.GAME.dollars + data.dollars)
+ end
+ local min_chips = bull(FN.SIM.running.min)
+ local exact_chips = bull(FN.SIM.running.exact)
+ local max_chips = bull(FN.SIM.running.max)
+ FN.SIM.add_chips(exact_chips, min_chips, max_chips)
+ end
+end
+FNSJ.simulate_diet_cola = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_trading = function(joker_obj, context)
+ -- Effect not relevant (Discard)
+end
+FNSJ.simulate_flash = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_popcorn = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_trousers = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.before and not context.blueprint then
+ if next(context.poker_hands["Two Pair"]) or next(context.poker_hands["Full House"]) then
+ joker_obj.ability.mult = joker_obj.ability.mult + joker_obj.ability.extra
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_ancient = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if
+ FN.SIM.is_suit(context.other_card, G.GAME.current_round.ancient_card.suit)
+ and not context.other_card.debuff
+ then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_ramen = function(joker_obj, context)
+ if context.cardarea == G.hand and context.discard then
+ joker_obj.ability.x_mult = math.max(1, joker_obj.ability.x_mult - joker_obj.ability.extra)
+ end
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_walkie_talkie = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_rank(context.other_card, { 10, 4 }) and not context.other_card.debuff then
+ FN.SIM.add_chips(joker_obj.ability.extra.chips)
+ FN.SIM.add_mult(joker_obj.ability.extra.mult)
+ end
+ end
+end
+FNSJ.simulate_selzer = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then FN.SIM.add_reps(1) end
+end
+FNSJ.simulate_castle = function(joker_obj, context)
+ if context.cardarea == G.hand and context.discard and not context.blueprint then
+ if
+ FN.SIM.is_suit(context.other_card, G.GAME.current_round.castle_card.suit) and not context.other_card.debuff
+ then
+ joker_obj.ability.extra.chips = joker_obj.ability.extra.chips + joker_obj.ability.extra.chip_mod
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chips) end
+end
+FNSJ.simulate_smiley = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_face(context.other_card) and not context.other_card.debuff then
+ FN.SIM.add_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_campfire = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_ticket = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if context.other_card.ability.effect == "Gold Card" and not context.other_card.debuff then
+ FN.SIM.add_dollars(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_mr_bones = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_acrobat = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ -- Note: Checking against 1 is needed as hands_left is not decremented as part of simulation
+ if G.GAME.current_round.hands_left == 1 then FN.SIM.x_mult(joker_obj.ability.extra) end
+ end
+end
+FNSJ.simulate_sock_and_buskin = function(joker_obj, context)
+ if context.cardarea == G.play and context.repetition then
+ if FN.SIM.is_face(context.other_card) and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_swashbuckler = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_mult(joker_obj.ability.mult) end
+end
+FNSJ.simulate_troubadour = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_certificate = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_smeared = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_throwback = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_hanging_chad = function(joker_obj, context)
+ if joker_obj.ability.extra == 1 then
+ if context.cardarea == G.play and context.repetition then
+ if context.other_card == context.scoring_hand[1] and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ if context.other_card == context.scoring_hand[2] and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ end
+ else
+ if context.cardarea == G.play and context.repetition then
+ if context.other_card == context.scoring_hand[1] and not context.other_card.debuff then
+ FN.SIM.add_reps(joker_obj.ability.extra)
+ end
+ end
+ end
+end
+FNSJ.simulate_rough_gem = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, "Diamonds") and not context.other_card.debuff then
+ FN.SIM.add_dollars(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_bloodstone = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, "Hearts") and not context.other_card.debuff then
+ local exact_xmult, min_xmult, max_xmult = FN.SIM.get_probabilistic_extremes(
+ pseudorandom("nopeagain"),
+ joker_obj.ability.extra.odds,
+ joker_obj.ability.extra.Xmult,
+ 1
+ )
+ FN.SIM.x_mult(exact_xmult, min_xmult, max_xmult)
+ end
+ end
+end
+FNSJ.simulate_arrowhead = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, "Spades") and not context.other_card.debuff then
+ FN.SIM.add_chips(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_onyx_agate = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, "Clubs") and not context.other_card.debuff then
+ FN.SIM.add_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_glass = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_ring_master = function(joker_obj, context)
+ -- Effect not relevant (Note: this is actually Showman)
+end
+FNSJ.simulate_flower_pot = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local suit_count = {
+ ["Hearts"] = 0,
+ ["Diamonds"] = 0,
+ ["Spades"] = 0,
+ ["Clubs"] = 0,
+ }
+
+ function inc_suit(suit)
+ suit_count[suit] = suit_count[suit] + 1
+ end
+
+ -- Account for all 'real' suits.
+ -- NOTE: Debuffed (non-wild) cards are still counted for their suits
+ for _, card in ipairs(context.scoring_hand) do
+ if card.ability.effect ~= "Wild Card" then
+ if FN.SIM.is_suit(card, "Hearts", true) and suit_count["Hearts"] == 0 then
+ inc_suit("Hearts")
+ elseif FN.SIM.is_suit(card, "Diamonds", true) and suit_count["Diamonds"] == 0 then
+ inc_suit("Diamonds")
+ elseif FN.SIM.is_suit(card, "Spades", true) and suit_count["Spades"] == 0 then
+ inc_suit("Spades")
+ elseif FN.SIM.is_suit(card, "Clubs", true) and suit_count["Clubs"] == 0 then
+ inc_suit("Clubs")
+ end
+ end
+ end
+
+ -- Let Wild Cards fill in the gaps.
+ -- NOTE: Debuffed wild cards are completely ignored
+ for _, card in ipairs(context.scoring_hand) do
+ if card.ability.effect == "Wild Card" then
+ if FN.SIM.is_suit(card, "Hearts") and suit_count["Hearts"] == 0 then
+ inc_suit("Hearts")
+ elseif FN.SIM.is_suit(card, "Diamonds") and suit_count["Diamonds"] == 0 then
+ inc_suit("Diamonds")
+ elseif FN.SIM.is_suit(card, "Spades") and suit_count["Spades"] == 0 then
+ inc_suit("Spades")
+ elseif FN.SIM.is_suit(card, "Clubs") and suit_count["Clubs"] == 0 then
+ inc_suit("Clubs")
+ end
+ end
+ end
+
+ if
+ suit_count["Hearts"] > 0
+ and suit_count["Diamonds"] > 0
+ and suit_count["Spades"] > 0
+ and suit_count["Clubs"] > 0
+ then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_blueprint = function(joker_obj, context)
+ local joker_to_mimic = nil
+ for idx, joker in ipairs(FN.SIM.env.jokers) do
+ if joker == joker_obj then joker_to_mimic = FN.SIM.env.jokers[idx + 1] end
+ end
+ if joker_to_mimic then
+ context.blueprint = (context.blueprint and (context.blueprint + 1)) or 1
+ if context.blueprint > #FN.SIM.env.jokers + 1 then return end
+ FN.SIM.simulate_joker(joker_to_mimic, context)
+ end
+end
+FNSJ.simulate_wee = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual and not context.blueprint then
+ if FN.SIM.is_rank(context.other_card, 2) and not context.other_card.debuff then
+ joker_obj.ability.extra.chips = joker_obj.ability.extra.chips + joker_obj.ability.extra.chip_mod
+ end
+ end
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chips) end
+end
+FNSJ.simulate_merry_andy = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_oops = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_idol = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if
+ FN.SIM.is_rank(context.other_card, G.GAME.current_round.idol_card.id)
+ and FN.SIM.is_suit(context.other_card, G.GAME.current_round.idol_card.suit)
+ and not context.other_card.debuff
+ then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_seeing_double = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local suit_count = {
+ ["Hearts"] = 0,
+ ["Diamonds"] = 0,
+ ["Spades"] = 0,
+ ["Clubs"] = 0,
+ }
+
+ function inc_suit(suit)
+ suit_count[suit] = suit_count[suit] + 1
+ end
+
+ -- Account for all 'real' suits:
+ for _, card in ipairs(context.scoring_hand) do
+ if card.ability.effect ~= "Wild Card" then
+ if FN.SIM.is_suit(card, "Hearts") then inc_suit("Hearts") end
+ if FN.SIM.is_suit(card, "Diamonds") then inc_suit("Diamonds") end
+ if FN.SIM.is_suit(card, "Spades") then inc_suit("Spades") end
+ if FN.SIM.is_suit(card, "Clubs") then inc_suit("Clubs") end
+ end
+ end
+
+ -- Let Wild Cards fill in the gaps:
+ for _, card in ipairs(context.scoring_hand) do
+ if card.ability.effect == "Wild Card" then
+ -- IMPORTANT: Clubs must come first here, because Clubs are required for xmult. This is in line with game's implementation.
+ if FN.SIM.is_suit(card, "Clubs") and suit_count["Clubs"] == 0 then
+ inc_suit("Clubs")
+ elseif FN.SIM.is_suit(card, "Hearts") and suit_count["Hearts"] == 0 then
+ inc_suit("Hearts")
+ elseif FN.SIM.is_suit(card, "Diamonds") and suit_count["Diamonds"] == 0 then
+ inc_suit("Diamonds")
+ elseif FN.SIM.is_suit(card, "Spades") and suit_count["Spades"] == 0 then
+ inc_suit("Spades")
+ end
+ end
+ end
+
+ if
+ suit_count["Clubs"] > 0
+ and (suit_count["Hearts"] > 0 or suit_count["Diamonds"] > 0 or suit_count["Spades"] > 0)
+ then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_matador = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.debuffed_hand then
+ if G.GAME.blind.triggered then FN.SIM.add_dollars(joker_obj.ability.extra) end
+ end
+end
+FNSJ.simulate_hit_the_road = function(joker_obj, context)
+ if context.cardarea == G.hand and context.discard and not context.blueprint then
+ if FN.SIM.is_rank(context.other_card, 11) and not context.other_card.debuff then
+ joker_obj.ability.x_mult = joker_obj.ability.x_mult + joker_obj.ability.extra
+ end
+ end
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_duo = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_trio = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_family = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_order = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_tribe = function(joker_obj, context)
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_stuntman = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then FN.SIM.add_chips(joker_obj.ability.extra.chip_mod) end
+end
+FNSJ.simulate_invisible = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_brainstorm = function(joker_obj, context)
+ local joker_to_mimic = FN.SIM.env.jokers[1]
+ if joker_to_mimic and joker_to_mimic ~= joker_obj then
+ context.blueprint = (context.blueprint and (context.blueprint + 1)) or 1
+ if context.blueprint > #FN.SIM.env.jokers + 1 then return end
+ FN.SIM.simulate_joker(joker_to_mimic, context)
+ end
+end
+FNSJ.simulate_satellite = function(joker_obj, context)
+ -- Effect not relevant (End of Round)
+end
+FNSJ.simulate_shoot_the_moon = function(joker_obj, context)
+ if context.cardarea == G.hand and context.individual then
+ if FN.SIM.is_rank(context.other_card, 12) and not context.other_card.debuff then FN.SIM.add_mult(13) end
+ end
+end
+FNSJ.simulate_drivers_license = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if (joker_obj.ability.driver_tally or 0) >= 16 then FN.SIM.x_mult(joker_obj.ability.extra) end
+ end
+end
+FNSJ.simulate_cartomancer = function(joker_obj, context)
+ -- Effect not relevant (Blind)
+end
+FNSJ.simulate_astronomer = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_burnt = function(joker_obj, context)
+ -- Effect not relevant (Discard)
+end
+FNSJ.simulate_bootstraps = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ local function bootstraps(data)
+ return joker_obj.ability.extra.mult
+ * math.floor((G.GAME.dollars + data.dollars) / joker_obj.ability.extra.dollars)
+ end
+ local min_mult = bootstraps(FN.SIM.running.min)
+ local exact_mult = bootstraps(FN.SIM.running.exact)
+ local max_mult = bootstraps(FN.SIM.running.max)
+ FN.SIM.add_mult(exact_mult, min_mult, max_mult)
+ end
+end
+FNSJ.simulate_caino = function(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if joker_obj.ability.caino_xmult > 1 then FN.SIM.x_mult(joker_obj.ability.caino_xmult) end
+ end
+end
+FNSJ.simulate_triboulet = function(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_rank(context.other_card, { 12, 13 }) and not context.other_card.debuff then
+ FN.SIM.x_mult(joker_obj.ability.extra)
+ end
+ end
+end
+FNSJ.simulate_yorick = function(joker_obj, context)
+ if context.cardarea == G.hand and context.discard and not context.blueprint then
+ -- This is only necessary for 'The Hook' blind.
+ if joker_obj.ability.yorick_discards > 1 then
+ joker_obj.ability.yorick_discards = joker_obj.ability.yorick_discards - 1
+ else
+ joker_obj.ability.yorick_discards = joker_obj.ability.extra.discards
+ joker_obj.ability.x_mult = joker_obj.ability.x_mult + joker_obj.ability.extra.xmult
+ end
+ end
+
+ FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+end
+FNSJ.simulate_chicot = function(joker_obj, context)
+ -- Effect not relevant (Meta)
+end
+FNSJ.simulate_perkeo = function(joker_obj, context)
+ -- Effect not relevant (Blind)
+end
diff --git a/compatibility/Preview/UtilsPreview.lua b/compatibility/Preview/UtilsPreview.lua
new file mode 100644
index 00000000..97eaa8b0
--- /dev/null
+++ b/compatibility/Preview/UtilsPreview.lua
@@ -0,0 +1,45 @@
+-- Utilities for checking states and formatting display.
+
+function FN.PRE.is_enough_to_win(chips)
+ if
+ G.GAME.blind
+ and (G.STATE == G.STATES.SELECTING_HAND or G.STATE == G.STATES.DRAW_TO_HAND or G.STATE == G.STATES.PLAY_TAROT)
+ then
+ return (G.GAME.chips + chips >= G.GAME.blind.chips)
+ else
+ return false
+ end
+end
+
+function FN.PRE.format_number(num)
+ if not num or type(num) ~= "number" then return num or "" end
+ -- Start using e-notation earlier to reduce number length, if showing min and max for preview:
+ if true and num >= 1e7 then
+ local x = string.format("%.4g", num)
+ local fac = math.floor(math.log(tonumber(x), 10))
+ return string.format("%.2f", x / (10 ^ fac)) .. "e" .. fac
+ end
+ return number_format(num) -- Default Balatro function.
+end
+
+function FN.PRE.get_dollar_colour(n)
+ if n == 0 then
+ return HEX("7e7667")
+ elseif n > 0 then
+ return G.C.MONEY
+ elseif n < 0 then
+ return G.C.RED
+ end
+end
+
+function FN.PRE.get_sign_str(n)
+ if n >= 0 then
+ return "+"
+ else
+ return "" -- Negative numbers already have a sign
+ end
+end
+
+function FN.PRE.enabled()
+ return G.SETTINGS.FN.preview_score or G.SETTINGS.FN.preview_dollars
+end
diff --git a/compatibility/Preview/UtilsSimulate.lua b/compatibility/Preview/UtilsSimulate.lua
new file mode 100644
index 00000000..68d137ea
--- /dev/null
+++ b/compatibility/Preview/UtilsSimulate.lua
@@ -0,0 +1,242 @@
+-- Utilities for writing simulation functions for jokers.
+--
+-- In general, these functions replicate the game's internal calculations and
+-- variables in order to avoid affecting the game's state during simulation.
+-- These functions ensure that the score calculation remains identical to the
+-- game; DO NOT directly modify the `FN.SIM.running` score variables.
+
+--
+-- HIGH-LEVEL:
+--
+
+function FN.SIM.JOKERS.add_suit_mult(joker_obj, context)
+ if context.cardarea == G.play and context.individual then
+ if FN.SIM.is_suit(context.other_card, joker_obj.ability.extra.suit) and not context.other_card.debuff then
+ FN.SIM.add_mult(joker_obj.ability.extra.s_mult)
+ end
+ end
+end
+
+function FN.SIM.JOKERS.add_type_mult(joker_obj, context)
+ if context.cardarea == G.jokers and context.global and next(context.poker_hands[joker_obj.ability.type]) then
+ FN.SIM.add_mult(joker_obj.ability.t_mult)
+ end
+end
+
+function FN.SIM.JOKERS.add_type_chips(joker_obj, context)
+ if context.cardarea == G.jokers and context.global and next(context.poker_hands[joker_obj.ability.type]) then
+ FN.SIM.add_chips(joker_obj.ability.t_chips)
+ end
+end
+
+function FN.SIM.JOKERS.x_mult_if_global(joker_obj, context)
+ if context.cardarea == G.jokers and context.global then
+ if
+ joker_obj.ability.x_mult > 1
+ and (joker_obj.ability.type == "" or next(context.poker_hands[joker_obj.ability.type]))
+ then
+ FN.SIM.x_mult(joker_obj.ability.x_mult)
+ end
+ end
+end
+
+function FN.SIM.get_probabilistic_extremes(random_value, odds, reward, default)
+ -- Exact mirrors the game's probability calculation
+ local exact = default
+ if random_value < G.GAME.probabilities.normal / odds then exact = reward end
+
+ -- Minimum is default unless probability is guaranteed (eg. 2 in 2 chance)
+ local min = default
+ if G.GAME.probabilities.normal >= odds then min = reward end
+
+ -- Maximum is always reward (probability is always > 0); redundant variable is for readability
+ local max = reward
+
+ return exact, min, max
+end
+
+function FN.SIM.adjust_field_with_range(adj_func, field, mod_func, exact_value, min_value, max_value)
+ if not exact_value then error("Cannot adjust field, exact_value is missing.") end
+
+ if not min_value or not max_value then
+ min_value = exact_value
+ max_value = exact_value
+ end
+
+ FN.SIM.running.min[field] = mod_func(adj_func(FN.SIM.running.min[field], min_value))
+ FN.SIM.running.exact[field] = mod_func(adj_func(FN.SIM.running.exact[field], exact_value))
+ FN.SIM.running.max[field] = mod_func(adj_func(FN.SIM.running.max[field], max_value))
+end
+
+function FN.SIM.add_chips(exact, min, max)
+ FN.SIM.adjust_field_with_range(function(x, y)
+ return x + y
+ end, "chips", FN.SIM.mod_chips, exact, min, max)
+end
+
+function FN.SIM.add_mult(exact, min, max)
+ FN.SIM.adjust_field_with_range(function(x, y)
+ return x + y
+ end, "mult", FN.SIM.mod_mult, exact, min, max)
+end
+
+function FN.SIM.x_mult(exact, min, max)
+ FN.SIM.adjust_field_with_range(function(x, y)
+ return x * y
+ end, "mult", FN.SIM.mod_mult, exact, min, max)
+end
+
+function FN.SIM.add_dollars(exact, min, max)
+ -- NOTE: no mod_func for dollars, so have to declare an identity function
+ FN.SIM.adjust_field_with_range(
+ function(x, y)
+ return x + y
+ end,
+ "dollars",
+ function(x)
+ return x
+ end,
+ exact,
+ min,
+ max
+ )
+end
+
+function FN.SIM.add_reps(n)
+ FN.SIM.running.reps = FN.SIM.running.reps + n
+end
+
+--
+-- LOW-LEVEL:
+--
+
+function FN.SIM.is_suit(card_data, suit, ignore_scorability)
+ if card_data.debuff and not ignore_scorability then return end
+ if card_data.ability.effect == "Stone Card" then return false end
+ if card_data.ability.effect == "Wild Card" and not card_data.debuff then return true end
+ if next(find_joker("Smeared Joker")) then
+ local is_card_suit_light = (card_data.suit == "Hearts" or card_data.suit == "Diamonds")
+ local is_check_suit_light = (suit == "Hearts" or suit == "Diamonds")
+ if is_card_suit_light == is_check_suit_light then return true end
+ end
+ return card_data.suit == suit
+end
+
+function FN.SIM.get_rank(card_data)
+ if card_data.ability.effect == "Stone Card" and not card_data.vampired then
+ FN.SIM.misc.next_stone_id = FN.SIM.misc.next_stone_id - 1
+ return FN.SIM.misc.next_stone_id
+ end
+ return card_data.rank
+end
+
+function FN.SIM.is_rank(card_data, ranks)
+ if card_data.ability.effect == "Stone Card" then return false end
+
+ if type(ranks) == "number" then ranks = { ranks } end
+ if FN.SIM.is_deck("b_mp_gradient") then
+ local temp = {}
+
+ for i, v in ipairs(ranks) do
+ temp[v - 1] = true
+ temp[v] = true
+ temp[v + 1] = true
+ end
+
+ ranks = {}
+ for k, v in pairs(temp) do
+ if k == 15 then
+ k = 2
+ elseif k == 1 then
+ k = 14
+ end
+ table.insert(ranks, k)
+ end
+ table.sort(ranks)
+ end
+ for _, r in ipairs(ranks) do
+ if card_data.rank == r then return true end
+ end
+ return false
+end
+
+function FN.SIM.check_rank_parity(card_data, check_even)
+ if check_even then
+ return FN.SIM.is_rank(card_data, { 2, 4, 6, 8, 10 })
+ else
+ return FN.SIM.is_rank(card_data, { 3, 5, 7, 9, 14 })
+ end
+end
+
+function FN.SIM.is_face(card_data)
+ return (FN.SIM.is_rank(card_data, { 11, 12, 13 }) or next(find_joker("Pareidolia")))
+end
+
+function FN.SIM.set_ability(card_data, center)
+ -- See Card:set_ability()
+ card_data.ability = {
+ name = center.name,
+ effect = center.effect,
+ set = center.set,
+ mult = center.config.mult or 0,
+ h_mult = center.config.h_mult or 0,
+ h_x_mult = center.config.h_x_mult or 0,
+ h_dollars = center.config.h_dollars or 0,
+ p_dollars = center.config.p_dollars or 0,
+ t_mult = center.config.t_mult or 0,
+ t_chips = center.config.t_chips or 0,
+ x_mult = center.config.Xmult or 1,
+ h_size = center.config.h_size or 0,
+ d_size = center.config.d_size or 0,
+ extra = copy_table(center.config.extra) or nil,
+ extra_value = 0,
+ type = center.config.type or "",
+ order = center.order or nil,
+ forced_selection = card_data.ability and card_data.ability.forced_selection or nil,
+ perma_bonus = card_data.ability and card_data.ability.perma_bonus or 0,
+ bonus = center.config.bonus or 0,
+ }
+end
+
+function FN.SIM.set_edition(card_data, edition)
+ card_data.edition = nil
+ if not edition then return end
+
+ if edition.holo then
+ if not card_data.edition then card_data.edition = {} end
+ card_data.edition.mult = G.P_CENTERS.e_holo.config.extra
+ card_data.edition.holo = true
+ card_data.edition.type = "holo"
+ elseif edition.foil then
+ if not card_data.edition then card_data.edition = {} end
+ card_data.edition.chips = G.P_CENTERS.e_foil.config.extra
+ card_data.edition.foil = true
+ card_data.edition.type = "foil"
+ elseif edition.polychrome then
+ if not card_data.edition then card_data.edition = {} end
+ card_data.edition.x_mult = G.P_CENTERS.e_polychrome.config.extra
+ card_data.edition.polychrome = true
+ card_data.edition.type = "polychrome"
+ elseif edition.negative then
+ -- TODO
+ end
+end
+
+function FN.SIM.is_deck(deck)
+ if G.GAME.selected_back.effect.center.key == deck then
+ return true
+ elseif G.GAME.selected_back.effect.center.key == "b_mp_cocktail" then
+ for i = 1, 3 do
+ if G.GAME.modifiers.mp_cocktail[i] == deck then return true end
+ end
+ end
+ return false
+end
+
+function FN.SIM.mod_chips(_chips)
+ return _chips
+end
+
+function FN.SIM.mod_mult(_mult)
+ return _mult
+end
diff --git a/compatibility/StrangePencil.lua b/compatibility/StrangePencil.lua
new file mode 100644
index 00000000..77b66f71
--- /dev/null
+++ b/compatibility/StrangePencil.lua
@@ -0,0 +1,14 @@
+if next(SMODS.find_mod("StrangePencil")) then
+ sendDebugMessage("Strange Pencil compatibility detected", "MULTIPLAYER")
+ MP.DECK.ban_card("j_pencil_calendar") -- potential desync
+ MP.DECK.ban_card("j_pencil_stonehenge") -- unfair advantage, also potential desync
+ MP.DECK.ban_card("c_pencil_chisel") -- might break phantom
+ MP.DECK.ban_card("c_pencil_peek") -- same reason as Matador
+
+ -- cannot insta-win in multiplayer
+ MP.DECK.ban_card("j_pencil_forbidden_one")
+ MP.DECK.ban_card("j_pencil_left_arm")
+ MP.DECK.ban_card("j_pencil_left_leg")
+ MP.DECK.ban_card("j_pencil_right_arm")
+ MP.DECK.ban_card("j_pencil_right_leg")
+end
diff --git a/compatibility/Talisman.lua b/compatibility/Talisman.lua
new file mode 100644
index 00000000..9c14e5fd
--- /dev/null
+++ b/compatibility/Talisman.lua
@@ -0,0 +1,3 @@
+to_big = to_big or function(x, y)
+ return x
+end
diff --git a/compatibility/TheOrder.lua b/compatibility/TheOrder.lua
new file mode 100644
index 00000000..63eded78
--- /dev/null
+++ b/compatibility/TheOrder.lua
@@ -0,0 +1,410 @@
+-- Credit to @MathIsFun_ for creating TheOrder, which this integration is a modified copy of
+-- Patches card creation to not be ante-based and use a single pool for every type/rarity
+local cc = create_card
+function create_card(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)
+ if MP.should_use_the_order() then
+ local a = G.GAME.round_resets.ante
+ G.GAME.round_resets.ante = 0
+ if _type == "Tarot" or _type == "Planet" or _type == "Spectral" then
+ if area == G.pack_cards then
+ key_append = _type .. "_pack"
+ else
+ key_append = _type
+ end
+ elseif not (_type == "Base" or _type == "Enhanced") then
+ if not (key_append == "jud" and G.GAME.stake >= 7) then
+ key_append = _rarity -- _rarity replacing key_append can be entirely removed to normalise rarity-specific skip tags, riff raff, and wraith with shop rarity queues
+ end
+ end
+ local c = cc(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)
+ G.GAME.round_resets.ante = a
+ return c
+ end
+ return cc(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)
+end
+
+-- Patches idol RNG when using the order to sort deck based on count of identical cards instead of default deck order
+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"
+
+ local count_map = {}
+ local valid_idol_cards = {}
+
+ for _, v in ipairs(G.playing_cards) do
+ 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 }
+ table.insert(valid_idol_cards, count_map[key])
+ end
+ count_map[key].count = count_map[key].count + 1
+ 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
+ end
+
+ local suit_order = {}
+ for i, suit in ipairs(SMODS.Suit.obj_buffer) do
+ suit_order[suit] = 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
+
+ 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 a_value = a.card.base.value
+ local b_value = b.card.base.value
+ return value_order[a_value] < value_order[b_value]
+ 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
+
+ local raw_random = pseudorandom("idol" .. G.GAME.round_resets.ante)
+
+ local threshold = 0
+ for _, entry in ipairs(valid_idol_cards) do
+ threshold = threshold + (entry.count / total_weight)
+ 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
+ )
+ 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
+ break
+ end
+ end
+ return
+ end
+
+ return original_reset_idol_card()
+end
+
+local original_reset_mail_rank = reset_mail_rank
+
+function reset_mail_rank()
+ if MP.should_use_the_order() then
+ G.GAME.current_round.mail_card.rank = "Ace"
+
+ local count_map = {}
+ local total_weight = 0
+ local value_order = {}
+ for i, rank in ipairs(SMODS.Rank.obj_buffer) do
+ value_order[rank] = i
+ end
+
+ local valid_ranks = {}
+
+ for _, v in ipairs(G.playing_cards) do
+ if v.ability.effect ~= "Stone Card" then
+ local val = v.base.value
+ if not count_map[val] then
+ count_map[val] = { count = 0, example_card = v }
+ table.insert(valid_ranks, { value = val, count = 0, example_card = v })
+ end
+ count_map[val].count = count_map[val].count + 1
+ end
+ end
+
+ -- Failsafe: all stone cards
+ if #valid_ranks == 0 then return end
+
+ -- Sort by count desc, then value asc
+ table.sort(valid_ranks, function(a, b)
+ if a.count ~= b.count then return a.count > b.count end
+ return value_order[a.value] < value_order[b.value]
+ end)
+
+ total_weight = 0
+ for _, entry in ipairs(valid_ranks) do
+ total_weight = total_weight + count_map[entry.value].count
+ end
+
+ local raw_random = pseudorandom("mail" .. G.GAME.round_resets.ante)
+
+ local threshold = 0
+ for i, entry in ipairs(valid_ranks) do
+ local count = count_map[entry.value].count
+ local weight = (count / total_weight)
+ threshold = threshold + weight
+ if raw_random < threshold then
+ sendDebugMessage(
+ "(Mail) Selected card "
+ .. entry.example_card.base.value
+ .. " with weight "
+ .. count
+ .. " of total "
+ .. total_weight
+ )
+ G.GAME.current_round.mail_card.rank = entry.example_card.base.value
+ G.GAME.current_round.mail_card.id = entry.example_card.base.id
+ break
+ end
+ end
+
+ return
+ end
+
+ return original_reset_mail_rank()
+end
+
+-- Take ownership of standard pack card creation
+SMODS.Booster:take_ownership_by_kind("Standard", {
+ create_card = function(self, card, i)
+ local s_append = "" -- MP.get_booster_append(card)
+ local b_append = MP.ante_based() .. s_append
+
+ local _edition = poll_edition("standard_edition" .. b_append, 2, true)
+ local _seal = SMODS.poll_seal({ mod = 10, key = "stdseal" .. b_append })
+
+ return {
+ set = (pseudorandom(pseudoseed("stdset" .. b_append)) > 0.6) and "Enhanced" or "Base",
+ edition = _edition,
+ seal = _seal,
+ area = G.pack_cards,
+ skip_materialize = true,
+ soulable = true,
+ key_append = "sta" .. s_append,
+ }
+ end,
+}, true)
+
+-- Patch seal queues
+local pollseal = SMODS.poll_seal
+function SMODS.poll_seal(args)
+ if MP.should_use_the_order() then
+ local a = G.GAME.round_resets.ante
+ G.GAME.round_resets.ante = 0
+ local ret = pollseal(args)
+ G.GAME.round_resets.ante = a
+ return ret
+ end
+ return pollseal(args)
+end
+
+-- Make voucher queue less chaotic
+-- I don't like the fact that we have to do this twice
+
+local function get_culled(_pool)
+ local culled = {}
+ for i = 1, #_pool, 2 do
+ local first = _pool[i]
+ local second = _pool[i + 1]
+
+ if second == nil then
+ -- idk if this ever triggers but just to be safe
+ culled[#culled + 1] = (first ~= "UNAVAILABLE") and first or "UNAVAILABLE"
+ elseif first ~= "UNAVAILABLE" and second ~= "UNAVAILABLE" then
+ -- only true in the case of mods adding t3 vouchers
+ culled[#culled + 1] = first
+ culled[#culled + 1] = second
+ elseif first ~= "UNAVAILABLE" then
+ culled[#culled + 1] = first
+ elseif second ~= "UNAVAILABLE" then
+ culled[#culled + 1] = second
+ else
+ culled[#culled + 1] = "UNAVAILABLE"
+ end
+ end
+ return culled
+end
+
+local nextvouchers = SMODS.get_next_vouchers
+function SMODS.get_next_vouchers(vouchers)
+ if MP.should_use_the_order() or MP.is_major_league_ruleset() then
+ vouchers = vouchers or { spawn = {} }
+ local _pool = get_current_pool("Voucher")
+ local culled = get_culled(_pool)
+ for i = #vouchers + 1, math.min(
+ SMODS.size_of_pool(_pool),
+ G.GAME.starting_params.vouchers_in_shop + (G.GAME.modifiers.extra_vouchers or 0)
+ ) do
+ local center = pseudorandom_element(culled, pseudoseed("Voucher0"))
+ local it = 1
+ while center == "UNAVAILABLE" or vouchers.spawn[center] do
+ it = it + 1
+ center = pseudorandom_element(culled, pseudoseed("Voucher0"))
+ end
+ vouchers[#vouchers + 1] = center
+ vouchers.spawn[center] = true
+ end
+ return vouchers
+ end
+ return nextvouchers(vouchers)
+end
+
+local nextvoucherkey = get_next_voucher_key
+function get_next_voucher_key(_from_tag)
+ if MP.should_use_the_order() or MP.is_major_league_ruleset() then
+ local _pool = get_current_pool("Voucher")
+ local culled = get_culled(_pool)
+ local center = pseudorandom_element(culled, pseudoseed("Voucher0"))
+ local it = 1
+ while center == "UNAVAILABLE" do
+ it = it + 1
+ center = pseudorandom_element(culled, pseudoseed("Voucher0"))
+ end
+ return center
+ end
+ return nextvoucherkey(_from_tag)
+end
+
+-- Helper function to make code more readable - deal with ante
+function MP.ante_based()
+ if MP.should_use_the_order() then return 0 end
+ return G.GAME.round_resets.ante
+end
+
+-- Handle round based rng with order (avoid desync with skips)
+function MP.order_round_based(ante_based)
+ if MP.should_use_the_order() then
+ return G.GAME.round_resets.ante .. (G.GAME.blind.config.blind.key or "") -- fine becase no boss shenanigans... change this if that happens
+ end
+ if ante_based then return MP.ante_based() end
+ return ""
+end
+
+-- Helper function for a sorted hand list to fix pairs() jank
+function MP.sorted_hand_list(current_hand)
+ if not current_hand then current_hand = "NULL" end
+ local _poker_hands = {}
+ local done = false
+ local order = 1
+ while not done do -- messy selection sort
+ done = true
+ for k, v in pairs(G.GAME.hands) do
+ if v.order == order then
+ order = order + 1
+ done = false
+ if v.visible and k ~= current_hand then _poker_hands[#_poker_hands + 1] = k end
+ end
+ end
+ end
+ return _poker_hands
+end
+
+-- Rework shuffle rng to be more similar between players
+local orig_shuffle = CardArea.shuffle
+function CardArea:shuffle(_seed)
+ if MP.should_use_the_order() and self == G.deck then
+ local centers =
+ { -- these are roughly ordered in terms of current meta, doesn't matter toooo much? but they have to be ordered
+ c_base = 0,
+ m_stone = 106,
+ m_bonus = 107,
+ m_mult = 108,
+ m_wild = 109,
+ m_gold = 110,
+ m_lucky = 111,
+ m_steel = 112,
+ m_glass = 113,
+ }
+ local seals = {
+ Gold = 122,
+ Blue = 131,
+ Purple = 140,
+ Red = 149,
+ }
+ local editions = {
+ foil = 157,
+ holo = 192,
+ polychrome = 227,
+ }
+ -- no mod compat, but mods aren't too competitive, it won't matter much
+
+ local tables = {}
+
+ for i, v in ipairs(self.cards) do -- give each card a value based on current enhancement/seal/edition
+ v.mp_stdval = 0 + (centers[v.config.center_key] or 0)
+ v.mp_stdval = v.mp_stdval + (seals[v.seal or "nil"] or 0)
+ v.mp_stdval = v.mp_stdval + (editions[v.edition and v.edition.type or "nil"] or 0)
+ local key = v.config.center_key == "m_stone" and "Stone" or v.base.suit .. v.base.id
+ tables[key] = tables[key] or {}
+ tables[key][#tables[key] + 1] = v
+ end
+
+ local true_seed = pseudorandom(_seed or "shuffle")
+
+ for k, v in pairs(tables) do
+ table.sort(v, function(a, b)
+ return a.mp_stdval > b.mp_stdval
+ end) -- largest value first
+ local mega_seed = k .. true_seed
+ for i, card in ipairs(v) do
+ card.mp_shuffleval = pseudorandom(mega_seed)
+ end
+ end
+ table.sort(self.cards, function(a, b)
+ return a.mp_shuffleval > b.mp_shuffleval
+ end)
+ self:set_ranks()
+ else
+ return orig_shuffle(self, _seed)
+ end
+end
+
+-- Make pseudorandom_element selecting a joker less chaotic
+local orig_pseudorandom_element = pseudorandom_element
+function pseudorandom_element(_t, seed, args)
+ if MP.should_use_the_order() then
+ local is_joker = true
+ for k, v in pairs(_t) do
+ if not (type(v) == "table" and v.ability and v.ability.set == "Joker") then
+ is_joker = false
+ break
+ end
+ end
+ if is_joker then
+ local tables = {}
+ local keys = {}
+ for k, v in pairs(_t) do
+ keys[#keys + 1] = { k = k, v = v }
+ local key = v.config.center.key
+ tables[key] = tables[key] or {}
+ tables[key][#tables[key] + 1] = v
+ end
+ local true_seed = pseudorandom(seed or math.random())
+ for k, v in pairs(tables) do
+ table.sort(v, function(a, b)
+ return a.sort_id < b.sort_id
+ end) -- oldest joker (lowest sort_id) first
+ local mega_seed = k .. true_seed
+ for i, card in ipairs(v) do
+ card.mp_shuffleval = pseudorandom(mega_seed)
+ end
+ end
+
+ table.sort(keys, function(a, b)
+ return a.v.mp_shuffleval > b.v.mp_shuffleval
+ end)
+
+ local key = keys[1].k
+ return _t[key], key
+ end
+ end
+ return orig_pseudorandom_element(_t, seed, args)
+end
diff --git a/compatibility/TooManyJokers.lua b/compatibility/TooManyJokers.lua
new file mode 100644
index 00000000..28df2f34
--- /dev/null
+++ b/compatibility/TooManyJokers.lua
@@ -0,0 +1,13 @@
+if TMJ then
+ TMJ.ALLOW_HIGHLIGHT = false --this is implemented in a hook, can't move it over here
+ G.FUNCS.tmj_spawn = function()
+ error("This is not allowed in Multiplayer")
+ end
+ --TMJ v4
+ if TMJ.FUNCS and TMJ.config then
+ TMJ.FUNCS.CHEAT_TOGGLE = function()
+ return
+ end --this would otherwise return the toggle which allows below config to be changed
+ TMJ.config.disable_ctrl_enter = true
+ end
+end
diff --git a/compatibility/UpgradeMod.lua b/compatibility/UpgradeMod.lua
new file mode 100644
index 00000000..f5b8e5a8
--- /dev/null
+++ b/compatibility/UpgradeMod.lua
@@ -0,0 +1,34 @@
+if SMODS.Mods["upgrademod"] and SMODS.Mods["upgrademod"].can_load then
+ function action_asteroid()
+ local hand_priority = {
+ ["Flush Five"] = 1,
+ ["Flush House"] = 2,
+ ["Five of a Kind"] = 3,
+ ["Straight Flush"] = 4,
+ ["Four of a Kind"] = 5,
+ ["Full House"] = 6,
+ ["Flush"] = 7,
+ ["Straight"] = 8,
+ ["Three of a Kind"] = 9,
+ ["Two Pair"] = 11,
+ ["Pair"] = 12,
+ ["High Card"] = 13,
+ }
+ local hand_type = "High Card"
+ local max_level = 0
+
+ for k, v in pairs(G.GAME.hands) do
+ if v.visible then
+ if
+ to_big(v.level) > to_big(max_level)
+ or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type])
+ then
+ hand_type = k
+ max_level = v.level
+ end
+ end
+ end
+
+ SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -((asteroid_factor or 1) * (planet_level or 1)) })
+ end
+end
diff --git a/compatibility/_compatibility.lua b/compatibility/_compatibility.lua
new file mode 100644
index 00000000..79fc5c84
--- /dev/null
+++ b/compatibility/_compatibility.lua
@@ -0,0 +1,90 @@
+MP.DECK = {}
+
+MP.DECK.BANNED_JOKERS = {}
+
+MP.DECK.BANNED_CONSUMABLES = {}
+
+MP.DECK.BANNED_VOUCHERS = {}
+
+MP.DECK.BANNED_ENHANCEMENTS = {}
+
+MP.DECK.BANNED_TAGS = {}
+
+MP.DECK.BANNED_BLINDS = {}
+
+function MP.DECK.ban_card(card_id)
+ if card_id:sub(1, 1) == "j" then
+ MP.DECK.BANNED_JOKERS[#MP.DECK.BANNED_JOKERS + 1] = card_id
+ elseif card_id:sub(1, 1) == "v" then
+ MP.DECK.BANNED_VOUCHERS[#MP.DECK.BANNED_VOUCHERS + 1] = card_id
+ elseif card_id:sub(1, 1) == "m" then
+ MP.DECK.BANNED_ENHANCEMENTS[#MP.DECK.BANNED_ENHANCEMENTS + 1] = card_id
+ end
+end
+
+function MP.DECK.ban_tag(tag_id)
+ MP.DECK.BANNED_TAGS[#MP.DECK.BANNED_TAGS + 1] = tag_id
+end
+
+function MP.DECK.ban_blind(blind_id)
+ MP.DECK.BANNED_BLINDS[#MP.DECK.BANNED_BLINDS + 1] = blind_id
+end
+
+local j_broken = {
+ order = 1,
+ unlocked = true,
+ start_alerted = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = true,
+ eternal_compat = true,
+ rarity = 4,
+ cost = 10000,
+ name = "BROKEN",
+ pos = { x = 9, y = 9 },
+ set = "Joker",
+ effect = "",
+ cost_mult = 1.0,
+ config = {},
+ key = "j_broken",
+}
+
+local card_init_ref = Card.init
+function Card:init(X, Y, W, H, card, center, params)
+ if center == nil then center = j_broken end
+ card_init_ref(self, X, Y, W, H, card, center, params)
+end
+
+MP.DECK.MAX_STAKE = 0
+
+local stake_queue = {}
+
+function MP.set_max_stake(stake_key)
+ if not SMODS.booted then
+ stake_queue[stake_key] = true
+ return
+ end
+ local stake = 1
+ repeat
+ local key = SMODS.stake_from_index(stake)
+ if key == stake_key then
+ sendTraceMessage("Setting max stake to " .. stake, "MULTIPLAYER")
+ MP.DECK.MAX_STAKE = math.max(stake, MP.DECK.MAX_STAKE)
+ return
+ end
+ stake = stake + 1
+ until key == "error"
+end
+
+local game_update_ref = Game.update
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:update(dt)
+ game_update_ref(self, dt)
+
+ if next(stake_queue) and SMODS.booted then
+ for key, _ in pairs(stake_queue) do
+ MP.set_max_stake(key)
+ stake_queue[key] = nil
+ end
+ end
+end
diff --git a/config.lua b/config.lua
new file mode 100644
index 00000000..3a036cae
--- /dev/null
+++ b/config.lua
@@ -0,0 +1,16 @@
+return {
+ ["username"] = "Guest",
+ ["blind_col"] = 1,
+ ["server_url"] = "balatro.virtualized.dev",
+ ["server_port"] = 8788,
+ ["logging"] = false,
+ ["misprint_display"] = true,
+ ["integrations"] = {
+ ["TheOrder"] = true,
+ ["Preview"] = false,
+ },
+ ["unlocked"] = true,
+ ["preview"] = {},
+ ["joker_stats"] = {},
+ ["match_history"] = {},
+}
diff --git a/core.lua b/core.lua
new file mode 100644
index 00000000..8391e4a9
--- /dev/null
+++ b/core.lua
@@ -0,0 +1,311 @@
+MP = SMODS.current_mod
+
+MP.BANNED_MODS = {
+ ["Incantation"] = true,
+ ["Brainstorm"] = true,
+ ["DVPreview"] = true,
+ ["Aura"] = true,
+ ["NotJustYet"] = true,
+ ["Showman"] = true,
+ ["TagPreview"] = true,
+ ["FantomsPreview"] = true,
+}
+
+MP.LOBBY = {
+ connected = false,
+ temp_code = "",
+ temp_seed = "",
+ code = nil,
+ type = "",
+ config = {}, -- Now set in MP.reset_lobby_config
+ deck = {
+ back = "Red Deck",
+ sleeve = "sleeve_casl_none",
+ stake = 1,
+ challenge = "",
+ },
+ username = "Guest",
+ blind_col = 1,
+ host = {},
+ guest = {},
+ is_host = false,
+ ready_to_start = false,
+}
+MP.GAME = {}
+MP.UI = {}
+MP.ACTIONS = {}
+MP.MOD_ACTIONS = {}
+
+-- SMODS flag: lets cards count as multiple enhancements at once (required by Alloy)
+MP.optional_features = { quantum_enhancements = true }
+
+function MP.register_mod_action(modAction, callback, modId)
+ if not modId then
+ local mod = SMODS.current_mod
+ if not mod then
+ sendWarnMessage("MP.register_mod_action called outside of mod init without a modId", "MULTIPLAYER")
+ return
+ end
+ modId = mod.id
+ end
+ MP.MOD_ACTIONS[modId] = MP.MOD_ACTIONS[modId] or {}
+ MP.MOD_ACTIONS[modId][modAction] = callback
+end
+
+MP.INTEGRATIONS = {
+ Preview = SMODS.Mods["Multiplayer"].config.integrations.Preview,
+}
+
+MP.PREVIEW = {
+ text = SMODS.Mods["Multiplayer"].config.preview.text,
+ button = SMODS.Mods["Multiplayer"].config.preview.button,
+}
+
+MP.EXPERIMENTAL = {
+ use_new_networking = true,
+ show_sandbox_collection = false,
+ alt_stakes = false,
+}
+
+-- Override experimental flags from .env file if present
+local env_path = MP.path .. "/.env"
+local env_info = NFS.getInfo(env_path)
+if env_info then
+ local content = NFS.read(env_path)
+ if content then
+ for line in content:gmatch("[^\r\n]+") do
+ line = line:match("^%s*(.-)%s*$") -- trim
+ if line ~= "" and not line:match("^#") then
+ local key, val = line:match("^([%w_]+)%s*=%s*(.+)$")
+ if key and MP.EXPERIMENTAL[key] ~= nil then
+ if val == "true" then
+ val = true
+ elseif val == "false" then
+ val = false
+ end
+ MP.EXPERIMENTAL[key] = val
+ end
+ end
+ end
+ sendDebugMessage("Loaded .env overrides for MP.EXPERIMENTAL", "MULTIPLAYER")
+ end
+end
+
+G.C.MULTIPLAYER = HEX("AC3232")
+
+MP.SMODS_VERSION = "1.0.0~BETA-1503a"
+MP.REQUIRED_LOVELY_VERSION = "0.9"
+
+function MP.should_use_the_order()
+ return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.the_order and MP.LOBBY.code
+end
+
+function MP.is_major_league_ruleset()
+ return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.ruleset == "ruleset_mp_majorleague" and MP.LOBBY.code
+end
+
+function MP.load_mp_file(file)
+ local chunk, err = SMODS.load_file(file, "Multiplayer")
+ if chunk then
+ local ok, func = pcall(chunk)
+ if ok then
+ return func
+ else
+ sendWarnMessage("Failed to process file: " .. func, "MULTIPLAYER")
+ end
+ else
+ sendWarnMessage("Failed to find or compile file: " .. tostring(err), "MULTIPLAYER")
+ end
+ return nil
+end
+
+function MP.load_mp_dir(directory, recursive)
+ recursive = recursive or false
+ local function has_prefix(name)
+ return name:match("^_") ~= nil
+ end
+
+ local dir_path = MP.path .. "/" .. directory
+ local items = NFS.getDirectoryItemsInfo(dir_path)
+ -- sort by prefix like { _file, _dir, file, dir }
+ table.sort(items, function(a, b)
+ local ac, bc = 0, 0
+ if has_prefix(a.name) then ac = ac + 100 end
+ if has_prefix(b.name) then bc = bc + 100 end
+ if a.type == "directory" then ac = ac + 10 end
+ if b.type == "directory" then bc = bc + 10 end
+ if ac ~= bc then return ac > bc end
+ return string.lower(a.name) < string.lower(b.name)
+ end)
+
+ -- load sorted files/dirs
+ for _, item in ipairs(items) do
+ local path = directory .. "/" .. item.name
+ if item.type ~= "directory" then
+ MP.load_mp_file(path)
+ elseif recursive then
+ MP.load_mp_dir(path, recursive)
+ end
+ end
+end
+
+MP.load_mp_dir("lib")
+MP.load_mp_dir("overrides")
+
+function MP.reset_lobby_config(persist_ruleset_and_gamemode)
+ sendDebugMessage("Resetting lobby options", "MULTIPLAYER")
+ MP.LOBBY.config = {
+ gold_on_life_loss = true,
+ no_gold_on_round_loss = false,
+ death_on_round_loss = true,
+ different_seeds = false,
+ the_order = true,
+ starting_lives = 4,
+ pvp_start_round = 2,
+ timer_base_seconds = 150,
+ timer_increment_seconds = 60,
+ pvp_countdown_seconds = 3,
+ showdown_starting_antes = 3,
+ ruleset = persist_ruleset_and_gamemode and MP.LOBBY.config.ruleset or "ruleset_mp_blitz",
+ gamemode = persist_ruleset_and_gamemode and MP.LOBBY.config.gamemode or "gamemode_mp_attrition",
+ weekly = nil,
+ custom_seed = "random",
+ different_decks = false,
+ back = "Red Deck",
+ sleeve = "sleeve_casl_none",
+ stake = 1,
+ challenge = "",
+ cocktail = "",
+ multiplayer_jokers = true,
+ timer = true,
+ timer_forgiveness = 0,
+ forced_config = false,
+ preview_disabled = false,
+ legacy_smallworld = false,
+ }
+end
+MP.reset_lobby_config()
+
+function MP.reset_game_states()
+ sendDebugMessage("Resetting game states", "MULTIPLAYER")
+ MP.GAME = {
+ ready_blind = false,
+ ready_blind_text = localize("b_ready"),
+ processed_round_done = false,
+ lives = 0,
+ loaded_ante = 0,
+ loading_blinds = false,
+ comeback_bonus_given = true,
+ comeback_bonus = 0,
+ end_pvp = false,
+ enemy = {
+ score = MP.INSANE_INT.empty(),
+ score_text = "0",
+ hands = 4,
+ location = localize("loc_selecting"),
+ skips = 0,
+ lives = MP.LOBBY.config.starting_lives,
+ sells = 0,
+ sells_per_ante = {},
+ spent_in_shop = {},
+ highest_score = MP.INSANE_INT.empty(),
+ },
+ location = "loc_selecting",
+ next_blind_context = nil,
+ ante_key = tostring(math.random()),
+ antes_keyed = {},
+ prevent_eval = false,
+ round_ended = false,
+ duplicate_end = false,
+ misprint_display = "",
+ spent_total = 0,
+ spent_before_shop = 0,
+ highest_score = MP.INSANE_INT.empty(),
+ timer = MP.LOBBY.config.timer_base_seconds,
+ timer_started = false,
+ pvp_countdown = 0,
+ real_money = 0,
+ ce_cache = false,
+ furthest_blind = 0,
+ pincher_index = -3,
+ pincher_unlock = false,
+ asteroids = 0,
+ pizza_discards = 0,
+ wait_for_enemys_furthest_blind = false,
+ disable_live_and_timer_hud = false,
+ timers_forgiven = 0,
+ stats = {
+ reroll_count = 0,
+ reroll_cost_total = 0,
+ -- Add more stats here in the future
+ },
+ }
+end
+MP.reset_game_states()
+
+MP.LOBBY.username = MP.UTILS.get_username()
+MP.LOBBY.blind_col = MP.UTILS.get_blind_col()
+
+MP.LOBBY.config.weekly = MP.UTILS.get_weekly()
+
+if not SMODS.current_mod.lovely then
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ trigger = "immediate",
+ blockable = false,
+ blocking = false,
+ func = function()
+ if G.MAIN_MENU_UI then
+ MP.UI.UTILS.overlay_message(
+ MP.UTILS.wrapText(
+ "Your Multiplayer Mod is not loaded correctly, make sure the Multiplayer folder does not have an extra Multiplayer folder around it.",
+ 50
+ )
+ )
+ return true
+ end
+ end,
+ }))
+ return
+end
+
+SMODS.Atlas({
+ key = "modicon",
+ path = "modicon.png",
+ px = 34,
+ py = 34,
+})
+
+MP.load_mp_dir("compatibility")
+
+local networking_dir = MP.EXPERIMENTAL.use_new_networking and "networking" or "networking-old"
+MP.load_mp_file(networking_dir .. "/action_handlers.lua")
+
+MP.load_mp_dir("gamemodes")
+MP.load_mp_dir("rulesets")
+MP.load_mp_dir("ui", true)
+
+if MP.LOBBY.config.weekly then -- this could be a function but why bother
+ MP.load_mp_file("rulesets/weeklies/" .. MP.LOBBY.config.weekly .. ".lua")
+end
+
+MP.load_mp_dir("objects/editions")
+MP.load_mp_dir("objects/enhancements")
+MP.load_mp_dir("objects/stickers")
+MP.load_mp_dir("objects/blinds")
+MP.load_mp_dir("objects/decks")
+MP.load_mp_dir("objects/jokers")
+MP.load_mp_dir("objects/jokers/sandbox")
+MP.load_mp_dir("objects/jokers/sandbox/extra-credit")
+MP.load_mp_dir("objects/jokers/standard")
+MP.load_mp_dir("objects/stakes")
+MP.load_mp_dir("objects/tags")
+MP.load_mp_dir("objects/consumables")
+MP.load_mp_dir("objects/consumables/sandbox")
+MP.load_mp_dir("objects/boosters")
+MP.load_mp_dir("objects/challenges")
+
+local SOCKET = MP.load_mp_file(networking_dir .. "/socket.lua")
+MP.NETWORKING_THREAD = love.thread.newThread(SOCKET)
+MP.NETWORKING_THREAD:start(SMODS.Mods["Multiplayer"].config.server_url, SMODS.Mods["Multiplayer"].config.server_port)
+MP.ACTIONS.connect()
diff --git a/gamemodes/_gamemodes.lua b/gamemodes/_gamemodes.lua
new file mode 100644
index 00000000..a97e0b60
--- /dev/null
+++ b/gamemodes/_gamemodes.lua
@@ -0,0 +1,32 @@
+G.P_CENTER_POOLS.Gamemode = {}
+MP.Gamemodes = {}
+MP.Gamemode = SMODS.GameObject:extend({
+ obj_table = {},
+ obj_buffer = {},
+ required_params = {
+ "key",
+ "get_blinds_by_ante", -- Define custom logic for determining Small, Big, and Boss Blind based on the ante number.
+ "banned_jokers",
+ "banned_consumables",
+ "banned_vouchers",
+ "banned_enhancements",
+ "banned_tags",
+ "banned_blinds",
+ "reworked_jokers",
+ "reworked_consumables",
+ "reworked_vouchers",
+ "reworked_enhancements",
+ "reworked_tags",
+ "reworked_blinds",
+ "create_info_menu",
+ },
+ class_prefix = "gamemode",
+ inject = function(self)
+ MP.Gamemodes[self.key] = self
+ if not G.P_CENTER_POOLS.Gamemode then G.P_CENTER_POOLS.Gamemode = {} end
+ table.insert(G.P_CENTER_POOLS.Gamemode, self)
+ end,
+ process_loc_text = function(self)
+ SMODS.process_loc_text(G.localization.descriptions["Gamemode"], self.key, self.loc_txt)
+ end,
+})
diff --git a/gamemodes/attrition.lua b/gamemodes/attrition.lua
new file mode 100644
index 00000000..bcd7973d
--- /dev/null
+++ b/gamemodes/attrition.lua
@@ -0,0 +1,225 @@
+MP.Gamemode({
+ key = "attrition",
+ get_blinds_by_ante = function(self, ante)
+ if ante >= MP.LOBBY.config.pvp_start_round then
+ if not MP.LOBBY.config.normal_bosses then
+ return nil, nil, "bl_mp_nemesis"
+ else
+ G.GAME.round_resets.pvp_blind_choices.Boss = true
+ end
+ end
+ return nil, nil, nil
+ end,
+ banned_jokers = {
+ "j_mr_bones",
+ "j_luchador",
+ "j_matador",
+ "j_chicot",
+ },
+ banned_consumables = {},
+ banned_vouchers = {
+ "v_hieroglyph",
+ "v_petroglyph",
+ "v_directors_cut",
+ "v_retcon",
+ },
+ banned_enhancements = {},
+ banned_tags = {
+ "tag_boss",
+ },
+ banned_blinds = {
+ "bl_wall",
+ "bl_final_vessel",
+ },
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "tm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = MP.UTILS.wrapText(localize("k_attrition_description"), 70),
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.3,
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(
+ localize({ type = "variable", key = "k_ante_number", vars = { "1" } }),
+ {
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.small(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.big(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.random(),
+ },
+ },
+ },
+ { text_scale = 0.6 }
+ ),
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(
+ localize({ type = "variable", key = "k_ante_min", vars = { "2" } }),
+ {
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.small(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.big(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.nemesis(),
+ },
+ },
+ },
+ { text_scale = 0.6 }
+ ),
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(localize("k_lives"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "4",
+ scale = 1.5,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ }, { text_scale = 0.6 }),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "bm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_values_are_modifiable"),
+ scale = 0.4,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ }
+ end,
+}):inject()
diff --git a/gamemodes/showdown.lua b/gamemodes/showdown.lua
new file mode 100644
index 00000000..95bd27e3
--- /dev/null
+++ b/gamemodes/showdown.lua
@@ -0,0 +1,221 @@
+MP.Gamemode({
+ key = "showdown",
+ get_blinds_by_ante = function(self, ante)
+ if ante >= MP.LOBBY.config.showdown_starting_antes then
+ return "bl_mp_nemesis", "bl_mp_nemesis", "bl_mp_nemesis"
+ end
+ return nil, nil, nil
+ end,
+ banned_jokers = {
+ "j_mr_bones",
+ "j_luchador",
+ "j_matador",
+ "j_chicot",
+ },
+ banned_consumables = {},
+ banned_vouchers = {
+ "v_hieroglyph",
+ "v_petroglyph",
+ "v_directors_cut",
+ "v_retcon",
+ },
+ banned_enhancements = {},
+ banned_tags = {
+ "tag_boss",
+ },
+ banned_blinds = {
+ "bl_wall",
+ "bl_final_vessel",
+ },
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "tm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = MP.UTILS.wrapText(localize("k_showdown_description"), 70),
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.3,
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(
+ localize({ type = "variable", key = "k_ante_range", vars = { "1", "2" } }),
+ {
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.small(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.big(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.random(),
+ },
+ },
+ },
+ { text_scale = 0.6 }
+ ),
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(
+ localize({ type = "variable", key = "k_ante_min", vars = { "3" } }),
+ {
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.nemesis(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.nemesis(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.nemesis(),
+ },
+ },
+ },
+ { text_scale = 0.6 }
+ ),
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(localize("k_lives"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "4",
+ scale = 1.5,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ }, { text_scale = 0.6 }),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "bm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_values_are_modifiable"),
+ scale = 0.4,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ }
+ end,
+}):inject()
diff --git a/gamemodes/survival.lua b/gamemodes/survival.lua
new file mode 100644
index 00000000..57fd85f9
--- /dev/null
+++ b/gamemodes/survival.lua
@@ -0,0 +1,147 @@
+MP.Gamemode({
+ key = "survival",
+ get_blinds_by_ante = function(self, ante)
+ return nil, nil, nil
+ end,
+ banned_jokers = {
+ "j_mp_conjoined_joker",
+ "j_mp_defensive_joker",
+ "j_mp_lets_go_gambling",
+ "j_mp_magnet_sandbox",
+ "j_mp_pacifist",
+ "j_mp_penny_pincher",
+ "j_mp_pizza",
+ "j_mp_skip_off",
+ "j_mp_speedrun",
+ "j_mp_taxes",
+ },
+ banned_consumables = {
+ "c_mp_asteroid",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "tm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = MP.UTILS.wrapText(localize("k_survival_description"), 70),
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.4,
+ minh = 0.4,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.3,
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(
+ localize({ type = "variable", key = "k_ante_min", vars = { "1" } }),
+ {
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.small(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.big(),
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.2,
+ minh = 0.2,
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = MP.UI.BlindChip.random(),
+ },
+ },
+ },
+ { text_scale = 0.6 }
+ ),
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(localize("k_lives"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "1",
+ scale = 1.5,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ }, { text_scale = 0.6 }),
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ end,
+}):inject()
diff --git a/lib/_init.lua b/lib/_init.lua
new file mode 100644
index 00000000..1d02b32c
--- /dev/null
+++ b/lib/_init.lua
@@ -0,0 +1 @@
+MP.UTILS = {}
diff --git a/lib/_table_utils.lua b/lib/_table_utils.lua
new file mode 100644
index 00000000..e52b600b
--- /dev/null
+++ b/lib/_table_utils.lua
@@ -0,0 +1,68 @@
+-- TODO docstring here for this
+--
+
+-- Credit to Henrik Ilgen (https://stackoverflow.com/a/6081639)
+function MP.UTILS.serialize_table(val, name, skipnewlines, depth)
+ skipnewlines = skipnewlines or false
+ depth = depth or 0
+
+ local tmp = string.rep(" ", depth)
+
+ if name then tmp = tmp .. name .. " = " end
+
+ if type(val) == "table" then
+ tmp = tmp .. "{" .. (not skipnewlines and "\n" or "")
+
+ for k, v in pairs(val) do
+ tmp = tmp
+ .. MP.UTILS.serialize_table(v, k, skipnewlines, depth + 1)
+ .. ","
+ .. (not skipnewlines and "\n" or "")
+ end
+
+ tmp = tmp .. string.rep(" ", depth) .. "}"
+ elseif type(val) == "number" then
+ tmp = tmp .. tostring(val)
+ elseif type(val) == "string" then
+ tmp = tmp .. string.format("%q", val)
+ elseif type(val) == "boolean" then
+ tmp = tmp .. (val and "true" or "false")
+ else
+ tmp = tmp .. '"[inserializeable datatype:' .. type(val) .. ']"'
+ end
+
+ return tmp
+end
+
+-- Used only for some UI blob, can be moved
+function MP.UTILS.get_array_index_by_value(options, value)
+ for i, v in ipairs(options) do
+ if v == value then return i end
+ end
+ return nil
+end
+
+function MP.UTILS.reverse_key_value_pairs(tbl, stringify_keys)
+ local reversed_tbl = {}
+ for k, v in pairs(tbl) do
+ if stringify_keys then v = tostring(v) end
+ reversed_tbl[v] = k
+ end
+ return reversed_tbl
+end
+
+function MP.UTILS.shallow_copy(t)
+ local copy = {}
+ for k, v in pairs(t) do
+ copy[k] = v
+ end
+ return copy
+end
+
+function MP.UTILS.merge_tables(t1, t2)
+ local copy = MP.UTILS.shallow_copy(t1)
+ for k, v in pairs(t2) do
+ copy[k] = v
+ end
+ return copy
+end
diff --git a/lib/card_utils.lua b/lib/card_utils.lua
new file mode 100644
index 00000000..a4662688
--- /dev/null
+++ b/lib/card_utils.lua
@@ -0,0 +1,100 @@
+-- Pre-compile a reversed list of all the centers
+local reversed_centers = nil
+
+function MP.UTILS.card_to_string(card)
+ if not card or not card.base or not card.base.suit or not card.base.value then return "" end
+
+ if not reversed_centers then reversed_centers = MP.UTILS.reverse_key_value_pairs(G.P_CENTERS) end
+
+ local suit = string.sub(card.base.suit, 1, 1)
+
+ local rank_value_map = {
+ ["10"] = "T",
+ Jack = "J",
+ Queen = "Q",
+ King = "K",
+ Ace = "A",
+ }
+ local rank = rank_value_map[card.base.value] or card.base.value
+
+ local enhancement = reversed_centers[card.config.center] or "none"
+ local edition = card.edition and MP.UTILS.reverse_key_value_pairs(card.edition, true)["true"] or "none"
+ local seal = card.seal or "none"
+
+ local card_str = suit .. "-" .. rank .. "-" .. enhancement .. "-" .. edition .. "-" .. seal
+
+ return card_str
+end
+
+function MP.UTILS.joker_to_string(card)
+ if not card or not card.config or not card.config.center or not card.config.center.key then return "" end
+
+ local edition = card.edition and MP.UTILS.reverse_key_value_pairs(card.edition, true)["true"] or "none"
+ local eternal_or_perishable = "none"
+ if card.ability then
+ if card.ability.eternal then
+ eternal_or_perishable = "eternal"
+ elseif card.ability.perishable then
+ eternal_or_perishable = "perishable"
+ end
+ end
+ local rental = (card.ability and card.ability.rental) and "rental" or "none"
+
+ local joker_string = card.config.center.key .. "-" .. edition .. "-" .. eternal_or_perishable .. "-" .. rental
+
+ return joker_string
+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
+ for i = 1, #G.jokers.cards do
+ if G.jokers.cards[i].ability.name == key then return G.jokers.cards[i] end
+ end
+ return nil
+end
+
+function MP.UTILS.get_phantom_joker(key)
+ if not MP.shared or not MP.shared.cards then return nil end
+ for i = 1, #MP.shared.cards do
+ if
+ MP.shared.cards[i].ability.name == key
+ and MP.shared.cards[i].edition
+ and MP.shared.cards[i].edition.type == "mp_phantom"
+ then
+ return MP.shared.cards[i]
+ end
+ end
+ return nil
+end
+
+-- ??? seems to be dead code
+function MP.UTILS.run_for_each_joker(key, func)
+ if not G.jokers or not G.jokers.cards then return end
+ for i = 1, #G.jokers.cards do
+ if G.jokers.cards[i].ability.name == key then func(G.jokers.cards[i]) end
+ end
+end
+
+-- ??? seems to be dead code
+function MP.UTILS.run_for_each_phantom_joker(key, func)
+ if not MP.shared or not MP.shared.cards then return end
+ for i = 1, #MP.shared.cards do
+ if MP.shared.cards[i].ability.name == key then func(MP.shared.cards[i]) end
+ end
+end
+
+function MP.UTILS.get_deck_key_from_name(_name)
+ for k, v in pairs(G.P_CENTERS) do
+ if v.name == _name then return k end
+ end
+end
+
+function MP.UTILS.get_culled_pool(_type, _rarity, _legendary, _append)
+ local pool = get_current_pool(_type, _rarity, _legendary, _append)
+ local ret = {}
+ for i, v in ipairs(pool) do
+ if v ~= "UNAVAILABLE" then ret[#ret + 1] = v end
+ end
+ return ret
+end
diff --git a/lib/crypto.lua b/lib/crypto.lua
new file mode 100644
index 00000000..bce25a9a
--- /dev/null
+++ b/lib/crypto.lua
@@ -0,0 +1,65 @@
+function MP.UTILS.bxor(a, b)
+ local res = 0
+ local bitval = 1
+ while a > 0 and b > 0 do
+ local a_bit = a % 2
+ local b_bit = b % 2
+ if a_bit ~= b_bit then res = res + bitval end
+ bitval = bitval * 2
+ a = math.floor(a / 2)
+ b = math.floor(b / 2)
+ end
+ res = res + (a + b) * bitval
+ return res
+end
+
+function MP.UTILS.encrypt_string(str)
+ local hash = 2166136261
+ for i = 1, #str do
+ hash = MP.UTILS.bxor(hash, str:byte(i))
+ hash = (hash * 16777619) % 2 ^ 32
+ end
+ return string.format("%08x", hash)
+end
+
+function MP.UTILS.server_connection_ID()
+ local os_name = love.system.getOS()
+ local raw_id
+
+ if os_name == "Windows" then
+ local ffi = require("ffi")
+
+ ffi.cdef([[
+ typedef unsigned long DWORD;
+ typedef int BOOL;
+ typedef const char* LPCSTR;
+
+ BOOL GetVolumeInformationA(
+ LPCSTR lpRootPathName,
+ char* lpVolumeNameBuffer,
+ DWORD nVolumeNameSize,
+ DWORD* lpVolumeSerialNumber,
+ DWORD* lpMaximumComponentLength,
+ DWORD* lpFileSystemFlags,
+ char* lpFileSystemNameBuffer,
+ DWORD nFileSystemNameSize
+ );
+ ]])
+
+ local serial_ptr = ffi.new("DWORD[1]")
+ local ok = ffi.C.GetVolumeInformationA("C:\\", nil, 0, serial_ptr, nil, nil, nil, 0)
+ if ok ~= 0 then raw_id = tostring(serial_ptr[0]) end
+ elseif os_name == "OS X" then
+ local cmd =
+ [[ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/ { split($0, line, "\""); printf("%s\n", line[4]); }']]
+ local handle = io.popen(cmd)
+ local result = handle:read("*a")
+ if handle then handle:close() end
+ print(result)
+ raw_id = tostring(result)
+ end
+
+ if not raw_id then raw_id = os.getenv("USER") or os.getenv("USERNAME") or os_name end
+
+ return MP.UTILS.encrypt_string(raw_id)
+end
diff --git a/lib/insane_int.lua b/lib/insane_int.lua
new file mode 100644
index 00000000..d96b2085
--- /dev/null
+++ b/lib/insane_int.lua
@@ -0,0 +1,106 @@
+-- These functions are mostly just for handling really big numbers,
+-- no matter the source and even if talisman is not installed.
+
+-- This should NOT be used as a substitute for bigints in functional coded due to how barebones it is,
+-- Instead, it should be used for graphical purposes and such
+
+MP.INSANE_INT = {}
+
+MP.INSANE_INT.empty = function()
+ return {
+ coeffiocient = 0,
+ exponent = 0,
+ e_count = 0,
+ }
+end
+
+MP.INSANE_INT.create = function(coeffiocient, exponent, e_count)
+ return {
+ coeffiocient = tonumber(coeffiocient) or 0,
+ exponent = tonumber(exponent) or 0,
+ e_count = tonumber(e_count) or 0,
+ }
+end
+
+MP.INSANE_INT.from_string = function(str)
+ local e_count = 0
+ while #str > 0 and string.lower(string.sub(str, 1, 1)) == "e" do
+ e_count = e_count + 1
+ str = string.sub(str, 2)
+ end
+
+ local parts = MP.UTILS.string_split(str, "e")
+
+ return MP.INSANE_INT.create(parts[1], #parts > 1 and parts[2] or 0, e_count)
+end
+
+MP.INSANE_INT.to_string = function(insane_int_display)
+ local e = ""
+ for i = 1, insane_int_display.e_count do
+ e = e .. "e"
+ end
+
+ if insane_int_display.exponent == 0 then return e .. number_format(insane_int_display.coeffiocient) end
+
+ return e
+ .. number_format(insane_int_display.coeffiocient, 10000)
+ .. "e"
+ .. number_format(insane_int_display.exponent)
+end
+
+-- This doesn't really fit with the comment at the top,
+-- but I needed a way to compare highscores without storing this value seperately for no reason
+MP.INSANE_INT.greater_than = function(insane_int_display1, insane_int_display2)
+ if insane_int_display1.e_count ~= insane_int_display2.e_count then
+ return tonumber(insane_int_display1.e_count) > tonumber(insane_int_display2.e_count)
+ end
+
+ if insane_int_display1.exponent ~= insane_int_display2.exponent then
+ return tonumber(insane_int_display1.exponent) > tonumber(insane_int_display2.exponent)
+ end
+
+ return tonumber(insane_int_display1.coeffiocient) > tonumber(insane_int_display2.coeffiocient)
+end
+
+-- ignore deprected warning for math.pow
+-- math.pow is used instead of ^ to avoid conflicts with talisman's __pow override
+-- theoretically the talisman override only applies to their special big number types and using '^' would be fine,
+-- but we use math.pow just in case
+---@diagnostic disable: deprecated
+MP.INSANE_INT.add = function(insane_int_display1, insane_int_display2)
+ local starting_e_count
+ local coeffiocient
+ local exponent
+
+ local myStartingECount = insane_int_display1.e_count
+ local myCoefficient = insane_int_display1.coeffiocient
+ local myExponent = insane_int_display1.exponent
+
+ local otherStartingECount = insane_int_display2.e_count
+ local otherCoefficient = insane_int_display2.coeffiocient
+ local otherExponent = insane_int_display2.exponent
+
+ if myStartingECount > otherStartingECount then
+ otherExponent = (otherExponent / math.pow(10, (myStartingECount - otherStartingECount)))
+ starting_e_count = myStartingECount
+ elseif myStartingECount < otherStartingECount then
+ myExponent = (myExponent / math.pow(10, (otherStartingECount - myStartingECount)))
+ starting_e_count = otherStartingECount
+ else
+ starting_e_count = myStartingECount
+ end
+
+ if myExponent > otherExponent then
+ coeffiocient = (otherCoefficient / math.pow(10, (myExponent - otherExponent))) + myCoefficient
+ exponent = myExponent
+ elseif myExponent < otherExponent then
+ coeffiocient = (myCoefficient / math.pow(10, (otherExponent - myExponent))) + otherCoefficient
+ exponent = otherExponent
+ else
+ coeffiocient = myCoefficient + otherCoefficient
+ exponent = myExponent
+ end
+
+ return MP.INSANE_INT.create(coeffiocient, exponent, starting_e_count)
+end
+---@diagnostic enable: deprecated
diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua
new file mode 100644
index 00000000..b3a0f19d
--- /dev/null
+++ b/lib/joker_stats.lua
@@ -0,0 +1,44 @@
+MP.STATS = {}
+
+function MP.STATS.get_player_joker_keys()
+ local keys = {}
+ if not G.jokers or not G.jokers.cards then return keys end
+ for i = 1, #G.jokers.cards do
+ local card = G.jokers.cards[i]
+ if card.config and card.config.center and card.config.center.key then
+ if not card.edition or card.edition.type ~= "mp_phantom" then table.insert(keys, card.config.center.key) end
+ end
+ end
+ return keys
+end
+
+function MP.STATS.record_match(won)
+ local config = SMODS.Mods["Multiplayer"].config
+ config.joker_stats = config.joker_stats or {}
+ config.match_history = config.match_history or {}
+
+ local joker_keys = MP.STATS.get_player_joker_keys()
+
+ local entry = {
+ won = won,
+ joker_keys = joker_keys,
+ gamemode = MP.LOBBY.config.gamemode,
+ ruleset = MP.LOBBY.config.ruleset,
+ timestamp = os.time(),
+ ante_reached = G.GAME.round_resets and G.GAME.round_resets.ante or 1,
+ }
+ table.insert(config.match_history, entry)
+
+ if won then
+ for _, key in ipairs(joker_keys) do
+ config.joker_stats[key] = (config.joker_stats[key] or 0) + 1
+ end
+ end
+
+ SMODS.save_mod_config(SMODS.Mods["Multiplayer"])
+end
+
+function MP.STATS.get_joker_wins(joker_key)
+ local config = SMODS.Mods["Multiplayer"].config
+ return config.joker_stats and config.joker_stats[joker_key] or 0
+end
diff --git a/lib/matchmaking.lua b/lib/matchmaking.lua
new file mode 100644
index 00000000..a41ba85a
--- /dev/null
+++ b/lib/matchmaking.lua
@@ -0,0 +1,222 @@
+MP.MOD_HASH = "0000"
+MP.MOD_STRING = ""
+
+function hash(str)
+ local str_to_hash = str or "0000"
+ local hash = 0
+ for i = 1, #str_to_hash do
+ local char = string.byte(str_to_hash, i)
+ hash = (hash * 31 + char) % 10000
+ end
+ return string.format("%04d", hash)
+end
+
+local function get_mod_data()
+ local mod_table = {}
+ for key, mod in pairs(SMODS.Mods) do
+ if not mod.disabled and key ~= "Balatro" then table.insert(mod_table, key .. "-" .. (mod.version or "UNK")) end
+ end
+ for key, mod in pairs(MP.INTEGRATIONS) do
+ if mod then table.insert(mod_table, key .. "-MultiplayerIntegration") end
+ end
+ return mod_table
+end
+
+function MP:generate_hash()
+ local mod_data = get_mod_data()
+ table.sort(mod_data)
+ table.insert(mod_data, 1, "serversideConnectionID=" .. tostring(MP.UTILS.server_connection_ID()))
+ table.insert(mod_data, 1, "encryptID=" .. tostring(MP.UTILS.encrypt_ID()))
+ SMODS.Mods["Multiplayer"].config.unlocked = MP.UTILS.unlock_check()
+ table.insert(mod_data, 1, "unlocked=" .. tostring(SMODS.Mods["Multiplayer"].config.unlocked))
+ table.insert(mod_data, 1, "preview=" .. tostring(SMODS.Mods["Multiplayer"].config.integrations.Preview))
+ local mod_string = table.concat(mod_data, ";")
+ MP.MOD_STRING = mod_string
+ MP.MOD_HASH = hash(mod_string) or "0000"
+ MP.ACTIONS.set_username(MP.LOBBY.username)
+end
+
+local hash_generated = false
+
+local game_update_ref = Game.update
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:update(dt)
+ game_update_ref(self, dt)
+
+ if not hash_generated and SMODS.booted then
+ MP:generate_hash()
+ hash_generated = true
+ end
+end
+
+function MP.UTILS.unlock_check()
+ local notFullyUnlocked = false
+
+ for k, v in pairs(G.P_CENTER_POOLS.Joker) do
+ if not v.unlocked then
+ notFullyUnlocked = true
+ break -- No need to keep checking once we know it's not fully unlocked
+ end
+ end
+
+ return not notFullyUnlocked
+end
+
+function MP.UTILS.encrypt_ID()
+ local encryptID = 1
+ for key, center in pairs(G.P_CENTERS or {}) do
+ if type(key) == "string" and key:match("^j_") then
+ if center.cost and type(center.cost) == "number" then encryptID = encryptID + center.cost end
+ if center.config and type(center.config) == "table" then
+ encryptID = encryptID + MP.UTILS.sum_numbers_in_table(center.config)
+ end
+ elseif type(key) == "string" and key:match("^[cvp]_") then
+ if center.cost and type(center.cost) == "number" then
+ if center.cost == 0 then return 0 end
+ encryptID = encryptID + center.cost
+ end
+ end
+ end
+ for key, value in pairs(G.GAME.starting_params or {}) do
+ if type(value) == "number" and value % 1 == 0 then encryptID = encryptID * value end
+ end
+ local day = tonumber(os.date("%d")) or 1
+ encryptID = encryptID * day
+ local gameSpeed = G.SETTINGS.GAMESPEED
+ if gameSpeed then
+ gameSpeed = gameSpeed * 16
+ gameSpeed = gameSpeed + 7
+ encryptID = encryptID + (gameSpeed / 1000)
+ else
+ encryptID = encryptID + 0.404
+ end
+ return encryptID
+end
+
+-- Parses a semicolon-delimited hash string containing client configuration data
+--
+-- Input format: "encryptID=123456;unlocked=true;ModName1-1.0.0;ModName2-2.1.0;serversideConnectionID=abc123"
+--
+-- Returns:
+-- config (table): Parsed configuration object with structure:
+-- {
+-- encryptID = number, -- Client's encryption ID
+-- unlocked = boolean, -- Whether client has all content unlocked
+-- Mods = table -- Parsed mod list (see parse_modlist for structure)
+-- }
+-- mod_string (string): Semicolon-delimited string of mod entries only (for backward compatibility)
+function MP.UTILS.parse_Hash(hash)
+ local parts = {}
+ for part in string.gmatch(hash, "([^;]+)") do
+ table.insert(parts, part)
+ end
+
+ local config = {
+ encryptID = nil,
+ unlocked = nil,
+ Mods = {},
+ }
+
+ local mod_data = {}
+
+ for _, part in ipairs(parts) do
+ local key, val = string.match(part, "([^=]+)=([^=]+)")
+ if key == "encryptID" then
+ config.encryptID = tonumber(val)
+ elseif key == "unlocked" then
+ config.unlocked = val == "true"
+ elseif key ~= "serversideConnectionID" then
+ table.insert(mod_data, part)
+ end
+ end
+
+ config.Mods = MP.UTILS.parse_modlist(mod_data)
+ -- this is for backwards compatibility
+ -- We don't need to return mod_string anymore; can use config.Mods as a cleaner interface for the host/guest's mods
+ -- and hash this in what we send to the server instead
+ local mod_string = table.concat(mod_data, ";")
+
+ return config, mod_string
+end
+
+-- Parses an array of mod entries into a mod table
+--
+-- Input: Array of mod entry strings: {"ModName1-1.0.0", "ModName2-2.1.0", "ModName3"}
+--
+-- Returns:
+-- mods (table): Key-value pairs where:
+-- - key = mod name (string)
+-- - value = mod version (string) or nil if no version specified
+--
+-- Example output:
+-- {
+-- ModName1 = "1.0.0",
+-- ModName2 = "2.1.0",
+-- ModName3 = nil
+-- }
+function MP.UTILS.parse_modlist(mod_entries)
+ if not mod_entries then return {} end
+
+ local mods = {}
+
+ for _, mod_entry in ipairs(mod_entries) do
+ local mod_name, mod_version
+
+ -- Split on the LAST dash to handle mod names with dashes (e.g., "lovely-compat-trance-v0.0.0")
+ mod_name, mod_version = string.match(mod_entry, "^(.-)%-([^%-]*)$")
+ if not mod_name then
+ -- No dash found, entire string is mod name
+ mod_name = mod_entry
+ mod_version = nil
+ end
+
+ mods[mod_name] = mod_version
+ end
+
+ return mods
+end
+
+function MP.UTILS.get_banned_mods(mods)
+ local banned_mods = {}
+ if not mods then return banned_mods end
+
+ for mod_name, mod_version in pairs(mods) do
+ local ban_info = MP.BANNED_MODS[mod_name]
+ local is_banned = false
+
+ if ban_info then
+ if type(ban_info) == "boolean" then
+ -- Old format: ban all versions
+ is_banned = ban_info
+ elseif type(ban_info) == "string" then
+ -- New format: ban specific version
+ is_banned = (mod_version == ban_info)
+ elseif type(ban_info) == "table" then
+ -- Table format: ban multiple specific versions
+ for _, banned_version in ipairs(ban_info) do
+ if mod_version == banned_version then
+ is_banned = true
+ break
+ end
+ end
+ end
+ end
+
+ if is_banned then table.insert(banned_mods, mod_name) end
+ end
+
+ return banned_mods
+end
+
+function MP.UTILS.sum_numbers_in_table(t)
+ local sum = 0
+ for k, v in pairs(t) do
+ if type(v) == "number" then
+ sum = sum + v
+ elseif type(v) == "table" then
+ sum = sum + MP.UTILS.sum_numbers_in_table(v)
+ end
+ -- ignore other types
+ end
+ return sum
+end
diff --git a/lib/ruleset_utils.lua b/lib/ruleset_utils.lua
new file mode 100644
index 00000000..f5d6ff3d
--- /dev/null
+++ b/lib/ruleset_utils.lua
@@ -0,0 +1,49 @@
+function MP.UTILS.get_standard_rulesets(add)
+ local ret = {}
+ for k, v in pairs(MP.Rulesets) do
+ if v.standard then ret[#ret + 1] = string.sub(v.key, 12, #v.key) end
+ end
+ if add then
+ if type(add) == "string" then add = { add } end
+ for i, v in ipairs(add) do
+ ret[#ret + 1] = v
+ end
+ end
+ return ret
+end
+
+function MP.UTILS.is_standard_ruleset()
+ local active = MP.get_active_ruleset()
+ if active == nil then return false end
+ for _, ruleset in ipairs(MP.UTILS.get_standard_rulesets()) do
+ if active == "ruleset_mp_" .. ruleset then return true end
+ end
+ return false
+end
+
+function MP.UTILS.get_weekly()
+ return SMODS.Mods["Multiplayer"].config.weekly
+end
+
+function MP.UTILS.is_weekly(arg)
+ return MP.UTILS.get_weekly() == arg and MP.LOBBY.config.ruleset == "ruleset_mp_weekly"
+end
+
+function MP.UTILS.check_smods_version()
+ if SMODS.version ~= MP.SMODS_VERSION then
+ return localize({ type = "variable", key = "k_ruleset_disabled_smods_version", vars = { MP.SMODS_VERSION } })
+ end
+ return false
+end
+
+function MP.UTILS.check_lovely_version()
+ local lovely_ver = SMODS.Mods["Lovely"] and SMODS.Mods["Lovely"].version or ""
+ if not lovely_ver:match("^" .. MP.REQUIRED_LOVELY_VERSION:gsub("%.", "%%.")) then
+ return localize({
+ type = "variable",
+ key = "k_ruleset_disabled_lovely_version",
+ vars = { MP.REQUIRED_LOVELY_VERSION },
+ })
+ end
+ return false
+end
diff --git a/lib/serialization.lua b/lib/serialization.lua
new file mode 100644
index 00000000..42856d38
--- /dev/null
+++ b/lib/serialization.lua
@@ -0,0 +1,56 @@
+-- From https://github.com/lunarmodules/Penlight (MIT license)
+local function save_global_env()
+ local env = {}
+ env.hook, env.mask, env.count = debug.gethook()
+
+ -- env.hook is "external hook" if is a C hook function
+ if env.hook ~= "external hook" then debug.sethook() end
+
+ env.string_mt = getmetatable("")
+ debug.setmetatable("", nil)
+ return env
+end
+
+-- From https://github.com/lunarmodules/Penlight (MIT license)
+local function restore_global_env(env)
+ if env then
+ debug.setmetatable("", env.string_mt)
+ if env.hook ~= "external hook" then debug.sethook(env.hook, env.mask, env.count) end
+ end
+end
+
+local function STR_UNPACK_CHECKED(str)
+ -- Code generated from STR_PACK should only return a table and nothing else
+ if str:sub(1, 8) ~= "return {" then error('Invalid string header, expected "return {..."') end
+
+ -- Protect against code injection by disallowing function definitions
+ -- This is a very naive check, but hopefully won't trigger false positives
+ if str:find("[^\"'%w_]function[^\"'%w_]") then error("Function keyword detected") end
+
+ -- Load with an empty environment, no functions or globals should be available
+ local chunk = assert(load(str, nil, "t", {}))
+ local global_env = save_global_env()
+ local success, str_unpacked = pcall(chunk)
+ restore_global_env(global_env)
+ if not success then error(str_unpacked) end
+
+ return str_unpacked
+end
+
+function MP.UTILS.str_pack_and_encode(data)
+ local str = STR_PACK(data)
+ local str_compressed = love.data.compress("string", "gzip", str)
+ local str_encoded = love.data.encode("string", "base64", str_compressed)
+ return str_encoded
+end
+
+function MP.UTILS.str_decode_and_unpack(str)
+ local success, str_decoded, str_decompressed, str_unpacked
+ 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)
+ if not success then return nil, str_decompressed end
+ success, str_unpacked = pcall(STR_UNPACK_CHECKED, str_decompressed)
+ if not success then return nil, str_unpacked end
+ return str_unpacked
+end
diff --git a/lib/settings_utils.lua b/lib/settings_utils.lua
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/string_utils.lua b/lib/string_utils.lua
new file mode 100644
index 00000000..c44a81e5
--- /dev/null
+++ b/lib/string_utils.lua
@@ -0,0 +1,26 @@
+-- Credit to Steamo (https://github.com/Steamopollys/Steamodded/blob/main/core/core.lua)
+function MP.UTILS.wrapText(text, maxChars)
+ local wrappedText = ""
+ local currentLineLength = 0
+
+ for word in text:gmatch("%S+") do
+ if currentLineLength + #word <= maxChars then
+ wrappedText = wrappedText .. word .. " "
+ currentLineLength = currentLineLength + #word + 1
+ else
+ wrappedText = wrappedText .. "\n" .. word .. " "
+ currentLineLength = #word + 1
+ end
+ end
+
+ return wrappedText
+end
+
+function MP.UTILS.string_split(inputstr, sep)
+ if sep == nil then sep = "%s" end
+ local t = {}
+ for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
+ table.insert(t, str)
+ end
+ return t
+end
diff --git a/lib/ui.lua b/lib/ui.lua
new file mode 100644
index 00000000..fdcabcc5
--- /dev/null
+++ b/lib/ui.lua
@@ -0,0 +1,118 @@
+function MP.UTILS.save_username(text)
+ MP.ACTIONS.set_username(text)
+ SMODS.Mods["Multiplayer"].config.username = text
+end
+
+function MP.UTILS.get_username()
+ return SMODS.Mods["Multiplayer"].config.username
+end
+
+function MP.UTILS.save_blind_col(num)
+ MP.ACTIONS.set_blind_col(num)
+ SMODS.Mods["Multiplayer"].config.blind_col = num
+end
+
+function MP.UTILS.get_blind_col()
+ return SMODS.Mods["Multiplayer"].config.blind_col
+end
+
+function MP.UTILS.blind_col_numtokey(num)
+ local keys = {
+ "tooth",
+ "small",
+ "big",
+ "hook",
+ "ox",
+ "house",
+ "wall",
+ "wheel",
+ "arm",
+ "club",
+ "fish",
+ "psychic",
+ "goad",
+ "water",
+ "window",
+ "manacle",
+ "eye",
+ "mouth",
+ "plant",
+ "serpent",
+ "pillar",
+ "needle",
+ "head",
+ "flint",
+ "mark",
+ }
+ return "bl_" .. keys[num]
+end
+
+function MP.UTILS.get_nemesis_key() -- calling this function assumes the user is currently in a multiplayer game
+ local ret =
+ MP.UTILS.blind_col_numtokey((MP.LOBBY.is_host and MP.LOBBY.guest.blind_col or MP.LOBBY.host.blind_col) or 1)
+ if tonumber(MP.GAME.enemy.lives) <= 1 and tonumber(MP.GAME.lives) <= 1 then
+ if G.STATE ~= G.STATES.ROUND_EVAL then -- very messy fix that mostly works. breaks in a different way... but far harder to notice
+ ret = "bl_final_heart"
+ end
+ end
+ return ret
+end
+
+function MP.UTILS.save_preview(table)
+ for k, v in pairs(table) do
+ SMODS.Mods["Multiplayer"].config.preview[k] = v
+ end
+end
+
+function MP.UTILS.get_preview_cfg(index)
+ local ret = SMODS.Mods["Multiplayer"].config.preview[index]
+ if not ret or #ret < 1 then
+ if index == "text" then
+ ret = "CALCULATING"
+ else
+ ret = "Calculate Score"
+ end
+ end
+ return ret
+end
+
+function MP.UTILS.copy_to_clipboard(text)
+ if G.F_LOCAL_CLIPBOARD then
+ G.CLIPBOARD = text
+ else
+ love.system.setClipboardText(text)
+ end
+end
+
+function MP.UTILS.get_from_clipboard()
+ if G.F_LOCAL_CLIPBOARD then
+ return G.F_LOCAL_CLIPBOARD
+ else
+ return love.system.getClipboardText()
+ end
+end
+
+function MP.UTILS.random_message()
+ local messages = {
+ localize("k_message1"),
+ localize("k_message2"),
+ localize("k_message3"),
+ localize("k_message4"),
+ localize("k_message5"),
+ localize("k_message6"),
+ localize("k_message7"),
+ localize("k_message8"),
+ localize("k_message9"),
+ }
+ return messages[math.random(1, #messages)]
+end
+
+function MP.UTILS.add_nemesis_info(info_queue)
+ if MP.LOBBY.code then
+ info_queue[#info_queue + 1] = {
+ set = "Other",
+ key = "current_nemesis",
+ vars = { MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username },
+ }
+ end
+end
diff --git a/localization/de.lua b/localization/de.lua
new file mode 100644
index 00000000..08d2bad4
--- /dev/null
+++ b/localization/de.lua
@@ -0,0 +1,299 @@
+--translation by Phrog
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "DEFEKT",
+ text = {
+ "Diese Karte ist defekt oder nicht",
+ "Implementiert in der jetzigen",
+ "version der Mod die benutzt wird.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Defensiver Joker",
+ text = {
+ "{C:chips}+#1#{} Chips für jedes {C:red,E:1}Leben{}",
+ "weniger als dein{X:purple,C:white} Erzfeind{}",
+ "{C:inactive}(Zurzeit {C:chips}+#2#{C:inactive} Chips)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Skip-Off",
+ text = {
+ "{C:blue}+#1#{} Hände und {C:red}+#2#{} Abwürfe ",
+ "pro zusätzliche übersprungene {C:attention}Blinds{} ",
+ "im Vergleich zu deinem {X:purple,C:white}Erzfeind {}",
+ "{C:inactive}(Currently {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's Go Gambling",
+ text = {
+ "{C:green}#1# in #2#{} Chance für",
+ "{X:mult,C:white}X#3#{} Mult und {C:money}$#4#{}",
+ "{C:green}#5# in #6#{} Chance",
+ "deinen {X:purple,C:white} Erzfeind{} {C:money}$#7# zu geben",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Falls du eine {C:attention}PvP Blind",
+ "bevor deinen{X:purple,C:white} Erzfeind{} erreichst,",
+ "Erscheint eine zufällige {C:spectral}Geister{} Karte",
+ "{C:inactive}(Muss Platz haben)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Verbundener Joker",
+ text = {
+ "Während einer{C:attention}PvP Blind{}, erhalte",
+ "{X:mult,C:white}X#1#{} Mult für jede {C:blue}Hand{}",
+ "die dein {X:purple,C:white} Erzfeind{} Übrig hat",
+ "{C:inactive}(Max {X:mult,C:white}X#2#{C:inactive} Mult, Zurzeit {X:mult,C:white}X#3#{C:inactive} Mult)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Pfennigfuchs",
+ text = {
+ "Beim Anfang des shop, erhalte",
+ "{C:money}$#1#{} für jede{C:money}$#2#{}",
+ "die dein {X:purple,C:white} Erzfeind{} im letzen shop benutzt hat",
+ },
+ },
+ j_mp_taxes = {
+ name = "Steuern",
+ text = {
+ "Wenn dein {X:purple,C:white} Erzfeind'{} eine ",
+ "Karte verkauft erhalte {C:mult}+#1#{} Mult",
+ "{C:inactive}(Zurzeit {C:mult}+#2#{C:inactive} Mult)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Magnet",
+ text = {
+ "Nach {C:attention}#1#{} Runden,",
+ "verkaufe diese Karte um eine {C:attention} Kopie{}",
+ "deines {X:purple,C:white} Erzfeinds'{}",
+ "{C:attention}Joker{} mit dem größten verkaufs wert",
+ "{C:inactive}(Zurzeit {C:attention}#2#{C:inactive}/#3# Runden)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "{C:red}+#1#{} Abwürfe für alle Spieler",
+ "{C:red}-#2#{} Abwürfe wenn irgendein Spieler",
+ "eine Blind auswählt",
+ "Aufgegessen wenn dein {X:purple,C:white} Erzfeind {} eine Runde Überspringt",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pazifist",
+ text = {
+ "{X:mult,C:white}X#1#{} Mult wenn",
+ "nicht in einer {C:attention}PvP Blind{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Stanzrest",
+ text = {
+ "Löse die{C:attention} Erste{} und {C:attention}zweite{}",
+ "gespielte Karte die für die Wertung benutzt wurde",
+ "{C:attention}#1#{} weiteres Mal aus",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroid",
+ text = {
+ "Entferne #1# level von",
+ "deinem {X:purple,C:white}Erzfeinds'{}",
+ "meist verbesserten",
+ "{C:legendary,E:1}Poker Hand{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Dein Erzfeind",
+ text = {
+ "Stelle dich einem anderen Spieler,",
+ "Größte Punktzahl gewinnt",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Phantom",
+ text = {
+ "{C:attention}Ewig{} und {C:dark_edition}Negative{}",
+ "Erzeugt und zerstört bei deinen {X:purple,C:white}Erzfeind{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Glass Karte",
+ text = {
+ "{X:mult,C:white} X#1# {} Mult",
+ "{C:green}#2# in #3#{} Chance diese",
+ "Karte zu zerstören",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Glass Karte",
+ text = {
+ "{X:mult,C:white} X#1# {} Mult",
+ "{C:green}#2# in #3#{} Chance diese",
+ "Karte zu zerstören",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Erzfeind",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Dein Größter und schlimmster Gegner",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Phantom",
+ },
+ challenge_names = {
+ c_mp_standard = "Standard",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Turnier",
+ c_mp_weekly = "Wöchentlich",
+ c_mp_vanilla = "Vanilla",
+ },
+ dictionary = {
+ b_singleplayer = "Einzelspieler",
+ b_join_lobby = "Lobby Beitreten",
+ b_return_lobby = "Zurück zur Lobby",
+ b_reconnect = "Wiederverbinden",
+ b_create_lobby = "Lobby Herstellen",
+ b_start_lobby = "Lobby Starten",
+ b_ready = "Bereit",
+ b_unready = "Nicht Bereit",
+ b_leave_lobby = "Lobby Verlassen",
+ b_mp_discord = "Balatro Mehrspieler Discord Server",
+ b_start = "START",
+ b_wait_for_host_start = {
+ "WARTEN AUF DEN",
+ "HOST ZUM STARTEN",
+ },
+ b_wait_for_players = {
+ "WARTEN AUF",
+ "SPIELER",
+ },
+ b_lobby_options = "LOBBY OPTIONEN",
+ b_copy_clipboard = "Kopier zur Zwischenablage",
+ b_view_code = "CODE ZEIGEN",
+ b_copy_code = "KOPIER CODE",
+ b_leave = "VERLASSEN",
+ b_opts_cb_money = "Gebe comeback $ wenn Leben verloren werden",
+ b_opts_no_gold_on_loss = "Kriege keine Blind Belohnung für verlieren",
+ b_opts_death_on_loss = "Verliere ein Leben bei Nicht-PVP Blind Niederlage",
+ b_opts_start_antes = "Starter Antes",
+ b_opts_diff_seeds = "Spieler haben unterschiedliche Code",
+ b_opts_lives = "Leben",
+ b_opts_multiplayer_jokers = "Füge Mehrspieler Karten hinzu",
+ b_opts_player_diff_deck = "Spieler haben unterschiedliche decks",
+ b_reset = "Neustart",
+ b_set_custom_seed = "Setzt eigenen Code fest",
+ b_mp_kofi_button = "Support mich auf Ko-fi",
+ b_unstuck = "Stecken geblieben",
+ b_unstuck_arcana = "Im Booster Pack stecken geblieben",
+ b_unstuck_blind = "Außerhalb der Pvp blind stecken geblieben",
+ k_enemy_score = "Gegners Punktzahl",
+ k_enemy_hands = "Gegners Übrige Hände: ",
+ k_coming_soon = "Kommt Noch!",
+ k_wait_enemy = "Warte auf dein Gegner...",
+ k_lives = "Leben",
+ k_lost_life = "Leben verloren",
+ k_total_lives_lost = " Gesamtzahl Verlorener Leben($4 each)",
+ k_attrition_name = "Attrition",
+ k_enter_lobby_code = "Lobby Code Einfügen",
+ k_paste = "Einfügen von der Zwischenablage",
+ k_username = "Benutzername:",
+ k_enter_username = "Benutzername Einfügen",
+ k_join_discord = "Finde uns auf ",
+ k_discord_msg = "Hier kannst du Bugs reporten und Spieler zum spielen finden",
+ k_enter_to_save = "Drück ENTER zum Speichern",
+ k_in_lobby = "In Der Lobby",
+ k_connected = "Verbunden zum Service",
+ k_warn_service = "WARNUNG: Konnten denn Mehrspieler service nicht finden",
+ k_set_name = "Setzt dein Benutzername im Hauptmenü! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "Spieler haben unterschiedliche mods oder mod versionen! Dies kann probleme erzeugen!",
+ k_lobby_options = "Lobby Optionen",
+ k_connect_player = "Verbundene Spieler:",
+ k_opts_only_host = "Nur der Lobby Host kann diese Option ändern",
+ k_opts_gm = "Spielemodus Modifikationen",
+ k_bl_life = "Leben",
+ k_bl_or = "oder",
+ k_bl_death = "Sterben",
+ k_current_seed = "jetziger Code: ",
+ k_random = "Zufällig",
+ k_standard = "Standard",
+ k_standard_description = "Der Standard Regelsatz,fügt Mehrspieler Karten und Änderungen hinzu um sich der Mehrspieler Meta anzupassen.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "Der Vanilla Regelsatz, Keine Mehrspieler Karten oder Änderungen zum standard Spiel.",
+ k_weekly = "Wöchentlich",
+ k_weekly_description = "Ein spezieller Regelsatz der sich wöchentlich oder jede andere woche ändert . Schätze du must selbst herausfinden was diesmal geändert wurde!Zurzeit: ",
+ k_tournament = "Turnier",
+ k_tournament_description = "Der Turnier Regelsatz, das selbe wie der standard Regelsatz aber du kannst nicht die Lobby Optionen ändern.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Ein wöchentlicher Regelsatz designed bei @dr_monty_the_snek Auf dem Discord Server dee permanent zur mod hinzufügt wurde.",
+ k_oops_ex = "Ups!",
+ ml_enemy_loc = {
+ "Gegner",
+ "Standort",
+ },
+ ml_mp_kofi_message = {
+ "Diese mod und der Spiel Server ist",
+ "Programmiert und beibehalten",
+ "von einer einzigen Person,",
+ "fallst du es magst dann vielleicht",
+ },
+ loc_ready = "Bereit für PvP",
+ loc_selecting = "Am Blind auswählen",
+ loc_shop = "Beim Einkaufen",
+ loc_playing = "Am Spielen ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Design: #1#",
+ },
+ a_mp_code = {
+ "Code: #1#",
+ },
+ a_mp_idea = {
+ "Idee: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Runden voraus",
+ },
+ a_mp_skips_behind = {
+ "#1# Runden hinter dir",
+ },
+ a_mp_skips_tied = {
+ "Unentschieden",
+ },
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Stanzrest{} ist {C:dark_edition} Verändert",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Glass Karten{} sind {C:dark_edition} Verändert",
+ },
+ },
+ },
+}
diff --git a/localization/en-us.lua b/localization/en-us.lua
new file mode 100644
index 00000000..8df7f5fc
--- /dev/null
+++ b/localization/en-us.lua
@@ -0,0 +1,1227 @@
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "Gambling Tag",
+ text = {
+ "{C:green}#1# in #2#{} chance",
+ "Shop has a free",
+ "{C:red}Rare Joker{}",
+ },
+ },
+ tag_mp_juggle_sandbox = {
+ name = "Juggle Tag",
+ text = {
+ "{C:attention}+#1#{} hand size",
+ "next {C:attention}PvP Blind",
+ },
+ },
+ tag_mp_investment_sandbox = {
+ name = "Investment Tag",
+ text = {
+ "After defeating",
+ "the Boss Blind, gain:",
+ "{C:money}$#1#{} + {C:money}$#2#{} per Ante",
+ "{C:inactive}(Currently {C:money}$#3#{C:inactive})",
+ },
+ },
+ },
+ Joker = {
+ j_mp_seltzer = {
+ name = "Seltzer",
+ text = {
+ "Retrigger all",
+ "cards played for",
+ "the next {C:attention}#1#{} hands",
+ },
+ },
+ j_mp_turtle_bean = {
+ name = "Turtle Bean",
+ text = {
+ "{C:attention}+#1#{} hand size,",
+ "reduces by",
+ "{C:red}#2#{} every round",
+ },
+ },
+ j_mp_idol = {
+ name = "The Idol",
+ text = {
+ "Each played {C:attention}#2#",
+ "of {V:1}#3#{} gives",
+ "{X:mult,C:white} X#1# {} Mult when scored",
+ "{s:0.8}Card changes every round",
+ },
+ },
+ j_mp_ticket = {
+ name = "Golden Ticket",
+ text = {
+ "Played {C:attention}Gold{} cards",
+ "earn {C:money}$#1#{} when scored",
+ },
+ },
+ j_broken = {
+ name = "BROKEN",
+ text = {
+ "This card is either broken or",
+ "not implemented in the current",
+ "version of a mod you are using.",
+ },
+ },
+ j_to_the_moon_mp = {
+ name = "To the Moon",
+ text = {
+ "Earn an extra {C:money}$#1#{} of",
+ "{C:attention}interest{} for every {C:money}$#2#{} you",
+ "have at end of round",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Defensive Joker",
+ text = {
+ "{C:chips}+#1#{} Chips for every {C:red,E:1}life{}",
+ "less than your {X:purple,C:white}Nemesis{}",
+ "{C:inactive}(Currently {C:chips}+#2#{C:inactive} Chips)",
+ "{C:inactive}(Stake-dependent)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Skip-Off",
+ text = {
+ "{C:blue}+#1#{} Hands and {C:red}+#2#{} Discards",
+ "per additional {C:attention}Blind{} skipped",
+ "compared to your {X:purple,C:white}Nemesis{}",
+ "{C:inactive}(Currently {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's Go Gambling",
+ text = {
+ "{C:green}#1# in #2#{} chance for",
+ "{X:mult,C:white}X#3#{} Mult and {C:money}$#4#{}",
+ "{C:green}#5# in #6#{} chance to give",
+ "your {X:purple,C:white}Nemesis{} {C:money}$#7#{} in {C:attention}PvP Blind",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "If you reach a {C:attention}PvP Blind",
+ "within {C:attention}30s{} of your {X:purple,C:white}Nemesis{},",
+ "create a random {C:spectral}Spectral{} card",
+ "{C:inactive}(Must have room)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Conjoined Joker",
+ text = {
+ "While in a {C:attention}PvP Blind{}, gain",
+ "{X:mult,C:white}X#1#{} Mult for every {C:blue}Hand{}",
+ "your {X:purple,C:white}Nemesis{} has left",
+ "{C:inactive}(Max {X:mult,C:white}X#2#{C:inactive} Mult, Currently {X:mult,C:white}X#3#{C:inactive} Mult)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Penny Pincher",
+ text = {
+ "At end of round, earn {C:money}$#1#{} for",
+ "every {C:money}$#2#{} your {X:purple,C:white}Nemesis{} spent",
+ "in corresponding shop {C:attention}last ante{}",
+ },
+ },
+ j_mp_taxes = {
+ name = "Taxes",
+ text = {
+ "Gains {C:mult}+#1#{} Mult for every card your",
+ "{X:purple,C:white}Nemesis{} {C:attention}sold{} since last {C:attention}PvP Blind{},",
+ "updates when {C:attention}PvP Blind{} is selected",
+ "{C:inactive}(Currently {C:mult}+#2#{C:inactive} Mult)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "At the end of the next {C:attention}PvP Blind{},",
+ "consume this Joker and grant",
+ "{C:red}+#1#{} discards to you and",
+ "{C:red}+#2#{} discards to your {X:purple,C:white}Nemesis{} for the ante",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifist",
+ text = {
+ "{X:mult,C:white}X#1#{} Mult while",
+ "not in a {C:attention}PvP Blind{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Hanging Chad",
+ text = {
+ "Retrigger {C:attention}first{} and {C:attention}second{}",
+ "played card used in scoring",
+ "{C:attention}#1#{} additional time",
+ },
+ },
+ j_mp_bloodstone = {
+ name = "Bloodstone",
+ text = {
+ "{C:green}#1# in #2#{} chance for",
+ "played cards with",
+ "{C:hearts}Heart{} suit to give",
+ "{X:mult,C:white} X#3# {} Mult when scored",
+ },
+ },
+ j_mp_magnet_sandbox = {
+ name = "Magnet",
+ text = {
+ "After {C:attention}#1#{} rounds, sell",
+ "this card to {C:attention}Copy{} your {X:purple,C:white}Nemesis'{}",
+ "highest sell cost {C:attention}Joker{}",
+ "polarity inverts after {C:attention}#3#{} rounds",
+ "BECOMING WORTHLESS SCRAP METAL!!!!",
+ "{C:inactive}(Currently {C:attention}#2#{C:inactive}/#1# rounds)",
+ },
+ },
+ j_mp_cloud_9_sandbox = {
+ name = "Cloud 9",
+ text = {
+ "NUMERAL MONOCULTURE FARMER",
+ "converting your DIVERSE DECK into",
+ "PROFITABLE NINE PLANTATION!!!!",
+ "{C:inactive}({C:green}#1# in #2#{} {C:inactive}chance, currently {C:money}$#3#{}{C:inactive})",
+ },
+ },
+ j_mp_lucky_cat_sandbox = {
+ name = "Lucky Cat",
+ text = {
+ "FORTUNE-TO-FRAGILITY PIPELINE OPERATOR",
+ "lucky cats become GLASS CATS",
+ "with EXPONENTIAL POWER!!!!",
+ "{C:inactive}(Currently {X:mult,C:white} X#2# {C:inactive} Mult)",
+ },
+ },
+ j_mp_constellation_sandbox = {
+ name = "Constellation",
+ text = {
+ "planet maintenance anxiety disorder",
+ "MUST FEED THE TAMAGOCHI",
+ "or it WITHERS AWAY!!!!",
+ "{C:inactive}(Currently {X:mult,C:white} X#1# {C:inactive} Mult)",
+ },
+ },
+ j_mp_bloodstone_sandbox = {
+ name = "Bloodstone",
+ text = {
+ "{V:1}PATCH NOTE REGRESSION SYNDROME",
+ "reverting to LAUNCH DAY TRAUMA",
+ "for NOSTALGIC {X:mult,C:white}X#3#{} POWER SPIKES!!!!",
+ "{C:inactive}({C:green}#1# in #2#{} {C:inactive}chance)",
+ },
+ },
+ j_mp_juggler_sandbox = {
+ name = "Juggler",
+ text = {
+ "HAND SIZE PERFECTIONIST",
+ "who must keep ALL THE CARDS",
+ "in the air AT ALL TIMES!!!!",
+ "{C:inactive}(Currently {C:attention}+#1#{C:inactive} hand size)",
+ },
+ },
+ j_mp_mail_sandbox = {
+ name = "Mail-in Rebate",
+ text = {
+ "Earn {C:money}$#1#{} for each",
+ "discarded {C:attention}#2#{}",
+ "{s:0.8}Rank never changes",
+ },
+ },
+ j_mp_hit_the_road_sandbox = {
+ name = "Hit the Road",
+ text = {
+ "This Joker gains {X:mult,C:white}X0.75{} Mult",
+ "for every {C:attention}Jack{} discarded",
+ "Discarded Jacks are {C:attention}destroyed{}",
+ "{C:inactive}(Currently {X:mult,C:white} X#2# {C:inactive} Mult)",
+ },
+ },
+ j_mp_misprint_sandbox = {
+ name = "Misprint",
+ text = {
+ "{V:1}#1#{} Mult",
+ "{C:attention}Value revealed on purchase{}",
+ "{C:green}Printing errors compound{}",
+ },
+ },
+ j_mp_castle_sandbox = {
+ name = "Castle",
+ text = {
+ "This Joker gains {C:chips}#3{} Chips",
+ "per discarded {V:1}#1#{}",
+ "Suit locked on purchase",
+ "{C:inactive}(Currently {C:chips}+#2#{C:inactive} Chips)",
+ },
+ },
+ j_mp_runner_sandbox = {
+ name = "Runner",
+ text = {
+ "SEQUENTIAL CARD SUPREMACIST",
+ "who believes ALL other",
+ "POKER HANDS are INFERIOR!!!!",
+ "{C:inactive}(Currently {C:chips}+#1#{C:inactive})",
+ },
+ },
+ j_mp_order_sandbox = {
+ name = "The Order",
+ text = {
+ "{X:mult,C:white}X3{} Mult if played hand contains a {C:attention}Straight{}",
+ "Gains {X:mult,C:white}X#1#{} Mult for each consecutive {C:attention}Straight{} played",
+ "Resets when any other hand is played",
+ "{C:inactive}(Currently {X:mult,C:white}X#2#{C:inactive} Mult)",
+ },
+ },
+ j_mp_photograph_sandbox = {
+ name = "Photograph",
+ text = {
+ "SINGLE SHOT PHOTOGRAPHER who gets",
+ "ONE PERFECT FRAME PER HAND!!!!",
+ },
+ },
+ j_mp_ride_the_bus_sandbox = {
+ name = "Ride the Bus",
+ text = {
+ "FACE CARD SOBRIETY PROGRAM",
+ "ONE FACE CARD and you're",
+ "KICKED OFF THE BUS!!!!",
+ "{C:inactive}(Currently {C:mult}+#1#{C:inactive} Mult)",
+ },
+ },
+ j_mp_loyalty_card_sandbox = {
+ name = "Loyalty Card",
+ text = {
+ "{X:mult,C:white}X6{} Mult every {C:attention}#3#{}",
+ "hands played of {C:attention}#1#{}",
+ "{C:inactive}(#2#/#3#)",
+ },
+ },
+ j_mp_faceless_sandbox = {
+ name = "Faceless Joker",
+ text = {
+ "ELITE FACE CARD SOMMELIER",
+ "who curates artisanal",
+ "THREE-VARIETY TASTING FLIGHTS",
+ "for PREMIUM DISPOSAL EXPERIENCES!!!!",
+ },
+ },
+ j_mp_square_sandbox = {
+ name = "Square Joker",
+ text = {
+ "This Joker gains {C:chips}+#2#{} Chips",
+ "if played hand has",
+ "exactly {C:attention}4{} cards",
+ "{C:attention}Only applies with 4-card hands{}",
+ "{C:inactive}(Currently {C:chips}+#1#{C:inactive} Chips)",
+ },
+ },
+ j_mp_throwback_sandbox = {
+ name = "Throwback",
+ text = {
+ "{X:mult,C:white}X#2#{} Base Mult for each",
+ "{C:attention}Blind{} skipped this run",
+ "{X:mult,C:white}X#3#{} Mult next Blind after skipping",
+ "Loses {X:mult,C:white}X#4#{} when Blind not skipped",
+ "{C:inactive}(Currently {X:mult,C:white} X#1# {C:inactive} Mult)",
+ },
+ },
+ j_mp_vampire_sandbox = {
+ name = "Vampire",
+ text = {
+ "This Joker gains {X:mult,C:white}X#1#{} Mult per",
+ "scoring {C:attention}Enhanced card{} played",
+ "Played enhanced cards become {C:attention}Stone{}",
+ "Stone cards give {C:money}$#3#{} when played",
+ "{C:inactive}(Currently {X:mult,C:white} X#2# {C:inactive} Mult)",
+ },
+ },
+ j_mp_baseball_sandbox = {
+ name = "Baseball Card",
+ text = {
+ "{C:green}Uncommon{} Jokers",
+ "each give",
+ "{X:mult,C:white}X#1#{} Mult",
+ },
+ },
+ j_mp_steel_joker_sandbox = {
+ name = "Steel Joker",
+ text = {
+ "Played Steel cards",
+ "are {C:attention}retriggered{}",
+ },
+ },
+ j_mp_golden_ticket_sandbox = {
+ name = "Golden Ticket",
+ text = {
+ "{C:green}#2# in #3#{} chance for",
+ "{C:attention}Gold{} cards to earn",
+ "{C:money}$#1#{} when played",
+ },
+ },
+ j_mp_satellite_sandbox = {
+ name = "Satellite",
+ text = {
+ "chronic satellite degradation anxiety",
+ "INFRASTRUCTURE SLOWLY FALLS APART",
+ "WITHOUT CONSTANT PLANETARY UPGRADES!!!!",
+ "{C:inactive}(Currently {C:money}$#1#{C:inactive})",
+ },
+ },
+ j_mp_idol_sandbox_zealot = {
+ name = "Zealot Idol",
+ text = {
+ "Each played {C:attention}#1#{}",
+ "gives {X:mult,C:white}X#2#{} Mult",
+ "when scored",
+ "{s:0.8}Card changes every round",
+ },
+ },
+ j_mp_idol_sandbox_collector = {
+ name = "Collector's Idol",
+ text = {
+ "Most common card gives",
+ "{X:mult,C:white}X#3#{} Mult when scored",
+ "({X:mult,C:white}+X#4#{} per copy in deck)",
+ "{C:inactive}(Currently {C:attention}#1#{} of {V:1}#2#{})",
+ },
+ },
+ j_mp_error_sandbox = {
+ name = "????",
+ text = {
+ "{X:purple,C:white,s:0.85}something's{} {X:purple,C:white,s:0.85}wrong",
+ },
+ },
+ j_mp_clowncollege_sandbox = {
+ name = "Clown College",
+ text = {
+ "{C:attention}Fill{} consumable slots with",
+ "{C:tarot}The Fool{} after",
+ "{C:attention}Boss Blind{} is defeated",
+ "{C:inactive}(Must have room)",
+ },
+ },
+ j_mp_alloy_sandbox = {
+ name = "Alloy",
+ text = {
+ "{C:attention}Gold Cards{} are also",
+ "considered {C:attention}Steel Cards{}",
+ "{C:attention}Steel Cards{} are also",
+ "considered {C:attention}Gold Cards{}",
+ },
+ },
+ j_mp_ambrosia_sandbox = {
+ name = "Ambrosia",
+ text = {
+ "{C:attention}Fill{} consumable slots with",
+ "{C:spectral}Spectral Cards{} whenever a",
+ "{C:attention}blind{} is {C:attention}skipped{}, destroyed",
+ "when any {C:spectral}Spectral Card{} is {C:attention}sold",
+ "{C:inactive}(Must have room)",
+ },
+ },
+ j_mp_bobby_sandbox = {
+ name = "Bobby",
+ text = {
+ "When {C:attention}Blind{} is selected,",
+ "lose {C:attention}#1#{} Hands and gain",
+ "{C:red}+#1#{} Discards for each Hand lost",
+ },
+ },
+ j_mp_candynecklace_sandbox = {
+ name = "Candy Necklace",
+ text = {
+ "At end of {C:attention}shop{}, create",
+ "a random {C:attention}Booster Pack Tag",
+ "{C:inactive}(#1# uses left){C:inactive}",
+ },
+ },
+ j_mp_chainlightning_sandbox = {
+ name = "Chain Lightning",
+ text = {
+ "Played {C:attention}Mult Cards{} give",
+ "{X:mult,C:white}X#1#{} Mult when scored,",
+ "then increase this by {X:mult,C:white}X#2#",
+ "{C:inactive}(Resets each hand)",
+ },
+ },
+ j_mp_clowncar_sandbox = {
+ name = "Clown Car",
+ text = {
+ "{C:mult}+#1#{} Mult and {C:money}-$#2#",
+ "{C:attention}before{} cards are scored",
+ },
+ },
+ j_mp_couponsheet_sandbox = {
+ name = "Coupon Sheet",
+ text = {
+ "Create a {C:attention}Coupon Tag",
+ "and a {C:attention}Voucher Tag",
+ "after {C:attention}Boss Blind{} is defeated",
+ },
+ },
+ j_mp_doublerainbow_sandbox = {
+ name = "Double Rainbow",
+ text = {
+ "{C:attention}Retrigger{} all {C:attention}Lucky Cards{}",
+ },
+ },
+ j_mp_espresso_sandbox = {
+ name = "Espresso",
+ text = {
+ "Gain {C:money}$#1#{} and destroy this",
+ "card when {C:attention}Blind{} is skipped",
+ "Decreases by {C:money}$#2#{} at end of round",
+ },
+ },
+ j_mp_farmer_sandbox = {
+ name = "Farmer",
+ text = {
+ "Cards with {V:1}#2#{} suit",
+ "held in hand give {C:money}$#1#",
+ "at end of round",
+ "{s:0.8}suit changes at end of round",
+ },
+ },
+ j_mp_forklift_sandbox = {
+ name = "Forklift",
+ text = {
+ "{C:attention}+#1#{} Consumable Slots",
+ },
+ },
+ j_mp_gofish_sandbox = {
+ name = "Go Fish",
+ text = {
+ "The {C:attention}first time{} that a",
+ "{C:attention}played hand{} contains any",
+ "scoring {C:attention}#1#s{}, destroy them",
+ "{s:0.8}rank changes at end of round",
+ },
+ },
+ j_mp_hoarder_sandbox = {
+ name = "Hoarder",
+ text = {
+ "This Joker gains {C:money}$#1#{} of sell value",
+ "whenever {C:money}money{} is earned",
+ },
+ },
+ j_mp_jokalisa_sandbox = {
+ name = "Joka Lisa",
+ text = {
+ "Gains {X:mult,C:white}X#2#{} Mult for",
+ "each {C:attention}unique enhancement",
+ "in scoring hand",
+ "{C:inactive}(Currently {X:mult,C:white}X#1#{C:inactive})",
+ },
+ },
+ j_mp_jokeroftheyear_sandbox = {
+ name = "Joker of the Year",
+ text = {
+ "If played hand has",
+ "{C:attention}5{} scoring cards,",
+ "{C:attention}retrigger{} played cards",
+ },
+ },
+ j_mp_lucky7_sandbox = {
+ name = "Lucky 7",
+ text = {
+ "If played hand contains",
+ "a scoring {C:attention}7{}, all played",
+ "cards count as {C:attention}Lucky Cards",
+ },
+ },
+ j_mp_montehaul_sandbox = {
+ name = "Monte Haul",
+ text = {
+ "After {C:attention}1 round{}, sell this card",
+ "to gain {C:attention}2{} random {C:attention}Joker Tags",
+ "{C:inactive}(Currently {C:attention}#1#{C:inactive} rounds)",
+ },
+ },
+ j_mp_pocketaces_sandbox = {
+ name = "Pocket Aces",
+ text = {
+ "Earn {C:money}$#1#{} at end of round",
+ "Played {C:attention}Aces{} increase payout",
+ "by {C:money}$#2#{}, resets each {C:attention}Ante",
+ },
+ },
+ j_mp_pyromancer_sandbox = {
+ name = "Pyromancer",
+ text = {
+ "{C:mult}+#1#{} Mult if",
+ "remaining {C:attention}Hands{} are less",
+ "than or equal to {C:attention}Discards",
+ },
+ },
+ j_mp_shipoftheseus_sandbox = {
+ name = "Ship of Theseus",
+ text = {
+ "Whenever a {C:attention}Playing Card{} is {C:attention}destroyed",
+ "add a {C:attention}copy{} of it to your {C:attention}deck",
+ "and this joker gains {X:mult,C:white}X#2#{} Mult",
+ "{C:inactive}(Currently {X:mult,C:white}X#1#{C:inactive} Mult)",
+ },
+ },
+ j_mp_starfruit_sandbox = {
+ name = "Starfruit",
+ text = {
+ "{C:attention}First played hand{} each round",
+ "has a {C:green}#2# in #3#{} chance",
+ "to gain {C:attention}1{} level",
+ "{C:inactive}({}{C:attention}#1#{}{C:inactive} rounds remaining)",
+ },
+ },
+ j_mp_trafficlight_sandbox = {
+ name = "Traffic Light",
+ text = {
+ "{X:mult,C:white}X#1#{} Mult",
+ "Decreases by {X:mult,C:white}X#2#{} after",
+ "each hand, resets after {X:mult,C:white}X0.5",
+ },
+ },
+ j_mp_tuxedo_sandbox = {
+ name = "Tuxedo",
+ text = {
+ "{C:attention}Retrigger{} all cards",
+ "with {V:1}#1#{} suit",
+ "{s:0.8}suit changes at end of round",
+ },
+ },
+ j_mp_warlock_sandbox = {
+ name = "Warlock",
+ text = {
+ "{C:green}#1# in #2#{} chance for played",
+ "{C:attention}Lucky Cards{} to be {C:red}destroyed",
+ "and spawn a {C:spectral}Spectral Card",
+ "{C:inactive}(Must have room)",
+ },
+ },
+ j_mp_werewolf_sandbox = {
+ name = "Werewolf",
+ text = {
+ "Played cards that are",
+ "{C:attention}enhanced{} become {C:attention}Wild Cards",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroid",
+ text = {
+ "Remove #1# level from",
+ "your {X:purple,C:white}Nemesis'{}",
+ "highest level {C:legendary,E:1}poker hand{}",
+ "at start of {C:attention}PvP Blind{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Your Nemesis",
+ text = {
+ "Face another player,",
+ "most chips wins",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Phantom",
+ text = {
+ "{C:attention}Eternal{} and {C:dark_edition}Negative{}",
+ "Created and destroyed by your {X:purple,C:white}Nemesis{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Glass Card",
+ text = {
+ "{X:mult,C:white} X#1# {} Mult",
+ "{C:green}#2# in #3#{} chance to",
+ "destroy card",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Glass Card",
+ text = {
+ "{X:mult,C:white} X#1# {} Mult",
+ "{C:green}#2# in #3#{} chance to",
+ "destroy card",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Cocktail Deck",
+ text = {
+ "Copies all effects",
+ "of {C:attention}3{} other decks",
+ "at random",
+ },
+ },
+ b_mp_gradient = {
+ name = "Gradient Deck",
+ text = {
+ "Cards are also considered",
+ "one rank {C:attention}higher{} or {C:attention}lower",
+ "for all {C:attention}Joker{} effects",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "Heidelberg Deck",
+ text = {
+ "Creates a {C:dark_edition}Negative{} copy of",
+ "{C:attention}1{} random {C:attention}consumable{}",
+ "card in your possession",
+ "at the end of the {C:attention}shop",
+ },
+ },
+ b_mp_indigo = {
+ name = "Indigo Deck",
+ text = {
+ "Choose {C:attention}+1{} additional card",
+ "from all Booster Packs",
+ "Booster Packs are {C:attention}unskippable{}",
+ },
+ },
+ b_mp_oracle = {
+ name = "Oracle Deck",
+ text = {
+ "Start run with {C:spectral,T:c_medium}Medium",
+ "and {C:attention,T:v_clearance_sale}Clearance Sale",
+ "Balance is capped at",
+ "{C:money}$50{} + {C:attention}current interest cap{}",
+ },
+ },
+ b_mp_orange = {
+ name = "Orange Deck",
+ text = {
+ "Start run with a",
+ "{C:attention,T:p_mp_standard_giga}Giga Standard Pack{}, and",
+ "{C:attention}2{} copies of {C:tarot,T:c_hanged_man}The Hanged Man",
+ },
+ },
+ b_mp_violet = {
+ name = "Violet Deck",
+ text = {
+ "{C:attention}+1{} Voucher in shop",
+ "Vouchers are {C:attention}50%{} off ",
+ "during Ante {C:attention}1{}, and {C:attention}30%{} off",
+ "during Ante {C:attention}2",
+ },
+ },
+ b_mp_white = {
+ name = "White Deck",
+ text = {
+ "View {X:purple,C:white}Nemesis'{} current",
+ "deck and Joker setup",
+ "{C:inactive}(Updates at PvP blind){}",
+ },
+ },
+ },
+ Other = {
+ mp_sticker_extra_credit = {
+ name = "Extra Credit",
+ text = {
+ "Made with friends from",
+ "Balatro University!",
+ },
+ },
+ current_nemesis = {
+ name = "Nemesis",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Your one and only Nemesis",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Giga Standard Pack",
+ text = {
+ "Choose {C:attention}#1#{} of up to",
+ "{C:attention}#2#{C:attention} Playing{} cards to",
+ "add to your deck",
+ "{C:attention}Unskippable{}",
+ },
+ },
+ mp_transmutations = {
+ name = "Transmutations",
+ text = {
+ "{C:purple,s:1.1}Will transmute into:",
+ },
+ },
+ mp_internal_sell_value = {
+ name = "Sell Value",
+ text = {
+ "{C:money,s:1.3}$#1#",
+ },
+ },
+ mp_sticker_persistent = {
+ name = "Persistent",
+ text = {
+ "Can't be destroyed",
+ "Costs {C:red}${} to sell",
+ "Cost increases by",
+ "{C:red}$3{} at end of round",
+ },
+ },
+ mp_sticker_unreliable = {
+ name = "Unreliable",
+ text = {
+ "Doesn't trigger on",
+ "{C:attention}final hand{}",
+ },
+ },
+ mp_sticker_draining = {
+ name = "Draining",
+ text = {
+ "{X:mult,C:white}X0.75{} Mult",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "Planet Stake",
+ text = {
+ "Applies {C:black}Black Stake{} effects, plus:",
+ "Shop can have {C:attention}Perishable{} Jokers",
+ "{C:inactive,s:0.8}(Debuffed after 5 Rounds)",
+ "Required score scales",
+ "faster for each {C:attention}Ante",
+ },
+ },
+ stake_mp_spectral = {
+ name = "Spectral Stake",
+ text = {
+ "Applies {C:planet}Planet Stake{} effects, plus:",
+ "{C:money}Rental{} Jokers appear in shop",
+ "Required score scales",
+ "faster for each {C:attention}Ante",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "Spectral+ Stake",
+ text = {
+ "Applies {C:planet}Spectral Stake{} effects, plus:",
+ "Required score scales",
+ "even faster for each {C:attention}Ante",
+ },
+ },
+ stake_mp_plastic = {
+ name = "Plastic Stake",
+ text = {
+ "Earn {C:money}$1{} of interest per {C:money}$10{}",
+ "{C:inactive,s:0.8}(Max of {C:money,s:0.8}$50{C:inactive,s:0.8})",
+ "{s:0.8}Applies White Stake",
+ },
+ },
+ stake_mp_pebble = {
+ name = "Pebble Stake",
+ text = {
+ "Required score scales",
+ "faster for each {C:attention}Ante",
+ "{s:0.8}Applies Plastic Stake",
+ },
+ },
+ stake_mp_ferrite = {
+ name = "Ferrite Stake",
+ text = {
+ "Specific Jokers are {C:attention}Persistent",
+ "{C:inactive,s:0.8}(Can't be destroyed, increasing sell cost)",
+ "{s:0.8}Applies Pebble Stake",
+ },
+ },
+ stake_mp_pyrite = {
+ name = "Pyrite Stake",
+ text = {
+ "Reroll price increases",
+ "by {C:money}$2{} each reroll",
+ "{s:0.8}Applies Ferrite Stake",
+ },
+ },
+ stake_mp_jade = {
+ name = "Jade Stake",
+ text = {
+ "Required score scales",
+ "faster for each {C:attention}Ante",
+ "{s:0.8}Applies Pyrite Stake",
+ },
+ },
+ stake_mp_crystal = {
+ name = "Crystal Stake",
+ text = {
+ "Specific Jokers are {C:attention}Unreliable",
+ "{C:inactive,s:0.8}(Doesn't trigger on {C:attention,s:0.8}final hand{C:inactive,s:0.8})",
+ "{s:0.8}Applies Jade Stake",
+ },
+ },
+ stake_mp_antimatter = {
+ name = "Antimatter Stake",
+ text = {
+ "Specific Jokers are {C:attention}Draining",
+ "{C:inactive,s:0.8}({X:mult,C:white,s:0.8} X0.75 {C:inactive,s:0.8} Mult)",
+ "{s:0.8}Applies Crystal Stake",
+ },
+ },
+ },
+ Spectral = {
+ c_mp_ouija_standard = {
+ name = "Ouija",
+ text = {
+ "Destroy {C:attention}#1#{} random cards,",
+ "then convert all remaining",
+ "cards to a single random {C:attention}rank",
+ },
+ },
+ 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 = {
+ mp_phantom = "Phantom",
+ mp_sticker_extra_credit = "Extra Credit",
+ mp_sticker_persistent = "Persistent",
+ mp_sticker_unreliable = "Unreliable",
+ mp_sticker_draining = "Draining",
+ },
+ dictionary = {
+ b_singleplayer = "Singleplayer",
+ b_sp_with_ruleset = "Practice Mode",
+ b_join_lobby = "Join Lobby",
+ b_join_lobby_clipboard = "Join From Clipboard",
+ b_return_lobby = "Return to Lobby",
+ b_reconnect = "Reconnect",
+ b_create_lobby = "Create Lobby",
+ b_start_lobby = "Start Lobby",
+ b_ready = "Ready",
+ b_unready = "Unready",
+ b_leave_lobby = "Leave Lobby",
+ b_mp_discord = "Balatro Multiplayer Discord Server",
+ b_start = "START",
+ b_wait_for_host_start = {
+ "WAITING FOR",
+ "HOST TO START",
+ },
+ b_wait_for_players = {
+ "WAITING FOR",
+ "PLAYERS",
+ },
+ b_wait_for_guest_ready = {
+ "WAITING FOR",
+ "GUEST TO READY UP",
+ },
+ b_lobby_options = "LOBBY OPTIONS",
+ b_copy_clipboard = "Copy to clipboard",
+ b_view_code = "VIEW CODE",
+ b_copy_code = "COPY CODE",
+ b_leave = "LEAVE",
+ b_opts_cb_money = "Give comeback $ on life loss",
+ b_opts_no_gold_on_loss = "Don't get blind rewards on round loss",
+ b_opts_death_on_loss = "Lose a life on non-PvP round loss",
+ b_opts_start_antes = "Starting Antes",
+ b_opts_diff_seeds = "Players have different seeds",
+ b_opts_lives = "Lives",
+ b_opts_multiplayer_jokers = "Enable Multiplayer Cards",
+ b_opts_player_diff_deck = "Players have different decks",
+ b_opts_normal_bosses = "Enable Boss Blind effects",
+ b_opts_timer = "Enable Timer",
+ b_opts_disable_preview = "Disable Score Preview",
+ b_opts_the_order = "Enable The Order",
+ b_opts_legacy_smallworld = "Legacy Small World mechanics",
+ b_reset = "Reset",
+ b_set_custom_seed = "Set Custom Seed",
+ b_mp_kofi_button = "Supporting me on Ko-fi",
+ b_unstuck = "Unstuck",
+ b_unstuck_blind = "Stuck Outside PvP",
+ b_misprint_display = "Display the next card in the deck",
+ b_players = "Players",
+ b_lobby_info = "Lobby Info",
+ b_continue_singleplayer = "Continue in Singleplayer",
+ b_the_order_integration = "Enable The Order Integration",
+ b_preview_integration = "Enable Score Preview",
+ b_view_nemesis_deck = "View Decks",
+ b_toggle_jokers = "Toggle Jokers",
+ b_skip_tutorial = "Skip Tutorial",
+ k_yes = "Yes",
+ k_no = "No",
+ k_are_you_sure = "Are you sure?",
+ k_has_multiplayer_content = "Has Multiplayer Content",
+ k_forces_lobby_options = "Forces Lobby Options",
+ k_forces_gamemode = "Forces Gamemode",
+ k_values_are_modifiable = "* Values are modifiable",
+ k_rulesets = "Rulesets",
+ k_gamemodes = "Gamemodes",
+ k_matchmaking = "Matchmaking",
+ k_tournament = "Tournament",
+ k_custom = "Custom",
+ k_other = "Other",
+ k_battle = "Battle",
+ k_challenge = "Challenge",
+ k_info = "Info",
+ k_continue_singleplayer_tooltip = "This will overwrite your current singleplayer run",
+ k_enemy_score = "Current Enemy score",
+ k_enemy_hands = "Enemy hands left: ",
+ k_coming_soon = "Coming Soon!",
+ k_wait_enemy = "Waiting for enemy to finish...",
+ k_wait_enemy_reach_this_blind = "Waiting for enemy to reach this blind...",
+ k_lives = "Lives",
+ k_lost_life = "Lost a life",
+ k_total_lives_lost = " Total Lives Lost",
+ k_comeback_money_sandbox = " Comeback Money ($3 × ante cleared)",
+ k_attrition_name = "Attrition",
+ k_enter_lobby_code = "Enter Lobby Code",
+ k_paste = "Paste From Clipboard",
+ k_username = "Username:",
+ k_enter_username = "Enter username",
+ k_customize_preview = "Customize Preview Text:",
+ k_join_discord = "Join the ",
+ k_discord_msg = "You can report any bugs and find players to play there",
+ k_enter_to_save = "Press enter to save",
+ k_in_lobby = "In the lobby",
+ k_connected = "Connected to Service",
+ k_warn_service = "WARN: Cannot Find Multiplayer Service",
+ k_set_name = "Set your username in the main menu! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "Players have different mods or mod versions! This can cause problems!",
+ k_steamodded_warning = "Players have different versions of Steamodded installed. This may cause the seeds to differ.",
+ k_warning_unlock_profile = "The profile you are playing on is not fully unlocked. If this is a ranked/tournament game, please create a new profile and hit unlock all in the profile settings",
+ k_warning_nemesis_unlock = "Your opponent is playing on a profile that is not fully unlocked. Please instruct them to create a new profile and hit unlock all in the profile settings",
+ k_warning_no_order = "One player has The Order integration enabled while the other does not. This will cause the seeds to differ.",
+ k_warning_cheating1 = "If you are seeing this, your opponent may be cheating.",
+ k_warning_cheating2 = "If this is a ranked game, please send the message '%s' and then open a support ticket in #support",
+ k_warning_banned_mods = "One or more players have banned mods installed. These mods are not allowed in ranked games.",
+ k_message1 = "Hold on, my mom made pizza pops",
+ k_message2 = "One sec, i gotta grab my slow cooker pork roast",
+ k_message3 = "One moment, getting a call from my mom",
+ k_message4 = "Brb, my cat is on fire",
+ k_message5 = "Wait, I think I left the stove on",
+ k_message6 = "Hold up, my pet rock just ran away",
+ k_message7 = "One sec, my plants are asking for water",
+ k_message8 = "Brb, my socks are plotting against me",
+ k_message9 = "Sorry, my WiFi is having an existential crisis",
+ k_lobby_options = "Lobby Options",
+ k_connect_player = "Connected Players:",
+ k_opts_only_host = "Only the Lobby Host can change these options",
+ k_lobby_general = "General",
+ k_lobby_gameplay = "Gameplay",
+ k_lobby_modifiers = "Modifiers",
+ k_lobby_advanced = "Advanced",
+ k_opts_pvp_start_round = "PVP Starts at Ante",
+ k_opts_pvp_timer = "Timer",
+ k_opts_showdown_starting_antes = "Showdown Starts at Ante",
+ k_opts_pvp_timer_increment = "Timer Increment",
+ k_opts_pvp_countdown_seconds = "PvP Countdown Seconds",
+ k_bl_life = "Life",
+ k_bl_or = "or",
+ k_bl_death = "Death",
+ k_bl_mostchips = "Most chips wins",
+ k_current_seed = "Current seed: ",
+ k_random = "Random",
+ k_standard = "Standard",
+ k_sandbox = "Sandbox: Extra Credit",
+ k_sandbox_description = "26 new jokers from Extra Credit join the roster.\nIdol splits into two: Zealot and Collector's. You pick one, the other's gone.\nNew Spectrals, reworked comeback gold, no score preview.\nThe meta's wide open. Built with friends at Balatro University.\n",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "The original Balatro experience.\n\nNo Multiplayer jokers, no balance changes.\nJust the base game as it was designed.\n\nMultiplayer features like the timer are still available\nbut can be disabled in Lobby Options.",
+ k_blitz = "Standard",
+ k_blitz_description = "The balanced Multiplayer ruleset.\n\nIncludes Multiplayer jokers and balance changes\nwith full control over your lobby settings.\n\n(See bans and reworks tabs for details)",
+ k_traditional = "Traditional",
+ k_traditional_description = "Multiplayer content without time pressure.\n\nIncludes Multiplayer jokers and balance changes,\nbut removes time-based mechanics for methodical play.\n\nTime-based jokers are banned.\nTimer is disabled.\n\n(See bans and reworks tabs for details)",
+ k_majorleague = "Major League",
+ 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_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",
+ k_legacy_ranked_description = "A minimal competitive ruleset.\n\nNo Multiplayer cards or balance changes\nexcept Glass. Has locked settings:\n- Attrition gamemode\n- The Order enabled\n- Requires recommended Steamodded version",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "A weekly ruleset designed by @dr_monty_the_snek on the discord server\nthat has been added to the mod permanently.\n\nThis ruleset bans 48 jokers, consumables, tags, etc.",
+ k_attrition = "Attrition",
+ k_attrition_description = "After the first ante, every boss blind is a Nemesis blind. No time to prepare. This gamemode forces you to be battle-ready from the start.",
+ k_showdown = "Showdown",
+ k_showdown_description = "After the first 2 antes, every blind is a Nemesis blind. This gamemode gives you time to prepare before battle.",
+ k_survival = "Survival",
+ k_survival_description = "The player who beats the farthest blind wins. No Nemesis blinds. This gamemode is a test of your ability to gradually build-up to the highest scoring Vanilla hands.",
+ k_weekly = "Weekly",
+ k_weekly_description = "A special ruleset that changes weekly or bi-weekly. I guess you'll have to find out what it is! Currently: ",
+ k_smallworld = "Small World",
+ k_smallworld_description = "It's a small world after all.\n\n75% of jokers, consumables, vouchers, and tags\nare randomly banned each game.\n\nBanned items get replaced with what's available.\nDuplicates allowed.",
+ k_speedlatro = "Speedlatro",
+ k_speedlatro_description = "Up the pace with an uncomfortably fast 147 second timer between\neach PvP blind. Good luck using Vagabond",
+ k_cost_up = "Cost Up",
+ k_destabilized = "Destabilized",
+ k_oops_ex = "Oops!",
+ k_asteroids = "Asteroids",
+ k_amount_short = "Amt.",
+ k_filed_ex = "Filed!",
+ k_timer = "Timer",
+ k_mods_list = "Mods List",
+ k_enemy_jokers = "Enemy Jokers",
+ k_your_jokers = "Your Jokers",
+ k_nemesis_deck = "Nemesis Deck",
+ k_your_deck = "Your Deck",
+ k_customization = "Customization",
+ k_the_order_credit = "*Credit to @MathIsFun_",
+ k_the_order_integration_desc = "This will patch card creation to not be ante-based and use a single pool for every type/rarity",
+ k_preview_credit = "*Credit to @Fantom, @Divvy",
+ k_preview_integration_desc = "This will enable score preview before playing a hand",
+ k_requires_restart = "*Requires a restart to take effect",
+ k_cocktail_select = "Select deck cards to include them",
+ k_cocktail_shiftclick = "Shift-click to foil, foiled decks will always be selected",
+ k_cocktail_rightclick = "Right-click to select all",
+ k_bans = "Bans",
+ k_reworks = "Reworks",
+ k_edit = "Edit",
+ k_ruleset_disabled_the_order_required = "The Order is Required",
+ k_ruleset_disabled_the_order_banned = "The Order is Banned",
+ k_ruleset_not_found = "Unknown ruleset",
+ k_tutorial_not_complete = "You must complete the tutorial before you can play Multiplayer",
+ k_created_by = "Created by",
+ k_major_contributors = "Major contributions by",
+ ml_enemy_loc = {
+ "Enemy",
+ "location",
+ },
+ k_hide_mp_content = "Hide Multiplayer content*",
+ k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets",
+ k_timer_sfx = "Timer Sound Effects",
+ ml_mp_kofi_message = {
+ "This mod and game server is",
+ "developed and maintained by ",
+ "one person, if",
+ "you like it consider",
+ },
+ ml_lobby_info = {
+ "Lobby",
+ "Info",
+ },
+ ml_mp_timersfx_opt = {
+ "On",
+ "Once per Ante",
+ "Off",
+ },
+ loc_ready = "Ready for PvP",
+ loc_selecting = "Selecting a Blind",
+ loc_shop = "Shopping",
+ loc_playing = "Playing ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Art: #1#",
+ },
+ a_mp_code = {
+ "Code: #1#",
+ },
+ a_mp_idea = {
+ "Idea: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Skips Ahead",
+ },
+ a_mp_skips_behind = {
+ "#1# Skips Behind",
+ },
+ a_mp_skips_tied = {
+ "Tied",
+ },
+ k_banned_objs = "Banned #1#",
+ k_no_banned_objs = "No Banned #1#",
+ k_reworked_objs = "Reworked #1#",
+ k_no_reworked_objs = "No Reworked #1#",
+ k_ruleset_disabled_smods_version = "SMODS Version #1# Required",
+ k_ruleset_disabled_lovely_version = "Lovely #1# Required",
+ k_failed_to_join_lobby = "Failed to join lobby: #1#",
+ k_ante_number = "Ante #1#",
+ k_ante_range = "Ante #1#-#2#", -- For example, "Ante 1-2"
+ k_ante_min = "Ante #1#+", -- For example, "Ante 2+"
+ k_credits_list = "#1# and many more!", -- #1# gets replaced with a list of names
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Hanging Chad{} is {C:dark_edition}reworked",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Glass Cards{} are {C:dark_edition}reworked",
+ },
+ ch_c_mp_score_instability = {
+ "Unbalanced score is {C:purple}destabilized{} further:",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(ex: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} -> {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:inactive}Minimum of {C:attention}1 {C:mult}Mult",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:inactive}Minimum of {C:attention}0 {C:chips}Chips",
+ },
+ ch_c_mp_ante_scaling = {
+ "{C:red}X#1#{} base Blind size",
+ },
+ ch_c_mp_no_shop_planets = {
+ "{C:planet}Planets{} no longer appear in the {C:attention}shop",
+ },
+ ch_c_mp_only_medium = {
+ "All {C:spectral}Spectral{} cards are {C:spectral}Mediums{}",
+ },
+ ch_c_mp_only_purple_seals = {
+ "All {C:attention}seals{} are {C:purple}Purple Seals{}",
+ },
+ ch_c_mp_sibyl_CREDITS = {
+ "{C:inactive}(Art by {C:attention}Ganpan14O{C:inactive})",
+ },
+ ch_c_mp_polymorph_spam = {
+ "On selecting blind, all held {C:attention}Jokers{} and {C:attention}Consumables{}",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED1 = {
+ "are transmuted into the {C:attention}N{}th next card in their collection,",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED2 = {
+ "where {C:attention}N{} is its current position in slots",
+ },
+ ch_c_mp_vantablack_CREDITS = {
+ "{C:inactive}(Art by {C:attention}aura!{C:inactive})",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Standard",
+ c_mp_sandbox = "Sandbox",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Tournament",
+ c_mp_weekly = "Weekly",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Misprint Deck",
+ c_mp_legendaries = "Legendaries",
+ c_mp_psychosis = "Psychosis",
+ c_mp_scratch = "From Scratch",
+ c_mp_twin_towers = "Twin Towers",
+ c_mp_in_the_red = "In the Red",
+ c_mp_paper_money = "Paper Money",
+ c_mp_high_hand = "High Hand",
+ c_mp_chore_list = "Chore List",
+ c_mp_oops_all_jokers = "Oops! All Jokers",
+ c_mp_divination = "Divination",
+ c_mp_skip_off = "Skip-Off",
+ c_mp_lets_go_gambling = "Let's Go Gambling",
+ c_mp_speed = "Speed",
+ c_mp_balancing_act = "Balancing Act",
+ c_mp_salvaged_sibyl = "Salvaged Sibyl",
+ c_mp_polymorph_spam = "Polymorph Spam",
+ c_mp_all_must_go = "All Must Go",
+ c_mp_vantablack = "Vantablack",
+ },
+ },
+}
diff --git a/localization/es_419.lua b/localization/es_419.lua
new file mode 100644
index 00000000..49ade3bc
--- /dev/null
+++ b/localization/es_419.lua
@@ -0,0 +1,981 @@
+-- Localization by @themike_71
+-- Corrections and further updates by ElTioRata
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "Etiqueta de apostador",
+ text = {
+ "Prob. de {C:green}#1# en #2#{} de que",
+ "la tienda tenga un",
+ "{C:red}Comodín raro{} gratis",
+ },
+ },
+ tag_mp_juggle_sandbox = {
+ name = "Etiqueta de malabares",
+ text = {
+ "{C:attention}+#1#{} tamaño de mano",
+ "en la siguiente {C:attention}Ciega JcJ{}",
+ },
+ },
+ tag_mp_investment_sandbox = {
+ name = "Etiqueta de inversión",
+ text = {
+ "Después de derrotar",
+ "la Ciega Jefe, gana:",
+ "{C:money}$#1#{} + {C:money}$#2#{} por apuesta",
+ "{C:inactive}(Actualmente {C:money}$#3#{C:inactive})",
+ },
+ },
+ },
+ Joker = {
+ j_broken = {
+ name = "ERROR :(",
+ text = {
+ "Esta carta está rota o no está",
+ "implementada en la versión actual",
+ "de un mod que estás usando.",
+ },
+ },
+ j_to_the_moon = {
+ name = "A la Luna",
+ text = {
+ "Gana {C:money}$#1#{} extra de",
+ "{C:attention}interés{} por cada {C:money}$#2#{} que",
+ "tengas al final de la ronda",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Comodín defensivo", -- themike_71: Personalmente prefiero "Jokers" antes que "Comodines" pero tomaré de base la traducción oficial de Balatro -- Marffe: la verdad me gusta más Comodín
+ text = {
+ "{C:chips}+#1#{} fichas por cada {C:red,E:1}vida{}",
+ "menos que tu {X:purple,C:white}némesis{}",
+ "{C:inactive}(Actualmente {C:chips}+#2#{C:inactive} fichas)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Avioncito", -- themike_71: "Skip-Off" a traducción literal sería "Saltalo"/"Saltado" pero lo cambié acorde al arte de la carta ya que pierde un poco el juego de palabras. | ElTioRata: Skip-Off proviene de "Take-Off" ("despegue", por eso el avión).
+ text = {
+ "{C:blue}+#1#{} manos y {C:red}+#2#{} descartes",
+ "por {C:attention}ciega{} adicional omitida",
+ "en comparación con tu {X:purple,C:white}némesis{}",
+ "{C:inactive}(Actualmente {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's Go Gambling", -- themike_71: La traducción sería "Vamos a apostar" pero lo dejareos así para mantener el meme
+ text = {
+ "Prob. de {C:green}#1# en #2#{} de",
+ "otorgar {X:mult,C:white}X#3#{} multi y {C:money}#4# ${}", -- ElTioRata: Signo de dólar a la derecha para mantener consistencia con localización del juego base
+ "Prob. de {C:green}#5# en #6#{} de dar",
+ "{C:money}#7# $ a tu {X:purple,C:white}némesis{}",
+ },
+ },
+ j_mp_speedrun = {
+ name = "Contrarreloj", -- ElTioRata: Sé que el término queda más largo pero así suelen ponerlo en traducciones oficiales -- Marffe: La RAE sugiera Contrarreloj para este contexto
+ text = {
+ "Si llegas a la {C:attention}ciega JcJ",
+ "antes que tu {X:purple,C:white}némesis{},",
+ "crea un carta {C:spectral}espectral{}",
+ "{C:inactive}(Debe haber espacio)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Comodín siamés",
+ text = {
+ "Mientras estés en una {C:attention}ciega JcJ{},",
+ "ganas {X:mult,C:white}X#1#{} multi por cada {C:blue}mano{}",
+ "que tenga tu {X:purple,C:white}némesis{}",
+ "{C:inactive,s:0.8}(Máximo {X:mult,C:white}X#2#{C:inactive,s:0.8} multi, actualmente {X:mult,C:white,s:0.8}X#3#{C:inactive,s:0.8} multi)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Tacaño",
+ text = {
+ "Al inicio de cada tienda, gana",
+ "{C:money}#1# ${} por cada {C:money}#2# ${} que gastó",
+ "tu {X:purple,C:white}némesis{} en la última tienda",
+ },
+ },
+ j_mp_taxes = {
+ name = "Impuestos", -- themike_71: Pensaba poner SAT pero me aguanté las ganas
+ text = {
+ "Cuando tu {X:purple,C:white}némesis{} vende",
+ "una carta ganas {C:mult}+#1#{} multi",
+ "{C:inactive}(Actualmente {C:mult}+#2#{C:inactive} multi)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Imán",
+ text = {
+ "Después de {C:attention}#1#{} rondas,",
+ "vende esta carta para {C:attention}copiar{}",
+ "el {C:attention}comodín{} con mayor valor de venta",
+ "que tenga tu {X:purple,C:white}némesis{}",
+ "{C:inactive}(Actualmente {C:attention}#2#{C:inactive}/#3# rondas)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza", -- themike_71: Sí, esta carta es una referencia a Breaking Bad
+ text = {
+ "{C:red}+#1#{} descartes para todos los jugadores",
+ "{C:red}-#2#{} descarte cuando un jugador",
+ "selecciona una ciega.",
+ "Se consume cuando tu",
+ "{X:purple,C:white}némesis{} omite una ciega",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Comodín Pacifista", -- Marffe: Si veo la oportunidad de escribir comodín, lo hago xd
+ text = {
+ "{X:mult,C:white}X#1#{} multi mientras no",
+ "estés en una {C:attention}ciega JcJ{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Papel perforado",
+ text = {
+ "Reactiva la {C:attention}primera{} y {C:attention}segunda{}",
+ "carta jugada al anotar",
+ "{C:attention}#1#{} veces adicionales",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Papel perforado",
+ text = {
+ "Reactiva la {C:attention}primera{} y {C:attention}segunda{}",
+ "carta jugada al anotar",
+ "{C:attention}#1#{} veces adicionales",
+ },
+ },
+ j_mp_bloodstone = {
+ name = "Heliotropo",
+ text = {
+ "Prob. de {C:green}#1# en #2#{} de que",
+ "las cartas jugadas de",
+ "{C:hearts}Corazones{} otorguen",
+ "{X:mult,C:white} X#3# {} multi al anotar",
+ },
+ },
+ j_mp_magnet_sandbox = {
+ name = "Imán",
+ text = {
+ "Después de {C:attention}#1#{} rondas, vende",
+ "esta carta para {C:attention}copiar{} el",
+ "{C:attention}comodín{} con mayor valor de venta",
+ "de tu {X:purple,C:white}némesis{}",
+ "la polaridad se invierte tras {C:attention}#3#{} rondas",
+ "VOLVIÉNDOSE CHATARRA INÚTIL!!!!",
+ "{C:inactive}(Actualmente {C:attention}#2#{C:inactive}/#1# rondas)",
+ },
+ },
+ j_mp_cloud_9_sandbox = {
+ name = "La Novena Puerta",
+ text = {
+ "GRANJERO DE MONOCULTIVO NUMÉRICO",
+ "convirtiendo tu BARAJA DIVERSA en",
+ "UNA PLANTACIÓN DE NUEVES RENTABLE!!!!",
+ "{C:inactive}Prob. de ({C:green}#1# en #2#{} {C:inactive}, actualmente {C:money}$#3#{}{C:inactive})",
+ },
+ },
+ j_mp_lucky_cat_sandbox = {
+ name = "Gato de la Suerte",
+ text = {
+ "OPERADOR DEL CICLO FORTUNA-A-FRAGILIDAD",
+ "los gatos de la suerte se vuelven GATOS DE VIDRIO",
+ "con PODER EXPONENCIAL!!!!",
+ "{C:inactive}(Actualmente {X:mult,C:white} X#2# {C:inactive} multi)",
+ },
+ },
+ j_mp_constellation_sandbox = {
+ name = "Constelación",
+ text = {
+ "Trastorno de ansiedad por mantenimiento planetario",
+ "DEBES ALIMENTAR EL TAMAGOCHI",
+ "O SE MARCHITA!!!!",
+ "{C:inactive}(Actualmente {X:mult,C:white} X#1# {C:inactive} multi)",
+ },
+ },
+ j_mp_bloodstone_sandbox = {
+ name = "Heliotropo",
+ text = {
+ "{V:1}SÍNDROME DE REGRESIÓN DE NOTAS DE PARCHE",
+ "volviendo al TRAUMA DEL DÍA DE LANZAMIENTO",
+ "por PICOS DE PODER {X:mult,C:white}X#3#{} NOSTÁLGICOS!!!!",
+ "{C:inactive}({C:green}#1# en #2#{} {C:inactive}prob.)",
+ },
+ },
+ j_mp_juggler_sandbox = {
+ name = "Malabarista",
+ text = {
+ "PERFECCIONISTA DEL TAMAÑO DE MANO",
+ "que debe mantener TODAS LAS CARTAS",
+ "en el aire TODO EL TIEMPO!!!!",
+ "{C:inactive}(Actualmente {C:attention}+#1#{C:inactive} tamaño de mano)",
+ },
+ },
+ j_mp_mail_sandbox = {
+ name = "Reembolso por correo",
+ text = {
+ "Gana {C:money}$#1#{} por cada",
+ "{C:attention}#2#{} descartado",
+ "{s:0.8}El valor nunca cambia",
+ },
+ },
+ j_mp_hit_the_road_sandbox = { -- Nerf a Hit the Roas? xd
+ name = "Al Camino",
+ text = {
+ "Este comodín gana {X:mult,C:white}X0.75{} multi",
+ "por cada {C:attention}J{} descartada",
+ "Las J descartadas se {C:attention}destruyen{}",
+ "{C:inactive}(Actualmente {X:mult,C:white} X#2# {C:inactive} multi)",
+ },
+ },
+ j_mp_misprint_sandbox = {
+ name = "Mala Impresión",
+ text = {
+ "{V:1}#1#{} multi",
+ "{C:attention}Valor revelado al comprar{}",
+ "{C:green}Los errores de impresión se acumulan{}",
+ },
+ },
+ j_mp_castle_sandbox = {
+ name = "Castillo",
+ text = {
+ "Este comodín gana {C:chips}#3{} fichas",
+ "por cada {V:1}#1#{} descartado",
+ "El palo se fija al comprar",
+ "{C:inactive}(Actualmente {C:chips}+#2#{C:inactive} fichas)",
+ },
+ },
+ j_mp_runner_sandbox = {
+ name = "Corredor",
+ text = {
+ "SUPREMACISTA DE CARTAS SECUENCIALES",
+ "cree que TODAS las demás MANOS DE PÓKER",
+ "son INFERIORES!!!!",
+ "{C:inactive}(Hizo una tesis para demostrarlo){}",
+ "{C:inactive}(Actualmente {C:chips}+#1#{C:inactive})",
+ },
+ },
+ j_mp_order_sandbox = {
+ name = "El Orden",
+ text = {
+ "{X:mult,C:white}X3{} multi si la mano jugada contiene una {C:attention}Escalera{}",
+ "Gana {X:mult,C:white}X#1#{} multi por cada {C:attention}Escalera{} consecutiva",
+ "Se reinicia al jugar cualquier otra mano",
+ "{C:inactive}(Actualmente {X:mult,C:white}X#2#{C:inactive} multi)",
+ },
+ },
+ j_mp_photograph_sandbox = {
+ name = "Fotografía",
+ text = {
+ "FOTÓGRAFO DE UNA SOLA TOMA que consigue",
+ "UN ENCUADRE PERFECTO POR MANO!!!!",
+ },
+ },
+ j_mp_ride_the_bus_sandbox = {
+ name = "Ride the Bus",
+ text = {
+ "PROGRAMA DE SOBRIEDAD DE FIGURAS",
+ "UNA FIGURA y te",
+ "BAJAN DEL CAMIÓN!!!!",
+ "{C:inactive}(Actualmente {C:mult}+#1#{C:inactive} multi)",
+ },
+ },
+ j_mp_loyalty_card_sandbox = {
+ name = "Tarjeta de lealtad",
+ text = {
+ "{X:mult,C:white}X6{} multi cada {C:attention}#3#{}",
+ "manos jugadas de {C:attention}#1#{}",
+ "{C:inactive}(#2#/#3#)",
+ },
+ },
+ j_mp_faceless_sandbox = {
+ name = "Comodín sin rostro",
+ text = {
+ "SOMMELIER ÉLITE DE FIGURAS",
+ "que prepara muestras artesanales",
+ "DE TRES VARIEDADES",
+ "para DESCARTES PREMIUM!!!!",
+ },
+ },
+ j_mp_square_sandbox = {
+ name = "Comodín cuadrado",
+ text = {
+ "Este comodín gana {C:chips}+#2#{} fichas",
+ "si la mano jugada tiene",
+ "exactamente {C:attention}4{} cartas",
+ "{C:attention}Solo aplica con manos de 4 cartas{}",
+ "{C:inactive}(Actualmente {C:chips}+#1#{C:inactive} fichas)",
+ },
+ },
+ j_mp_throwback_sandbox = {
+ name = "Retro",
+ text = {
+ "{X:mult,C:white}X#2#{} multi base por cada",
+ "{C:attention}Ciega{} omitida en esta partida",
+ "{X:mult,C:white}X#3#{} multi en la siguiente ciega tras omitir",
+ "Pierde {X:mult,C:white}X#4#{} si no se omite la ciega",
+ "{C:inactive}(Actualmente {X:mult,C:white} X#1# {C:inactive} multi)",
+ },
+ },
+ j_mp_vampire_sandbox = {
+ name = "Vampiro",
+ text = {
+ "Este comodín gana {X:mult,C:white}X#1#{} multi",
+ "por cada {C:attention}carta mejorada{} anotada",
+ "Las cartas mejoradas jugadas se vuelven {C:attention}Piedra{}",
+ "Las cartas de piedra dan {C:money}$#3#{} al jugarse",
+ "{C:inactive}(Actualmente {X:mult,C:white} X#2# {C:inactive} multi)",
+ },
+ },
+ j_mp_baseball_sandbox = {
+ name = "Carta de béisbol",
+ text = {
+ "Los comodines {C:green}Inusuales{}",
+ "otorgan {X:mult,C:white}X#1#{} multi",
+ },
+ },
+ j_mp_steel_joker_sandbox = {
+ name = "Comodín de acero",
+ text = {
+ "Las cartas de acero jugadas",
+ "se {C:attention}reactivan{}",
+ },
+ },
+ j_mp_satellite_sandbox = {
+ name = "Satélite",
+ text = {
+ "ansiedad crónica por degradación de satélites",
+ "LA INFRAESTRUCTURA SE DESMORONA",
+ "SIN MEJORAS PLANETARIAS CONSTANTES!!!!",
+ "{C:inactive}(Actualmente {C:money}$#1#{C:inactive})",
+ },
+ },
+ j_mp_idol_sandbox_zealot = {
+ name = "Ídolo fanático",
+ text = {
+ "Cada {C:attention}#1#{} jugada",
+ "da {X:mult,C:white}X#2#{} multi",
+ "al anotar",
+ "{s:0.8}La carta cambia cada ronda",
+ },
+ },
+ j_mp_idol_sandbox_collector = {
+ name = "Ídolo meta",
+ text = {
+ "La carta más común da",
+ "{X:mult,C:white}X#3#{} Multi al anotar",
+ "({X:mult,C:white}+X#4#{} por copia en la baraja)",
+ "{C:inactive}(Actualmente {C:attention}#1#{} de {V:1}#2#{})",
+ },
+ },
+ j_mp_error_sandbox = {
+ name = "????",
+ text = {
+ "{X:purple,C:white,s:0.85}algo{} {X:purple,C:white,s:0.85}anda{} {X:purple,C:white,s:0.85}mal{}",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroide",
+ text = {
+ "Disminuye #1# nivel de la",
+ "{C:legendary,E:1}mano de póker{}",
+ "con mayor nivel",
+ "de tu {X:purple,C:white}némesis{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Tu némesis",
+ text = {
+ "Tú contra tu propio némesis,",
+ "quien tenga más fichas gana",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Fantasma",
+ text = {
+ "{C:attention}Eterno{} y {C:dark_edition}negativo{}",
+ "Creado y destruido por tu {X:purple,C:white}némesis{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Carta de vidrio",
+ text = {
+ "{X:mult,C:white} X#1# {} multi",
+ "{C:green}#2# en #3#{} probabilidades",
+ "de destruir la carta",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Carta de vidrio",
+ text = {
+ "{X:mult,C:white} X#1# {} multi",
+ "{C:green}#2# en #3#{} probabilidades",
+ "de destruir la carta",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Baraja Cóctel",
+ text = {
+ "Copia todos los",
+ "efectosde {C:attention}3{} barajas",
+ "al azar",
+ },
+ },
+ b_mp_gradient = {
+ name = "Baraja Gradiente",
+ text = {
+ "Las cartas también cuentan como",
+ "la categoría {C:attention}mayor{} o {C:attention}menor{}",
+ "para todos los efectos de {C:attention}comodines{}",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "Baraja de Heidelberg",
+ text = {
+ "Crea una copia {C:dark_edition}negativa{} de",
+ "{C:attention}1 consumible{} al azar",
+ "que tengas al final de la",
+ "{C:attention}tienda{}",
+ },
+ },
+ b_mp_indigo = {
+ name = "Baraja Índigo",
+ text = {
+ "Elige {C:attention}+1{} carta adicional",
+ "en todos los paquetes potenciadores",
+ "Los paquetes potenciadores",
+ "no se pueden {C:attention}omitir{}",
+ },
+ },
+ b_mp_oracle = {
+ name = "Baraja del Oráculo",
+ text = {
+ "Comienzas con {C:spectral,T:c_medium}Medium",
+ "y {C:attention,T:v_clearance_sale}Liquidación",
+ "Sólo puedes tener hasta {C:money}$50{}",
+ "+ {C:money}límite de interés{}",
+ },
+ },
+ b_mp_orange = {
+ name = "Baraja Naranja",
+ text = {
+ "Comienzas con un",
+ "{C:attention,T:p_mp_standard_giga}Giga Paquete Estándar{}, y",
+ "{C:attention}2{} copias de {C:tarot,T:c_hanged_man}El Colgado",
+ },
+ },
+ b_mp_violet = {
+ name = "Baraja Violeta",
+ text = {
+ "Hay {C:attention}1{} vale adicional",
+ "Durante la {C:attention}primera{} apuesta,",
+ "los vales cuestan {C:attention}50%{} menos",
+ },
+ },
+ b_mp_white = {
+ name = "Baraja Blanca",
+ text = {
+ "Puedes ver la baraja y los comodines",
+ "actuales de tu {X:purple,C:white}némesis{}",
+ "{C:inactive}(Se actualiza en la ciega JcJ){}",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "Pozo Planetario",
+ text = {
+ "Aplica efectos de {C:black}Pozo Negro{}, más:",
+ "La tienda puede tener comodines {C:attention}perecederos{}",
+ "{C:inactive,s:0.8}(Se debilitan tras 5 rondas)",
+ "La puntuación requerida escala",
+ "más rápido por cada {C:attention}apuesta{}",
+ },
+ },
+ stake_mp_spectral = {
+ name = "Pozo Espectral",
+ text = {
+ "Aplica efectos de {C:planet}Pozo Planetario{}, más:",
+ "Los comodines {C:money}de alquiler{} aparecen en la tienda",
+ "La puntuación requerida escala",
+ "más rápido por cada {C:attention}apuesta{}",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "Pozo Espectral+",
+ text = {
+ "Aplica efectos de {C:planet}Pozo Espectral{}, más:",
+ "La puntuación requerida escala",
+ "aún más rápido por cada {C:attention}apuesta{}",
+ },
+ },
+ stake_mp_plastic = {
+ name = "Pozo Plástico",
+ text = {
+ "Gana {C:money}$1{} de interés por cada {C:money}$10{}",
+ "{C:inactive,s:0.8}(Máximo de {C:money,s:0.8}$50{C:inactive,s:0.8})",
+ "{s:0.8}Aplica Apuesta Blanca",
+ },
+ },
+ stake_mp_pebble = {
+ name = "Pozo Guijarro",
+ text = {
+ "La puntuación requerida escala",
+ "más rápido por cada {C:attention}apuesta{}",
+ "{s:0.8}Aplica Apuesta Plástica",
+ },
+ },
+ stake_mp_ferrite = {
+ name = "Pozo Ferrita",
+ text = {
+ "Comodines específicos son {C:attention}Persistentes",
+ "{C:inactive,s:0.8}(No se pueden destruir, aumenta el valor de venta)",
+ "{s:0.8}Aplica Apuesta Guijarro",
+ },
+ },
+ stake_mp_pyrite = {
+ name = "Pozo Pirita",
+ text = {
+ "El precio de rebarajar aumenta",
+ "en {C:money}$2{} con cada rebaraje",
+ "{s:0.8}Aplica Apuesta Ferrita",
+ },
+ },
+ stake_mp_jade = {
+ name = "Pozo de Jade",
+ text = {
+ "La puntuación requerida escala",
+ "más rápido por cada {C:attention}apuesta{}",
+ "{s:0.8}Aplica Apuesta Pirita",
+ },
+ },
+ stake_mp_crystal = {
+ name = "Pozo de Cristal",
+ text = {
+ "Comodines específicos son {C:attention}Poco confiables",
+ "{C:inactive,s:0.8}(No activan en la {C:attention,s:0.8}mano final{C:inactive,s:0.8})",
+ "{s:0.8}Aplica Apuesta Jade",
+ },
+ },
+ stake_mp_antimatter = {
+ name = "Pozo Antimateria",
+ text = {
+ "Comodines específicos son {C:attention}Drenantes",
+ "{C:inactive,s:0.8}({X:mult,C:white,s:0.8} X0.75 {C:inactive,s:0.8} multi)",
+ "{s:0.8}Aplica Apuesta Cristal",
+ },
+ },
+ },
+ Spectral = {
+ c_mp_ouija_standard = {
+ name = "Ouija",
+ text = {
+ "Destruye {C:attention}#1#{} cartas al azar,",
+ "luego convierte las restantes",
+ "al mismo {C:attention}valor{} al azar",
+ },
+ },
+ c_mp_ectoplasm_sandbox = {
+ name = "Ectoplasma",
+ text = {
+ "Añade {C:dark_edition}Negativo{} a",
+ "un {C:attention}comodín{} al azar,",
+ "Aplica al azar uno de:",
+ "{C:red}-1{} mano,",
+ "{C:red}-1{} descarte,",
+ "{C:red}-1{} tamaño de mano",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Némesis",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Tu único e inigualable némesis",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Giga Paquete Estándar",
+ text = {
+ "Elige {C:attention}#1#{} de hasta",
+ "{C:attention}#2#{C:attention} cartas{} para",
+ "agregar a tu baraja",
+ "{C:attention}No se puede omitir{}",
+ },
+ },
+ mp_transmutations = {
+ name = "Transmutaciones",
+ text = {
+ "{C:purple,s:1.1}Se transmutará en:",
+ },
+ },
+ mp_internal_sell_value = {
+ name = "Valor de venta",
+ text = {
+ "{C:money,s:1.3}$#1#",
+ },
+ },
+ mp_sticker_persistent = {
+ name = "Persistente",
+ text = {
+ "No se puede destruir",
+ "Cuesta {C:red}${} vender",
+ "El costo aumenta",
+ "{C:red}$3{} al final de la ronda",
+ },
+ },
+ mp_sticker_unreliable = {
+ name = "Inconsistente",
+ text = {
+ "No se activa en",
+ "la {C:attention}mano final{}",
+ },
+ },
+ mp_sticker_draining = {
+ name = "Drenante",
+ text = {
+ "{X:mult,C:white}X0.75{} multi",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Fantasma",
+ mp_sticker_persistent = "Persistente",
+ mp_sticker_unreliable = "Inconsistente",
+ mp_sticker_draining = "Drenante",
+ },
+ dictionary = {
+ b_singleplayer = "Un jugador",
+ b_join_lobby = "Unirse a sala",
+ b_join_lobby_clipboard = "Unirse desde el portapapeles",
+ b_return_lobby = "Volver a sala",
+ b_reconnect = "Reconectar",
+ b_create_lobby = "Crear sala",
+ b_start_lobby = "Iniciar sala",
+ b_ready = "Prepararse",
+ b_unready = "Desprepararse", -- ElTioRata: Este término no está reconocido por la RAE pero tampoco existe un equivalente real de "Unready" así que queda como está.
+ b_leave_lobby = "Abandonar sala",
+ b_mp_discord = "Servidor de Discord del multijugador de Balatro",
+ b_start = "INICIAR",
+ b_wait_for_host_start = {
+ "ESPERANDO AL",
+ "ANFITRIÓN PARA INICIAR",
+ },
+ b_wait_for_players = {
+ "ESPERANDO",
+ "JUGADORES",
+ },
+ b_wait_for_guest_ready = {
+ "ESPERANDO",
+ "AL INVITADO",
+ },
+ b_lobby_options = "OPCIONES DE SALA",
+ b_copy_clipboard = "Copiar al portapapeles",
+ b_view_code = "VER CÓDIGO",
+ b_copy_code = "COPIAR CÓDIGO",
+ b_leave = "ABANDONAR",
+ b_opts_cb_money = "Recibe $ al perder una vida",
+ b_opts_no_gold_on_loss = "No obtener recompensa al perder una ronda",
+ b_opts_death_on_loss = "Pierde una vida al perder en rondas no-JcJ",
+ b_opts_start_antes = "Apuestas iniciales",
+ b_opts_diff_seeds = "Los jugadores estan en semillas diferentes",
+ b_opts_lives = "Vidas",
+ b_opts_multiplayer_jokers = "Habilitar cartas de multijugador",
+ b_opts_player_diff_deck = "Los jugadores tienen barajas diferentes",
+ b_opts_normal_bosses = "Habilitar efectos de Ciega Jefe",
+ b_opts_timer = "Habilitar temporizador",
+ b_opts_disable_preview = "Deshabilitar previsualización de puntuación",
+ b_opts_the_order = "Habilitar El Orden",
+ b_opts_legacy_smallworld = "Mecánicas antiguas de Small World",
+ b_reset = "Reiniciar",
+ b_set_custom_seed = "Agregar semilla personalizada",
+ b_mp_kofi_button = "Donar en Ko-fi",
+ b_unstuck = "Desatascar", -- themike_71: No sé que quiere decir realmente "Unstuck", sé que es como "Desatascar/Destrabar" pero lo dejaré así por ahora
+ b_unstuck_arcana = "Atascado en paquete potenciadores",
+ b_unstuck_blind = "Atascado fuera de JcJ",
+ b_misprint_display = "Muestra la siguiente carta de la baraja",
+ b_players = "Jugadores",
+ b_lobby_info = "Info de sala",
+ b_continue_singleplayer = "Continuar partida individual",
+ b_the_order_integration = "Habilitar integración con El Orden",
+ b_preview_integration = "Habilitar previsualización de puntuación",
+ b_view_nemesis_deck = "Ver barajas",
+ b_toggle_jokers = "Alternar comodines",
+ b_skip_tutorial = "Omitir tutorial",
+ k_yes = "Sí",
+ k_no = "No",
+ k_are_you_sure = "¿Seguro?",
+ k_has_multiplayer_content = "Tiene contenido multijugador",
+ k_forces_lobby_options = "Forzar opciones de sala",
+ k_forces_gamemode = "Forzar modo de juego",
+ k_values_are_modifiable = "* Los valores son modificables",
+ k_rulesets = "Reglas",
+ k_gamemodes = "Modos de juego",
+ k_competitive = "Competitivo",
+ k_other = "Otro",
+ k_battle = "Batalla",
+ k_challenge = "Desafío",
+ k_info = "Info",
+ k_continue_singleplayer_tooltip = "Esto sobrescribirá tu partida individual actual",
+ k_enemy_score = "Puntuación del Némesis:",
+ k_enemy_hands = "Manos del Némesis: ", -- ElTioRata: Esta línea y la anterior se acortan para que el texto no quede tan finito -- Marffe ¿Porque no némesis?
+ k_coming_soon = "¡Próximamente!",
+ k_wait_enemy = "Esperando que termine tu némesis...",
+ k_wait_enemy_reach_this_blind = "Esperando que tu némesis llegue a esta ciega...",
+ k_lives = "Vidas",
+ k_lost_life = "-1 vida", -- themike_71: Realmente es "Perdió una vida", mucho texto, -1 tambien sirve creo yo
+ k_total_lives_lost = " Vidas perdidas en total (4 $ c/u)",
+ k_comeback_money_sandbox = " Dinero de remontada ($3 × apuesta superada)",
+ k_attrition_name = "Atrición", -- ElTioRata: "Desgaste" sería más correcto pero es mejor dejar la palabra original para ser más preciso
+ k_enter_lobby_code = "Agregar código de sala",
+ k_paste = "Pegar desde portapapeles",
+ k_username = "Nombre de usuario:",
+ k_enter_username = "Agregar nombre de usuario",
+ k_customize_preview = "Personalizar texto de previsualización:",
+ k_join_discord = "Unirse al ",
+ k_discord_msg = "Puedes reportar cualquier error y encontrar jugadores allí",
+ k_enter_to_save = "Oprime ENTER para guardar",
+ k_in_lobby = "En sala",
+ k_connected = "Conectado al servidor",
+ k_warn_service = "ADVERTENCIA: No se encontró el servidor multijugador",
+ k_set_name = "¡Agrega tu usuario en el menú pricipal! (Mods > Multijugador > Configuración)",
+ k_mod_hash_warning = "¡Los jugadores tienen diferentes mods o diferentes versiones de mods! ¡Esto puede causar problemas!",
+ k_steamodded_warning = "Los jugadores tienen versiones diferentes de Steamodded instaladas. Esto puede causar que las semillas difieran.",
+ k_warning_unlock_profile = "El perfil que estás usando no está completamente desbloqueado. Si es una partida con ranking/torneo, crea un perfil nuevo y usa 'Desbloquear todo' en ajustes del perfil.",
+ k_warning_nemesis_unlock = "Tu oponente está jugando en un perfil que no está completamente desbloqueado. Pídele que cree un perfil nuevo y use 'Desbloquear todo' en ajustes del perfil.",
+ k_warning_no_order = "Un jugador tiene habilitada la integración con La Orden y el otro no. Esto hará que las semillas difieran.",
+ k_warning_cheating1 = "Si estás viendo esto, tu oponente podría estar haciendo trampa.",
+ k_warning_cheating2 = "Si es una partida con ranking, envía el mensaje '%s' y abre un ticket de soporte en #support.",
+ k_warning_banned_mods = "Uno o más jugadores tienen mods prohibidos instalados. No están permitidos en partidas con ranking.",
+ k_message1 = "Espera, mi mamá hizo pizza",
+ k_message2 = "Un segundo, tengo que agarrar mi estofado",
+ k_message3 = "Un momento, me está llamando mi mamá",
+ k_message4 = "Vuelvo, mi gato está en llamas",
+ k_message5 = "Espera, creo que dejé la estufa encendida",
+ k_message6 = "Aguanta, mi roca mascota se escapó",
+ k_message7 = "Un segundo, mis plantas están pidiendo agua",
+ k_message8 = "Vuelvo, mis calcetines están conspirando contra mí",
+ k_message9 = "Perdón, mi WiFi está teniendo una crisis existencial",
+ k_lobby_options = "Opciones de sala",
+ k_connect_player = "Jugadores conectados:",
+ k_opts_only_host = "Solo el anfitrión puede modificar estas opciones",
+ k_lobby_general = "General",
+ k_lobby_gameplay = "Jugabilidad",
+ k_lobby_modifiers = "Modificadores",
+ k_lobby_advanced = "Avanzado",
+ k_opts_pvp_start_round = "JcJ empieza en apuesta",
+ k_opts_pvp_timer = "Temporizador",
+ k_opts_showdown_starting_antes = "Showdown empieza en apuesta",
+ k_opts_pvp_timer_increment = "Incremento del temporizador",
+ k_opts_pvp_countdown_seconds = "Cuenta regresiva JcJ (segundos)",
+ k_bl_life = "VIDA",
+ k_bl_or = "o",
+ k_bl_death = "MUERTE", -- ElTioRata: En mayúsculas tiene más gancho ;)
+ k_bl_mostchips = "Gana quien tenga más fichas",
+ k_current_seed = "Semilla actual: ",
+ k_random = "Al azar",
+ k_standard = "Estándar",
+ k_standard_description = "Reglas del modo estándar, agrega cartas del multijugador y algunos cambios del juego base para adaptarse al meta del mod.",
+ k_vanilla = "Vainilla",
+ k_vanilla_description = "Reglas del modo vainilla, sin cartas del multijugador y no modifica nada del juego base.",
+ k_weekly = "Semanal",
+ k_weekly_description = "Reglas especiales que cambian semanal o quincenalmente. ¡Supongo que tendrás que descubrirlo por tu cuenta! Actualmente: ",
+ k_tournament = "Torneo",
+ k_tournament_description = "Reglas de torneo, igual que las reglas del modo estándar pero no se permite cambiar las opciones de sala.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Reglas semanales diseñadas por @dr_monty_the_snek en nuestro servidor de Discord que se agregaron de forma permanente.",
+ k_blitz = "Estándar",
+ k_blitz_description = "Este reglamento incluye cartas y funciones que fomentan jugar rápido y\nusar el tiempo como un recurso.\n\nAlgunas cartas están balanceadas aquí para ajustarse mejor al meta del multijugador:\n- Papel perforado está modificado\n- Justicia se elimina\n- Vidrio está modificado\n\n(Ver las pestañas de prohibiciones y ajustes para más info)",
+ k_traditional = "Tradicional",
+ k_traditional_description = "Este reglamento quita los aspectos del multijugador que usan el tiempo como recurso.\n\nPermite jugar con el contenido multijugador\nmientras mantienes una partida metódica.\n\nAlgunas cartas están balanceadas aquí para ajustarse mejor al meta del multijugador:\n- Papel perforado está modificado\n- Justicia se elimina\n- Vidrio está modificado\n\n(Ver las pestañas de prohibiciones y ajustes para más info)",
+ k_majorleague = "Major League",
+ k_majorleague_description = "Este es el reglamento oficial para Major League Balatro.\n\nEs igual al reglamento Vainilla con algunas excepciones:\n- La integración con La Orden está deshabilitada\n- El temporizador se fija en 180 segundos\n- La primera vez que el temporizador llegue a 0 no perderás una vida",
+ k_minorleague = "Minor League",
+ k_minorleague_description = "Este es el reglamento oficial para Minor League Balatro.\n\nEs igual al reglamento Vainilla con algunas excepciones:\n- La integración con La Orden está habilitada\n- El temporizador se fija en 180 segundos\n- La primera vez que el temporizador llegue a 0 no perderás una vida",
+ k_ranked = "Ranked",
+ k_ranked_description = "Este es el reglamento oficial para jugar Ranked Balatro Multiplayer.\n\nEs igual al reglamento Estándar con algunas excepciones:\n- La integración con La Orden está habilitada\n- Debes usar la versión recomendada de Steamodded",
+ k_attrition = "Atrición",
+ k_attrition_description = "Después de la primera apuesta, cada ciega jefe es una ciega de Némesis. No hay tiempo para prepararse. Este modo te obliga a estar listo para la batalla desde el inicio.",
+ k_showdown = "Showdown",
+ k_showdown_description = "Después de las primeras 2 apuestas, cada ciega es una ciega de Némesis. Este modo te da tiempo para prepararte antes de la batalla.",
+ k_survival = "Supervivencia",
+ k_survival_description = "Gana el jugador que llegue más lejos. Sin ciegas de Némesis. Es una prueba de tu capacidad para escalar poco a poco hacia las manos Vainilla con más puntuación.",
+ k_smallworld = "Small World",
+ k_smallworld_description = "Un reglamento muy experimental, donde por alguna razón\nse prohíbe al azar 3/4 de todo lo del juego",
+ k_speedlatro = "Speedlatro",
+ k_speedlatro_description = "Sube el ritmo con un temporizador incómodamente rápido de 147 segundos entre\ncada ciega JcJ. Buena suerte usando Vagabond",
+ k_cost_up = "Costo arriba",
+ k_destabilized = "Desestabilizado",
+ k_oops_ex = "¡Ups!",
+ k_asteroids = "Asteroides",
+ k_amount_short = "Cant.",
+ k_filed_ex = "Archivado!",
+ k_timer = "Temporizador",
+ k_mods_list = "Lista de mods",
+ k_enemy_jokers = "Comodínes del enemigo",
+ k_your_jokers = "Tus comodines",
+ k_nemesis_deck = "Baraja del némesis",
+ k_your_deck = "Tu baraja",
+ k_customization = "Personalización",
+ k_the_order_credit = "*Créditos a @MathIsFun_",
+ k_the_order_integration_desc = "Esto ajusta la creación de cartas para que no dependa de la apuesta y use un solo grupo para cada tipo/rareza",
+ k_preview_credit = "*Créditos a @Fantom, @Divvy",
+ k_preview_integration_desc = "Esto habilita la previsualización de puntuación antes de jugar una mano",
+ k_requires_restart = "*Requiere reiniciar para aplicarse",
+ k_cocktail_select = "Selecciona cartas de baraja para incluirlas",
+ k_cocktail_shiftclick = "Shift-clic para poner foil; las barajas con foil siempre se seleccionan",
+ k_cocktail_rightclick = "Clic derecho para seleccionar todas",
+ k_bans = "Prohibiciones",
+ k_reworks = "Ajustes",
+ k_edit = "Editar",
+ k_ruleset_disabled_the_order_required = "La Orden es requerida",
+ k_ruleset_disabled_the_order_banned = "La Orden está prohibida",
+ k_ruleset_not_found = "Reglamento desconocido",
+ k_tutorial_not_complete = "Debes completar el tutorial antes de jugar Multijugador",
+ k_created_by = "Creado por",
+ k_major_contributors = "Con contribuciones principales de",
+ k_hide_mp_content = "Ocultar contenido multijugador*",
+ k_applies_singleplayer_vanilla_rulesets = "*Aplica en modo individual y reglamentos vainilla",
+ k_timer_sfx = "Efectos de sonido del temporizador",
+ ml_enemy_loc = {
+ "Enemigo",
+ "ubicación",
+ },
+ ml_mp_kofi_message = {
+ "Este mod y sus servidores son",
+ "desarrollados y mantenidos por ",
+ "una sola persona, si te gusta",
+ "puedes considerar",
+ },
+ ml_lobby_info = {
+ "Sala",
+ "Info",
+ },
+ ml_mp_timersfx_opt = {
+ "Activado",
+ "Una vez por apuesta",
+ "Desactivado",
+ },
+ loc_ready = "Listo para JcJ",
+ loc_selecting = "Seleccionando ciega",
+ loc_shop = "En la tienda",
+ loc_playing = "Jugando ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Arte: #1#",
+ },
+ a_mp_code = {
+ "Código: #1#",
+ },
+ a_mp_idea = {
+ "Idea: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# omisiones por delante",
+ },
+ a_mp_skips_behind = {
+ "#1# omisiones por detrás",
+ },
+ a_mp_skips_tied = {
+ "empatadas",
+ },
+ k_banned_objs = "#1# prohibidos",
+ k_no_banned_objs = "Sin #1# prohibidos",
+ k_reworked_objs = "#1# modificados",
+ k_no_reworked_objs = "Sin #1# modificados",
+ k_ruleset_disabled_smods_version = "Se requiere SMODS #1#",
+ k_failed_to_join_lobby = "No se pudo unir a la sala: #1#",
+ k_ante_number = "Apuesta #1#",
+ k_ante_range = "Apuesta #1#-#2#",
+ k_ante_min = "Apuesta #1#+",
+ k_credits_list = "#1# y muchos más!",
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Papel perforado{} está {C:dark_edition}modificado",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Cartas de vidrio{} están {C:dark_edition}modificadas",
+ },
+ ch_c_mp_score_instability = {
+ "La puntuación desbalanceada se {C:purple}desestabiliza{} más:",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(ej: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} -> {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:inactive}Mínimo de {C:attention}1 {C:mult}Multi",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:inactive}Mínimo de {C:attention}0 {C:chips}Fichas",
+ },
+ ch_c_mp_ante_scaling = {
+ "{C:red}X#1#{} tamaño base de la ciega",
+ },
+ ch_c_mp_no_shop_planets = {
+ "Los {C:planet}Planetas{} ya no aparecen en la {C:attention}tienda",
+ },
+ ch_c_mp_only_medium = {
+ "Todas las cartas {C:spectral}Espectrales{} son {C:spectral}Mediums{}",
+ },
+ ch_c_mp_only_purple_seals = {
+ "Todos los {C:attention}sellos{} son {C:purple}Sellos Morados{}",
+ },
+ ch_c_mp_sibyl_CREDITS = {
+ "{C:inactive}(Arte por {C:attention}Ganpan14O{C:inactive})",
+ },
+ ch_c_mp_polymorph_spam = {
+ "Al seleccionar una ciega, todos los {C:attention}comodines{} y {C:attention}consumibles{}",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED1 = {
+ "se transmutan a la {C:attention}N{}-ésima carta siguiente en su colección,",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED2 = {
+ "donde {C:attention}N{} es su posición actual en los espacios",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Estándar",
+ c_mp_sandbox = "Sandbox",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Torneo",
+ c_mp_weekly = "Semanal",
+ c_mp_vanilla = "Vainilla",
+ c_mp_misprint_deck = "Baraja mal impresa",
+ c_mp_legendaries = "Legendarios",
+ c_mp_psychosis = "Psicosis",
+ c_mp_scratch = "Desde cero",
+ c_mp_twin_towers = "Torres gemelas",
+ c_mp_in_the_red = "Con déficit",
+ c_mp_paper_money = "Papel moneda",
+ c_mp_high_hand = "Mano más alta",
+ c_mp_chore_list = "Lista de pendientes",
+ c_mp_oops_all_jokers = "Solo comodínes", -- ElTioRata: "Oops! All 6s" se localizó como "Todos séises", saco el "¡Ups!" por consistencia aunque la referencia al cereal se pierda.
+ c_mp_divination = "Divinidad",
+ c_mp_skip_off = "Avioncito",
+ c_mp_lets_go_gambling = "Let's Go Gambling",
+ c_mp_speed = "Velocidad",
+ c_mp_balancing_act = "Acto de equilibrio",
+ c_mp_salvaged_sibyl = "Sibila rescatada",
+ c_mp_polymorph_spam = "Spam de polimorfismo",
+ c_mp_all_must_go = "Todo debe irse",
+ },
+ },
+}
diff --git a/localization/es_ES.lua b/localization/es_ES.lua
new file mode 100644
index 00000000..bc093293
--- /dev/null
+++ b/localization/es_ES.lua
@@ -0,0 +1,351 @@
+-- Localization by @kmiras, @panbimbogd, @themike_71
+-- Traducido por @kmiras, @panbimbogd, @themike_71
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "ERROR :(", -- The direct translation would be "ROTO" or "ROTA" but I see "ERROR :(" better | La traducción directa sería "ROTO" o "ROTA" pero veo mejor "ERROR :("
+ text = {
+ "Esta carta está rota o no está",
+ "implementada en la versión actual",
+ "de un mod que estás usando.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Comodín defensivo",
+ text = {
+ "{C:chips}+#1#{} Fichas por cada {C:red,E:1}vida{}",
+ "menos que tu {X:purple,C:white}Némesis{}",
+ "{C:inactive}(Actualmente {C:chips}+#2#{C:inactive} Fichas)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Sálta-lo", -- Referring to the hopscotch game, the joker art (saltalo = jump it - skip-off) | Refiriéndose al juego de rayuela, el arte del comodín (saltalo = jump it - skip-off)
+ text = {
+ "{C:blue}+#1#{} Manos y {C:red}+#2#{} Descartes",
+ "por cada {C:attention}ciega{} adicional omitida",
+ "comparado con tu {X:purple,C:white}Némesis{}",
+ "{C:inactive}(Actualmente {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's Go Gambling", -- It could be translated as "A jugar/apostar se ha dicho" in a different tone but I would leave it in English for the original meme | Podría traducirse como "A jugar/apostar se ha dicho" en un tono diferente pero lo dejaría en inglés por el meme original
+ text = {
+ "{C:green}#1# en #2#{} probabilidades de",
+ "{X:mult,C:white}X#3#{} Multi y {C:money}$#4#{}",
+ "{C:green}#5# en #6#{} probabilidades de dar",
+ "a tu {X:purple,C:white}Némesis{} {C:money}$#7#",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Si llegas a una {C:attention}Ciega PvP{}",
+ "antes que tu {X:purple,C:white}Némesis{}, crea",
+ "una carta {C:spectral}Espectral{} aleatoria",
+ "{C:inactive}(Debe haber espacio)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Comodín siamés",
+ text = {
+ "Mientras estés en una {C:attention}Ciega PvP{}, gana",
+ "{X:mult,C:white}X#1#{} Multi por cada {C:blue}Mano{} restante",
+ "que le quede a tu {X:purple,C:white}Némesis{}",
+ "{C:inactive}(Máximo {X:mult,C:white}X#2#{C:inactive} Multi, Actualmente {X:mult,C:white}X#3#{C:inactive} Multi)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Tacaño",
+ text = {
+ "Al final de la ronda,",
+ "gana {C:money}$#1#{} por cada {C:money}$#2#{} que",
+ "gastó tu {X:purple,C:white}Némesis{} en la última tienda",
+ },
+ },
+ j_mp_taxes = {
+ name = "Impuestos",
+ text = {
+ "Cuando tu {X:purple,C:white}Némesis{} vende",
+ "una carta ganas {C:mult}+#1#{} Multi",
+ "{C:inactive}(Actualmente {C:mult}+#2#{C:inactive} Multi)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Imán",
+ text = {
+ "Después de {C:attention}#1#{} rondas,",
+ "vende esta carta para {C:attention}Copiar{}",
+ "el {C:attention}Comodín{} de mayor",
+ "valor de venta de tu {X:purple,C:white}Némesis{}",
+ "{C:inactive}(Actualmente {C:attention}#2#{C:inactive}/#3# rondas)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "{C:red}+#1#{} Descartes para todos los jugadores",
+ "{C:red}-#2#{} Descarte cuando",
+ "cualquier jugador selecciona una ciega",
+ "Se consume cuando tu {X:purple,C:white}Némesis{} omite una ciega",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifista",
+ text = {
+ "{X:mult,C:white}X#1#{} Multi mientras",
+ "no estés en una {C:attention}Ciega PvP{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Papel perforado",
+ text = {
+ "Reactiva la {C:attention}primera{} y {C:attention}segunda{}",
+ "carta jugada utilizada para puntuar",
+ "{C:attention}#1#{} vez adicional",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroide",
+ text = {
+ "Disminuye #1# nivel de",
+ "la {C:legendary,E:1}mano de póker{} de",
+ "mayor nivel de tu {X:purple,C:white}Némesis{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Tu Némesis",
+ text = {
+ "Enfréntate a otro jugador,",
+ "quien puntúe más fichas gana",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Fantasma",
+ text = {
+ "{C:attention}Eterno{} y {C:dark_edition}Negativo{}",
+ "Creado y destruido por tu {X:purple,C:white}Némesis{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Carta de vidrio",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# en #3#{} probabilidades de",
+ "destruir la carta",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Carta de vidrio",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# en #3#{} probabilidades de",
+ "destruir la carta",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Némesis",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Tu único y verdadero Némesis",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Fantasma",
+ },
+ dictionary = {
+ b_singleplayer = "Un jugador",
+ b_join_lobby = "Unirse a la Sala",
+ b_return_lobby = "Volver a la Sala",
+ b_reconnect = "Reconectarse",
+ b_create_lobby = "Crear Sala",
+ b_start_lobby = "Iniciar Sala",
+ b_ready = "Listo",
+ b_unready = "No Listo",
+ b_leave_lobby = "Salir de la Sala",
+ b_mp_discord = "Servidor de Discord de Balatro Multiplayer",
+ b_start = "INICIAR",
+ b_wait_for_host_start = {
+ "ESPERANDO A",
+ "QUE EL ANFITRIÓN INICIE",
+ },
+ b_wait_for_players = {
+ "ESPERANDO A",
+ "JUGADORES",
+ },
+ b_lobby_options = "OPCIONES DE LA SALA",
+ b_copy_clipboard = "Copiar al portapapeles",
+ b_view_code = "VER CÓDIGO",
+ b_copy_code = "COPIAR CÓDIGO",
+ b_leave = "SALIR",
+ b_opts_cb_money = "Recibe $ al perder una vida como ayuda para remontar",
+ b_opts_no_gold_on_loss = "No recibes recompensas de la ciega PvP al perder la ronda",
+ b_opts_death_on_loss = "Pierdes una vida al perder una ronda que no sea PvP",
+ b_opts_start_antes = "Apuestas Iniciales",
+ b_opts_diff_seeds = "Los jugadores tienen códigos diferentes",
+ b_opts_lives = "Vidas",
+ b_opts_multiplayer_jokers = "Habilitar cartas multijugador",
+ b_opts_player_diff_deck = "Los jugadores tienen diferentes barajas",
+ b_opts_normal_bosses = "Habilitar efectos de Ciegas Jefe",
+ b_reset = "Reiniciar",
+ b_set_custom_seed = "Establecer código personalizado",
+ b_mp_kofi_button = "Apoyarme en Ko-fi",
+ b_unstuck = "Desatascar",
+ b_unstuck_arcana = "Atascado en el paquete potenciador",
+ b_unstuck_blind = "Atascado fuera del PvP",
+ b_misprint_display = "Muestra la siguiente carta en la baraja",
+ b_players = "Jugadores",
+ b_continue_singleplayer = "Continuar en modo Un Jugador",
+ b_the_order_integration = "Habilitar integración The Order",
+ b_view_nemesis_deck = "Ver mazo del enemigo",
+ k_enemy_score = "Puntuación actual del enemigo",
+ k_enemy_hands = "Manos restantes del enemigo: ",
+ k_coming_soon = "¡Próximamente!",
+ k_wait_enemy = "Esperando a que el enemigo acabe...",
+ k_lives = "Vidas",
+ k_lost_life = "Pierde una vida",
+ k_total_lives_lost = " Vidas Totales perdidas (4$ cada una)", -- Should that space be there? | ¿Ese espacio debe estar ahí?
+ k_attrition_name = "Desgaste",
+ k_enter_lobby_code = "Introducir código de sala",
+ k_paste = "Pegar desde el portapapeles",
+ k_username = "Nombre de usuario:",
+ k_enter_username = "Introduce tu nombre de usuario",
+ k_join_discord = "Entra en el ",
+ k_discord_msg = "Allí puedes reportar bugs y encontrar personas con las que jugar",
+ k_enter_to_save = "Presiona INTRO para guardar",
+ k_in_lobby = "En la Sala",
+ k_connected = "Conectado al servicio",
+ k_warn_service = "ADVERTENCIA: No se ha podido encontrar el servicio multijugador",
+ k_set_name = "¡Especifica tu nombre de usuario en el menú principal! (Mods > Multiplayer > Configuración)",
+ k_mod_hash_warning = "¡Los jugadores tienen diferentes mods o diferentes versiones de mods! ¡Esto puede causar problemas!",
+ k_warning_unlock_profile = "El perfil en el que estás jugando no está completamente desbloqueado. Si es un juego de ranked/torneo, crea un nuevo perfil y presiona desbloquear todo en la configuración del perfil",
+ k_warning_cheating1 = "Si ves esto, tu oponente puede estar haciendo trampa.",
+ k_warning_cheating2 = "Si es un juego de ranked, por favor envía el mensaje '%s' y luego abre un ticket de soporte en #support",
+ k_message1 = "Un momento, mi mamá hizo pizza",
+ k_message2 = "Un segundo, tengo que ir a buscar mi cerdo asado",
+ k_message3 = "Un momento, tengo una llamada de mi mamá",
+ k_message4 = "Un momento, mi gato está en llamas",
+ k_message5 = "Espera, creo que dejé la estufa encendida",
+ k_message6 = "Espera, mi roca mascota se escapó",
+ k_message7 = "Un segundo, mis plantas piden agua",
+ k_message8 = "Un momento, mis calcetines están conspirando contra mí",
+ k_message9 = "Lo siento, mi WiFi está teniendo una crisis existencial",
+ k_lobby_options = "Opciones de la Sala",
+ k_connect_player = "Jugadores conectados:",
+ k_opts_only_host = "Solo el anfitrión puede cambiar estas opciones",
+ k_opts_gm = "Modificadores de juego",
+ k_opts_pvp_timer = "Temporizador",
+ k_opts_pvp_start_round = "JcJ Empieza en la ronda",
+ k_bl_life = "Vida",
+ k_bl_or = "o",
+ k_bl_death = "Muerte",
+ k_bl_mostchips = "Mayor puntuación gana",
+ k_current_seed = "Código actual: ",
+ k_random = "Aleatorio", -- It can also be left as is, "Random" | También puede dejarse como es, "Random"
+ k_standard = "Estándar",
+ k_standard_description = "El conjunto de reglas estándar, incluye cartas multijugador y cambios al juego base para adaptarse a la estrategia del multijugador.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "El conjunto de reglas clásico, sin cartas multijugador, sin modificaciones al contenido del juego base.",
+ k_weekly = "Semanal",
+ k_weekly_description = "Un conjunto de reglas especial que cambia semanal o quincenalmente. ¡Supongo que tendrás que descubrir de qué se trata! Actualmente: ",
+ k_tournament = "Torneo",
+ k_tournament_description = "El conjunto de reglas de torneo, es igual que el conjunto de reglas estándar pero no permite cambiar las opciones de la sala.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Un conjunto de reglas semanal diseñado por @dr_monty_the_snek en el servidor de Discord que ha sido añadido al mod de forma permanente.",
+ k_oops_ex = "Oops!", -- Maybe as "¡Vaya!" but not bad as is | Quizás como "¡Vaya!" pero no está mal tal cual
+ k_timer = "Temporizador",
+ k_mods_list = "Lista de Mods",
+ k_enemy_jokers = "Comodines del enemigo",
+ k_nemesis_deck = "Mazo del enemigo",
+ k_the_order_credit = "*Crédito a @MathIsFun_",
+ k_the_order_integration_desc = "Esto parcheará la aparición de cartas para que no sea basada en las apuestas iniciales y use un solo grupo para cada tipo/rareza",
+ k_requires_restart = "*Requiere reiniciar para que tome efecto",
+ ml_enemy_loc = {
+ "Ubicación del",
+ "enemigo",
+ },
+ ml_mp_kofi_message = {
+ "Este mod y servidor es",
+ "desarrollado y mantenido por ",
+ "una persona, sí",
+ "te gusta, considera",
+ },
+ ml_lobby_info = {
+ "Info de",
+ "la sala",
+ },
+ loc_ready = "Listo para el PvP",
+ loc_selecting = "Seleccionando ciega",
+ loc_shop = "Comprando",
+ loc_playing = "Jugando ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Arte: #1#",
+ },
+ a_mp_code = {
+ "Código: #1#",
+ },
+ a_mp_idea = {
+ "Idea: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Ciegas por delante",
+ },
+ a_mp_skips_behind = {
+ "#1# Ciegas por detrás",
+ },
+ a_mp_skips_tied = {
+ "Empatado",
+ },
+ --
+ k_banned_objs = "#1# Prohi",
+ k_no_banned_objs = "No hay #1# prohibidos",
+ k_reworked_objs = "#1# añadidos/modificados",
+ k_no_reworked_objs = "No hay #1# añadidos/modificaciones ",
+ --
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "El {C:attention}Papel perforado{} está {C:dark_edition}modificado",
+ },
+ ch_c_glass_cards_rework = {
+ "Las {C:attention}Cartas de vidrio{} están {C:dark_edition}modificadas",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Estándar",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Torneo",
+ c_mp_weekly = "Semanal",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Mazo con errores de imprenta",
+ c_mp_legendaries = "Legendarios",
+ c_mp_psychosis = "Psicosis",
+ c_mp_scratch = "Desde Cero",
+ c_mp_twin_towers = "Torres Gemelas",
+ c_mp_in_the_red = "En Números Rojos",
+ c_mp_paper_money = "Dinero de Papel",
+ c_mp_high_hand = "Carta alta",
+ c_mp_chore_list = "Lista de Tareas",
+ c_mp_oops_all_jokers = "Solo Comodines",
+ c_mp_divination = "Divinidad",
+ c_mp_skip_off = "Sálta-lo",
+ c_mp_lets_go_gambling = "Let's Go Gambling", -- same as joker | igual que el comodín
+ c_mp_speed = "Velocidad",
+ },
+ },
+}
diff --git a/localization/fr.lua b/localization/fr.lua
new file mode 100644
index 00000000..07506cfd
--- /dev/null
+++ b/localization/fr.lua
@@ -0,0 +1,374 @@
+-- Localization by @ninoleplot - Localisation par @ninoleplot
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "CASSÉ",
+ text = {
+ "Cette carte est cassée, ou n'est",
+ "pas implémentée dans une version",
+ "actuelle d'un mod que vous utilisez.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Joker Défensif",
+ text = {
+ "{C:chips}+#1#{} Jetons par {C:red,E:1}vie{}",
+ "de moins que votre {X:purple,C:white}Némésis{}",
+ "{C:inactive}(Actuellement {C:chips}+#2#{C:inactive} Jetons)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Marelle",
+ text = {
+ "{C:blue}+#1#{} Mains et {C:red}+#2#{} Défausses",
+ "par {C:attention}Blinde{} passée ",
+ "{C:attention}de plus{} que votre {X:purple,C:white}Némésis{}",
+ "{C:inactive}(Actuellement {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Machine à Sous",
+ text = {
+ "{C:green}#1# chance(s) sur #2#{} d'octroyer",
+ "{X:mult,C:white}X#3#{} Multi et {C:money}$#4#{}",
+ "{C:green}#5# chance(s) sur #6#{} de donner",
+ "{C:money}$#7#{} à votre {X:purple,C:white}Némésis{}",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Si vous atteignez une {C:attention}Blinde PvP",
+ "avant votre {X:purple,C:white}Némésis{},",
+ "crée une carte {C:spectral}Spectrale{} aléatoire",
+ "{C:inactive}(Selon la place disponible)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Joker Conjoint",
+ text = {
+ "Pendant une {C:attention}Blinde PvP{}, octroie",
+ "{X:mult,C:white}X#1#{} Multi pour chaque {C:blue}Main{}",
+ "restante de votre {X:purple,C:white}Némésis{}",
+ "{C:inactive}(Max. {X:mult,C:white}X#2#{C:inactive} Multi, Actuellement {X:mult,C:white}X#3#{C:inactive} Multi)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Grippe-Sou",
+ text = {
+ "A la fin d'une manche, gagnez {C:money}$#1#{}",
+ "par {C:money}$#2#{} dépensés par votre{X:purple,C:white}Némésis{}",
+ "dans le magasin correspondant de la {C:attention}dernière mise initiale{}",
+ "{C:inactive}(Prochain gain: {C:money}$#3#{C:inactive})",
+ },
+ },
+ j_mp_taxes = {
+ name = "Impôts",
+ text = {
+ "{C:mult}+#1#{} Multi pour chaque carte",
+ "{C:attention}vendue{} par votre {X:purple,C:white}Némésis{}",
+ "durant cette partie, s'actualise quand la",
+ " {C:attention}Blinde PvP{} est sélectionnée",
+ "{C:inactive}(Actuellement {C:mult}+#2#{C:inactive} Multi,",
+ "{C:inactive}prochaine actualisation : {C:mult}+#3#{C:inactive} Multi)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Aimant",
+ text = {
+ "Après {C:attention}#1#{} manches,",
+ "vendez cette carte pour {C:attention}Copier{}",
+ "le {C:attention}Joker{} à la plus haute ",
+ "valeur de vente de votre {X:purple,C:white}Némésis{}",
+ "{C:inactive}(Actuellement {C:attention}#2#{C:inactive}/#3# manches)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "{C:red}+#1#{} défausse(s) et",
+ "{C:red}+#2#{} défausse(s) pour votre {X:purple,C:white}Némésis{}",
+ "{C:inactive}(Détruit après une {C:attention}Blinde PvP{}{C:inactive})",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifiste",
+ text = {
+ "Octroie {X:mult,C:white}X#1#{} Multi",
+ "{C:attention}sauf{} pendant les {C:attention}Blindes PvP{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Carte de Vote",
+ text = {
+ "Déclenche à nouveau la {C:attention}première{} et la {C:attention}seconde{}",
+ "carte jouée pour marquer des points",
+ "{C:attention}#1#{} fois supplémentaire(s)",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Astéroïde",
+ text = {
+ "Retire #1# niveau",
+ "à la {C:legendary,E:1}main de poker{}",
+ "la plus améliorée",
+ "de votre {X:purple,C:white}Némésis{}",
+ "au début de la prochaine {C:attention}Blinde PvP{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Votre Némésis",
+ text = {
+ "Jouez contre un autre joueur,",
+ "le plus haut score l'emporte.",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Fantôme",
+ text = {
+ "{C:attention}Éternel{} et {C:dark_edition}Négatif{}",
+ "Créé et détruit par votre {X:purple,C:white}Némésis{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Carte Verre",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# chance(s) sur #3#{} de",
+ "détruire la carte",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Carte Verre",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# chance(s) sur #3#{} de",
+ "détruire la carte",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Némésis",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Votre seul et unique Némésis.",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Fantôme",
+ },
+ dictionary = {
+ b_singleplayer = "Solo",
+ b_join_lobby = "Rejoindre un Lobby",
+ b_return_lobby = "Retourner au Lobby",
+ b_reconnect = "Se Reconnecter",
+ b_create_lobby = "Créer un Lobby",
+ b_start_lobby = "Créer le Lobby",
+ b_ready = "Prêt",
+ b_unready = "Pas Prêt",
+ b_leave_lobby = "Quitter le Lobby",
+ b_mp_discord = "Serveur Discord Balatro Multiplayer",
+ b_start = "COMMENCER",
+ b_wait_for_host_start = {
+ "EN ATTENTE DE",
+ "L'HÔTE POUR COMMENCER",
+ },
+ b_wait_for_players = {
+ "EN ATTENTE DES",
+ "JOUEURS",
+ },
+ b_lobby_options = "OPTIONS DE LOBBY",
+ b_copy_clipboard = "Copier vers le presse-papiers",
+ b_view_code = "VOIR LE CODE",
+ b_copy_code = "COPIER LE CODE",
+ b_leave = "PARTIR",
+ b_opts_cb_money = "Gagner des $ lors de la perte d'une vie",
+ b_opts_no_gold_on_loss = "Ne pas obtenir les récompenses de la Blinde lors d'une défaite",
+ b_opts_death_on_loss = "Perdre une vie après une défaite contre une Blinde non-PvP",
+ b_opts_start_antes = "Mises initiales de Départ",
+ b_opts_diff_seeds = "Graines différentes pour les joueurs",
+ b_opts_lives = "Vies",
+ b_opts_multiplayer_jokers = "Activer les cartes multijoueur",
+ b_opts_player_diff_deck = "Les joueurs peuvent avoir des decks différents",
+ b_opts_normal_bosses = "Activer les effets des Blindes Boss",
+ b_opts_timer = "Activer le Timer",
+ b_reset = "Réinitialiser",
+ b_set_custom_seed = "Graine Personnalisée",
+ b_mp_kofi_button = "Me soutenir sur Ko-fi",
+ b_unstuck = "Se Décoincer",
+ b_unstuck_blind = "Coincé en dehors du PvP",
+ b_misprint_display = "Afficher la prochaine carte du deck",
+ b_players = "Joueurs",
+ b_lobby_info = "Infos Lobby",
+ b_continue_singleplayer = "Continuer en Solo",
+ b_the_order_integration = "Activer l'intégration de The Order",
+ b_view_nemesis_deck = "Voir le deck adverse",
+ b_toggle_jokers = "Activer/Désactiver les Jokers",
+ k_continue_singleplayer_tooltip = "Votre partie en solo sera écrasée",
+ k_enemy_score = "Score ennemi actuel",
+ k_enemy_hands = "Mains adverses restantes: ",
+ k_coming_soon = "Bientôt!",
+ k_wait_enemy = "En attente que l'adversaire termine...",
+ k_wait_enemy_reach_this_blind = "En attente que l'adversaire atteigne cette blinde...",
+ k_lives = "Vies",
+ k_lost_life = "A perdu une Vie",
+ k_total_lives_lost = " Total de vies perdues ($4 chacune)",
+ k_attrition_name = "Érosion",
+ k_enter_lobby_code = "Entrer le code du Lobby",
+ k_paste = "Coller depuis le presse-papiers",
+ k_username = "Pseudo:",
+ k_enter_username = "Entrer un pseudo",
+ k_join_discord = "Rejoindre le ",
+ k_discord_msg = "Vous pourrez y signaler des bugs et trouver des joueurs avec qui jouer",
+ k_enter_to_save = "Appuyer sur Entrée pour sauvegarder",
+ k_in_lobby = "Dans le Lobby",
+ k_connected = "Connecté aux Services",
+ k_warn_service = "WARN: Impossible de Trouver le Service Multijoueur",
+ k_set_name = "Entrez votre pseudo dans le menu ! (Mods > Cliquer sur le mod Multiplayer > Config)",
+ k_mod_hash_warning = "Les joueurs n'ont pas les même mods/versions de mods! Cela peut causer des dysfonctionnements!",
+ k_warning_unlock_profile = "Le profil actuel n'a pas tout le contenu débloqué. Si cette partie est classée/un tournoi, veuillez créer un nouveau profil et cliquer sur 'tout débloquer' dans les paramètres du profil.",
+ k_warning_nemesis_unlock = "Votre adversaire joue sur un profil qui n'a pas tout débloqué. Indiquez lui d'appuyer sur 'tout débloquer' dans les paramètres de son profil.",
+ k_warning_no_order = "Un joueur a activé l'intégration de The Order mais l'autre non. Les graines seront peut-être différentes.",
+ k_warning_cheating1 = "Si vous lisez ceci, il se peut que votre adversaire triche.",
+ k_warning_cheating2 = "Si cette partie est classée, envoyez le message '%s' et ouvrez un ticket de support dans #support.",
+ k_message1 = "Attends, ma mère a fait des mini-pizzas",
+ k_message2 = "Bouge pas, faut que j’aille sortir le rôti du four.",
+ k_message3 = "Une seconde, c’est ma mère qui m’appelle",
+ k_message4 = "Je reviens, mon chat est en feu",
+ k_message5 = "Attends, j'ai oublié de fermer le gaz",
+ k_message6 = "Attends, mon caillou de compagnie a pris la fuite",
+ k_message7 = "Deux secondes, mes plantes réclament de l'eau",
+ k_message8 = "Je reviens, mes chaussettes complotent contre moi",
+ k_message9 = "Désolé, mon Wi-Fi traverse une crise existentielle",
+ k_lobby_options = "Options de Lobby",
+ k_connect_player = "Joueurs Connectés:",
+ k_opts_only_host = "Seul l'Hôte du Lobby peut changer ces options",
+ k_opts_gm = "Options du Mode de Jeu",
+ k_opts_pvp_start_round = "Le PvP commence à la mise initiale",
+ k_opts_pvp_timer = "Timer",
+ k_opts_showdown_starting_antes = "L'Affrontement commence à la Mise Initiale",
+ k_opts_pvp_timer_increment = "Incrément du Timer",
+ k_bl_life = "Vie",
+ k_bl_or = "ou",
+ k_bl_death = "Mort",
+ k_bl_mostchips = "Le plus de jetons l'emporte",
+ k_current_seed = "Graine actuelle: ",
+ k_random = "Aléatoire",
+ k_standard = "Standard",
+ k_standard_description = "Les règles classiques, les cartes Multijoueur sont incluses ainsi que les ajustements du jeu de base pour le multijoueur.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "Les règles du jeu de base, pas de cartes multijoueur, pas de changement du contenu de base.",
+ k_weekly = "Hebdo",
+ k_weekly_description = "Des règles spéciales qui changent toutes les semaines/deux semaines. Essayez par vous-même! Actuellement: ",
+ k_tournament = "Tournoi",
+ k_tournament_description = "Règles de tournoi, similaire aux règles standard mais sans les options de lobby.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Des règles hebdomadaires écrites par @dr_monty_the_snek sur le serveur discord, qui ont été ajoutées au mod de façon permanente.",
+ k_attrition = "Erosion",
+ k_attrition_description = "Toutes les blindes boss sont PvP. Pas le temps de se préparer. Ce mode force à être prêt au combat dès le début.",
+ k_showdown = "Confrontation",
+ k_showdown_description = "Après les 2 premières mises initiales, toutes les blindes boss sont PvP. Ce mode laisse le temps de se préparer avant le combat.",
+ k_survival = "Survie",
+ k_survival_description = "Pas de blindes PvP, le joueur allant le plus loin dans la partie gagne. Ce mode permet de tester vos capacités à aller le plus loin possible dans le jeu classique.",
+ k_oops_ex = "Oups!",
+ k_asteroids = "Astéroïdes",
+ k_amount_short = "Qté",
+ k_filed_ex = "C'est envoyé !",
+ k_timer = "Timer",
+ k_mods_list = "Liste des Mods",
+ k_enemy_jokers = "Jokers Adverses",
+ k_your_jokers = "Vos Jokers",
+ k_nemesis_deck = "Deck Adverse",
+ k_your_deck = "Votre Deck",
+ k_the_order_credit = "*Crédit à @MathIsFun_",
+ k_the_order_integration_desc = "La création de carte sera modifiée pour ne pas être basée sur la mise initiale et pour utiliser une seule pool pour chaque type/rareté",
+ k_requires_restart = "*Nécessite un redémarrage pour s'appliquer",
+ k_bans = "Bans",
+ k_reworks = "Ajouts/Refontes",
+ ml_enemy_loc = {
+ "Position",
+ "ennemie",
+ },
+ ml_mp_kofi_message = {
+ "Ce mod et serveur de jeu est",
+ "développé et maintenu par ",
+ "une seule personne, si",
+ "vous l'aimez, n'hésitez pas à",
+ },
+ ml_lobby_info = {
+ "Infos",
+ "Lobby",
+ },
+ loc_ready = "Prêt.e pour le PvP",
+ loc_selecting = "Sélectionne une Blinde",
+ loc_shop = "Dans le magasin",
+ loc_playing = "Joue ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Art: #1#",
+ },
+ a_mp_code = {
+ "Code: #1#",
+ },
+ a_mp_idea = {
+ "Idée: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Blindes passées d'avance",
+ },
+ a_mp_skips_behind = {
+ "#1# Blindes passées de retard",
+ },
+ a_mp_skips_tied = {
+ "En Égalité",
+ },
+ k_banned_objs = "#1# Banni(e)s",
+ k_no_banned_objs = "Pas de #1# Banni(e)s",
+ k_reworked_objs = "#1# Ajouté(e)s/Modifié(e)s",
+ k_no_reworked_objs = "Pas de #1# Ajouté(e)s/Modifié(e)s ",
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Carte de Vote{} est {C:dark_edition}modifié",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Les Cartes Verre{} sont {C:dark_edition}modifiées",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Standard",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Tournoi",
+ c_mp_weekly = "Hebdo",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Deck Erreur d'Impression",
+ c_mp_legendaries = "Légendaires",
+ c_mp_psychosis = "Psychose",
+ c_mp_scratch = "De Zéro",
+ c_mp_twin_towers = "Tours Jumelles",
+ c_mp_in_the_red = "Dans le Rouge",
+ c_mp_paper_money = "Billets de Banque",
+ c_mp_high_hand = "Grande Main",
+ c_mp_chore_list = "Liste des Corvées",
+ c_mp_oops_all_jokers = "Que des Jokers",
+ c_mp_divination = "Divination",
+ c_mp_skip_off = "La Marelle",
+ c_mp_lets_go_gambling = "Le Casino",
+ c_mp_speed = "Vitesse",
+ },
+ },
+}
diff --git a/localization/it.lua b/localization/it.lua
new file mode 100644
index 00000000..ce87b7fe
--- /dev/null
+++ b/localization/it.lua
@@ -0,0 +1,758 @@
+-- Traduzione creata da @ieatmilk(su discord) e conseguentemente aggiornata da @alexxyo(sempre su discord)
+-- Localization by @ieatmilk and updated by @alexxyo
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "Patto scommessa",
+ text = {
+ "{C:green}#1# probabilità su #2#",
+ "Che il negozio ha",
+ "un {C:red}Jolly raro",
+ },
+ },
+ },
+ Joker = {
+ j_broken = {
+ name = "BROKEN",
+ text = {
+ "Questa carta non funziona o non è",
+ "ancora stata implementata nella versione",
+ "attuale di una mod che stai usando.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Jolly difensivo",
+ text = {
+ "Questo {C:attention}Jolly{} guadagna {C:chips}+#1#{} fiche",
+ "per ogni {C:red,E:1}vita{} in meno della tua {X:purple,C:white}Nemesi",
+ "{C:inactive}(Attualmente {C:chips}+#2#{C:inactive} fiche)",
+ "{C:inactive}(Dipende dalla puntata)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Campana",
+ text = {
+ "{C:blue}+#1#{} Mano e {C:red}+#2#{} Scarto",
+ "per ogni {C:attention}buio{} saltato",
+ "rispetto alla tua {X:purple,C:white}Nemesi{}",
+ "{C:inactive}(Attualmente {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive})",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's go gambling!",
+ text = {
+ "{C:green}#1# probabilità su #2#{} di ricevere",
+ "{X:mult,C:white}X#3#{} Molt e {C:money}$#4#",
+ "{C:green}#5# probabilità su #6#{} di dare",
+ "{C:money}$#7#{} alla tua {X:purple,C:white}Nemesi{} in un {C:attention}Buio PvP",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Se raggiungi un {C:attention}Buio PvP",
+ "prima della tua {X:purple,C:white}Nemesi{}",
+ "crea una carta {C:spectral}Spettrale{} a caso",
+ "{C:inactive}(Serve spazio)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Jolly congiunto",
+ text = {
+ "Se in un {C:attention}Buio PvP{}, ottiene",
+ "{X:mult,C:white}X#1#{} Molt per ogni {C:blue}Mano{}",
+ "rimanente alla tua {X:purple,C:white}Nemesi{}",
+ "{C:inactive}(Max {X:mult,C:white}X#2#{C:inactive} Molt, Attualmente {X:mult,C:white}X#3#{C:inactive} Molt)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Spilorcio",
+ text = {
+ "Alla fine del round,",
+ "guadagna {C:money}$#1#{} per ogni {C:money}$#2#{} spesi",
+ "dalla tua {X:purple,C:white}Nemesi{} nell'ultimo negozio",
+ },
+ },
+ j_mp_taxes = {
+ name = "Tasse",
+ text = {
+ "Quando la tua {X:purple,C:white}Nemesi{} vende",
+ "una carta, guadagna {C:mult}+#1#{} Molt.",
+ "Si aggiorna quando viene",
+ "selezionato il {C:attention}Buio PvP",
+ "{C:inactive}(Attualmente {C:mult}+#2#{C:inactive} Molt)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "Alla fine del prossimo {C:attention}Buio PvP",
+ "consuma questo {C:attention}Jolly{} per",
+ "assegnare {C:red}+#1#{} Scarti a te e ",
+ "{C:red}-#2#{} Scarto alla tua {X:purple,C:white}Nemesi{} per questo ante",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifista",
+ text = {
+ "{X:mult,C:white}X#1#{} Molt",
+ "fuori da un {C:attention}Buio PvP",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Scheda non valida",
+ text = {
+ "Riattiva la {C:attention}prima{} e la {C:attention}seconda",
+ "carta giocata per ottenere punti",
+ "{C:attention}#1#{} volta in più",
+ },
+ },
+ j_mp_bloodstone = {
+ name = "Diaspro sanguinoso",
+ text = {
+ "{C:green}#1# probabilità su #2#{} che",
+ "le carte giocate con",
+ "seme {C:hearts}Cuori{} diano",
+ "{X:mult,C:white} X#3# {} Molt quando assegna punti",
+ },
+ },
+ j_mp_magnet_sandbox = {
+ name = "Magnete",
+ text = {
+ "Dopo {C:attention}#1#{} round, vendi",
+ "questa carta per {C:attention}Copiare{}",
+ "il {C:attention}Jolly{} più costoso",
+ "della tua {X:purple,C:white}Nemesi",
+ "la polarità si inverte dopo {C:attention}#3#{} round",
+ "DIVENTANDO UN INUTILE ROTTAME METALLICO!!!!",
+ "{C:inactive}(Attualmente {C:attention}#2#{C:inactive}/#1# round)",
+ },
+ },
+ j_mp_cloud_9_sandbox = {
+ name = "Nove nuvoloso",
+ text = {
+ "CONTADINO MONOCOLTURE DI NUMERI",
+ "converte il tuo MAZZO DIVERSIFICATO in",
+ "PIANTAGIONI DI NOVE REDDITIZI!!!!",
+ "{C:inactive}({C:green}#1# probabilità su #2#{}{C:inactive}, attualmente {C:money}$#3#{}{C:inactive})",
+ },
+ },
+ j_mp_lucky_cat_sandbox = {
+ name = "Gatto della fortuna",
+ text = {
+ "OPERATORE DI TUBATURE DA-FORTUNA-A-FRAGILITÀ",
+ "il Gatto della fortuna diventa il GATTO DI VETRO",
+ "con un POTERE ESPONENZIALE!!!!",
+ "{C:inactive}(Attualmente {X:mult,C:white} X#2# {C:inactive}Molt)",
+ },
+ },
+ j_mp_constellation_sandbox = {
+ name = "Costellazione",
+ text = {
+ "disturbo d'ansia da manutenzione del pianeta",
+ "DEVI NUTRIRE IL TAMAGOTCHI",
+ "o APPASSIRÀ!!!!",
+ "{C:inactive}(Attualmente {X:mult,C:white} X#1# {C:inactive}Molt)",
+ },
+ },
+ j_mp_bloodstone_sandbox = {
+ name = "Diaspro sanguinoso",
+ text = {
+ "SINDROME DA REGRESSIONE DELLA PATCH NOTE",
+ "tornando al TRAUMA DEL GIORNO DEL LANCIO",
+ "per PICCHI DI POTERE NOSTALGICI!!!!",
+ "{C:inactive}({C:green}#1# probabilità su #2#{}{C:inactive})",
+ },
+ },
+ j_mp_juggler_sandbox = {
+ name = "Giocoliere",
+ text = {
+ "PERFEZIONISTA DELLA DIMENSIONE DELLA MANO",
+ "che deve tenere TUTTE LE CARTE",
+ "in aria COSTANTEMENTE!!!!",
+ "{C:inactive}(Attualmente {C:attention}+#1#{C:inactive} carte della mano)",
+ },
+ },
+ j_mp_mail_sandbox = {
+ name = "Offerta per posta",
+ text = {
+ "MODULO D'OFFERTA BLOCCATO PER VALORE",
+ "qualcuno ha scritto {C:attention}#2#{}",
+ "con l'INCHIOSTRO INDELEBILE!!!!",
+ },
+ },
+ j_mp_hit_the_road_sandbox = {
+ name = "In viaggio",
+ text = {
+ "SMALTIMENTO JACK AUTOSTRADALI",
+ "lanciando dei {C:attention}Jack{}",
+ "NELL'ASFALTO PER SEMPRE!!!!",
+ "{C:inactive}(Attualmente {X:mult,C:white} X#2# {C:inactive} Molt)",
+ },
+ },
+ j_mp_misprint_sandbox = {
+ name = "Errore di stampa",
+ text = {
+ "LOTTERIA DI SCHRODINGER, i biglietti del",
+ "GIOCATORE sono sia VINCENTI che PERDENTI",
+ "finche osservati!!!!",
+ "{C:inactive}({V:1}#1#{C:inactive} Molt)",
+ },
+ },
+ j_mp_castle_sandbox = {
+ name = "Castello",
+ text = {
+ "MATRIMONIO DI SEME BASATO SULLO SCARTO",
+ "butta via SOLO {V:1}#1#{} per sempre",
+ "perché QUESTO È IL LORO LINGUAGGIO D'AMORE!!!!",
+ "{C:inactive}(Attualmente {C:chips}+#2#{C:inactive} Fiche)",
+ },
+ },
+ j_mp_runner_sandbox = {
+ name = "Corridore",
+ text = {
+ "SUPREMATISTA DELLE SEQUENZIALI",
+ "che crede che TUTTE le altre",
+ "MANI DI POKER sono INFERIORI!!!!",
+ "{C:inactive}(Attualmente {C:chips}+#1#{C:inactive} Fiche)",
+ },
+ },
+ j_mp_order_sandbox = {
+ name = "L'ordine",
+ text = {
+ "COORDINATORE DELLA RIVOLTA CONTADINA",
+ "organizzando i NUMERI per rovesciare",
+ "i loro OPPRESSORI FIGURE!!!!",
+ },
+ },
+ j_mp_photograph_sandbox = {
+ name = "Fotografia",
+ text = {
+ "FOTOGRAFO DALLO SCATTO SINGOLO che prende",
+ "UNA FOTO PERFETTA PER MANO!!!!",
+ },
+ },
+ j_mp_ride_the_bus_sandbox = {
+ name = "Giro sull'autobus",
+ text = {
+ "PROGRAMMA DI SOBRIETÀ PER LE FIGURE",
+ "UNA FIGURA e sei",
+ "FUORI DAL BUS!!!!",
+ "{C:inactive}(Attualmente {C:mult}+#1#{C:inactive} Molt)",
+ },
+ },
+ j_mp_loyalty_card_sandbox = {
+ name = "Carta fedeltà",
+ text = {
+ "PROGRAMMA FEDELTÀ PER TIPO DI MANO",
+ "tradisci {C:attention}#1#{}",
+ "e il contatore SI RESETTA!!!!",
+ "{C:inactive}(Fedele per {C:attention}#2#/#3#{} {C:inactive}mani)",
+ },
+ },
+ j_mp_faceless_sandbox = {
+ name = "Jolly senza volto",
+ text = {
+ "SOMMELIER D'ELITE DELLE FIGURE",
+ "che cura l'artigianato",
+ "DEGUSTAZIONE A TRE VARIETÀ",
+ "per un ESPERIENZA DI SCARTO PREMIUM!!!!",
+ },
+ },
+ j_mp_square_sandbox = {
+ name = "Jolly squadrato",
+ text = {
+ "PERFEZIONISTA DELLE QUATTRO CARTE",
+ "che venera LA SACRA GEOMETRIA DEGLI",
+ "ARRANGIAMENTI PERFETTAMENTE BILANCIATI DEL QUADRATO!!!!",
+ "{C:inactive}(Attualmente {C:chips}+#1#{C:inactive} Fiche)",
+ },
+ },
+ j_mp_throwback_sandbox = {
+ name = "Tuffo nel passato",
+ text = {
+ "SERVIZI DI CONSULENZA PROFESSIONALE DA CODARDI",
+ "io vengo PAGATO per scappare dalle cose",
+ "E PIÙ SCAPPO PIÙ DIVENTO FORTE!!!!",
+ "{C:inactive}(Attualmente {X:mult,C:white} X#1# {C:inactive} Molt)",
+ },
+ },
+ j_mp_vampire_sandbox = {
+ name = "Vampiro",
+ text = {
+ "vampiro economista CREA",
+ "VALUTA BASATA SUI SASSI",
+ "DALLA FORZA VITALE!!!!",
+ "{C:inactive}(Attualmente {X:mult,C:white} X#2# {C:inactive} Molt)",
+ },
+ },
+ j_mp_baseball_sandbox = {
+ name = "Carta da baseball",
+ text = {
+ 'LA "CONTROVERSIA" DELLE CARTE SPORTIVE',
+ "travestita da CAMBIO DI BILANCIO!!!!",
+ },
+ },
+ j_mp_steel_joker_sandbox = {
+ name = "Jolly d'acciaio",
+ text = {
+ "SPECIALISTA IN RINDONDANZA DELL'ACCIAIO",
+ "ogni LEGA GIOCATA viene",
+ "RICONTROLLATA!!!!",
+ },
+ },
+ j_mp_satellite_sandbox = {
+ name = "Satellite",
+ text = {
+ "ansia cronica da degredo del satellite",
+ "L'INFRASTRUTTURA CASCA A PEZZI LENTAMENTE",
+ "SENZA COSTANTI MIGLIORAMENTI PLANETARI!!!!",
+ "{C:inactive}(Attualmente {C:money}$#1#{C:inactive})",
+ },
+ },
+ j_mp_error_sandbox = {
+ name = "????",
+ text = {
+ "PREVIEW DISABLED",
+ "{X:purple,C:white,s:0.85}something's{} {X:purple,C:white,s:0.85}wrong",
+ "PREVIEW DISABLED",
+ "PREVIEW DISABLED",
+ -- "{C:inactive}(CURRENTLY {C:money}$7{C:inactive})",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroide",
+ text = {
+ "Rimuove #1# livello",
+ "dalla {C:legendary,E:1}mano di poker{}",
+ "di livello più alto della tua",
+ "{X:purple,C:white}Nemesi{} all'inizio del {C:attention}Buio PVP{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "La tua Nemesi",
+ text = {
+ "Sei contro un altro giocatore,",
+ "chi fa più punti vince",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Fantasma",
+ text = {
+ "{C:attention}Eterna{} e {C:dark_edition}Negativa{}",
+ "Creata e distrutta dalla tua {X:purple,C:white}Nemesi{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Carta di vetro",
+ text = {
+ "{X:mult,C:white} X#1#{} Molt",
+ "{C:green}#2# probabilità su #3#{} di",
+ "distruggere la carta",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Carta di vetro",
+ text = {
+ "{X:mult,C:white} X#1#{} Molt",
+ "{C:green}#2# probabilità su #3#{} di",
+ "distruggere la carta",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Mazzo cocktail",
+ text = {
+ "Copia tutti gli effetti",
+ "di {C:attention}3{} mazzi",
+ "a caso",
+ },
+ },
+ b_mp_gradient = {
+ name = "Mazzo gradiente",
+ text = {
+ "Le carte sono considerate",
+ "di un valore più {C:attention}alto{} o più {C:attention}basso",
+ "per ogni effetto dei {C:attention}Jolly{}",
+ },
+ },
+ b_mp_indigo = {
+ name = "Mazzo indigo",
+ text = {
+ "Scegli {C:attention}1{} carta extra",
+ "da ogni busta di espansione",
+ },
+ },
+ b_mp_orange = {
+ name = "Mazzo arancione",
+ text = {
+ "Inizi con una",
+ "{C:attention,T:p_mp_standard_giga}Busta standard giga{}, e",
+ "{C:attention}2{} copie de {C:tarot,T:c_hanged_man}Il matto",
+ },
+ },
+ b_mp_oracle = {
+ name = "Mazzo dell'oracolo",
+ text = {
+ "Inizi con {C:spectral,T:c_medium}Medium",
+ "e {C:attention,T:v_clearance_sale}Svendita.",
+ "I soldi sono limitati",
+ "a {C:money}$50",
+ },
+ },
+ b_mp_violet = {
+ name = "Mazzo violetto",
+ text = {
+ "{C:attention}+1{} Buono nel negozio.",
+ "Durante l'Ante {C:attention}1{}, i Buoni",
+ "hanno uno sconto del {C:attention}50%{}",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "Mazzo Heidelberg",
+ text = {
+ "Crea una copia {C:dark_edition}Negativa{} di",
+ "{C:attention}1{} carta {C:attention}consumabile",
+ "casuale in tuo possesso",
+ "alla fine del {C:attention}negozio",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Nemesi",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "La tua sola e unica Nemesi",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Busta standard giga",
+ text = {
+ "Scegli {C:attention}#1#{} tra massimo",
+ "{C:attention}#2#{} carte da {C:attention}gioco{} da",
+ "aggiungere al tuo mazzo",
+ "{C:attention}Non si può saltare{}",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "Puntata pianeta",
+ text = {
+ "il fratello più figo della {C:attention}Puntata arancione{}",
+ "che ti ha gentilmente reso il tuo {C:red}scarto",
+ "{C:red}come supporto emotivo{} perché non è così crudele",
+ },
+ },
+ stake_mp_spectral = {
+ name = "Puntata spettrale",
+ text = {
+ "Si applica alla {C:planet}Puntata pianeta{} e in più:",
+ "i Jolly {C:money}A noleggio{} appaiono nel negozio",
+ "Richiede scale di punteggio, più veloce per ogni {C:attention}Ante",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "Puntata spettrale+",
+ text = {
+ "Si applica alla {C:planet}Puntata spettrale{} e in più:",
+ "Richiede scale di punteggio",
+ "ancora più veloce per ogni {C:attention}Ante",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Fantasma",
+ },
+ dictionary = {
+ b_singleplayer = "Giocatore singolo",
+ b_join_lobby = "Unisciti ad una Lobby",
+ b_join_lobby_clipboard = "Unisciti dagli appunti",
+ b_return_lobby = "Ritorna alla Lobby",
+ b_reconnect = "Riconnettiti",
+ b_create_lobby = "Crea una Lobby",
+ b_start_lobby = "Start Lobby",
+ b_ready = "Pronto",
+ b_unready = "Non pronto",
+ b_leave_lobby = "Lascia Lobby",
+ b_mp_discord = "Server Discord di Balatro Multiplayer",
+ b_start = "START",
+ b_wait_for_host_start = {
+ "IN ATTESA",
+ "DELL'HOST",
+ },
+ b_wait_for_players = {
+ "IN ATTESA",
+ "DI GIOCATORI",
+ },
+ b_wait_for_guest_ready = {
+ "IN ATTESA",
+ "DEGLI OSPITI",
+ },
+ b_lobby_options = "OPZIONI LOBBY",
+ b_copy_clipboard = "Copia negli Appunti",
+ b_view_code = "RIVELA IL CODICE",
+ b_copy_code = "COPIA IL CODICE",
+ b_leave = "ESCI",
+ b_opts_cb_money = "Rimborsa 4$ per vita persa alla fine di ogni round",
+ b_opts_no_gold_on_loss = "Non dare i $ del buio se perdi il round",
+ b_opts_death_on_loss = "Perdi una vita in un round non PvP",
+ b_opts_start_antes = "Anti iniziali",
+ b_opts_diff_seeds = "I giocatori giocano in seed differenti",
+ b_opts_lives = "Vite",
+ b_opts_multiplayer_jokers = "Abilita carte multigiocatore",
+ b_opts_player_diff_deck = "I giocatori hanno mazzi diversi",
+ b_opts_normal_bosses = "Attiva l'effetto del buio Boss nei round PvP",
+ b_opts_timer = "Abilita Timer",
+ b_opts_disable_preview = "Disabilita l'anteprima del punteggio",
+ b_opts_the_order = 'Abilita il Jolly "L\'ordine"',
+ b_opts_legacy_smallworld = "Meccaniche legacy per Small World",
+ b_reset = "Reset",
+ b_set_custom_seed = "Imposta seed specifico",
+ b_mp_kofi_button = "supporta il creatore su Ko-fi",
+ b_unstuck = "Premi se sei bloccato",
+ b_unstuck_blind = "Bloccato in PvP esterno",
+ b_misprint_display = "Mostra la prossima carta nel mazzo",
+ b_players = "Giocatori",
+ b_lobby_info = "Info Lobby",
+ b_continue_singleplayer = "Continua in Giocatore singolo",
+ b_the_order_integration = 'Abilita il Jolly "L\'ordine"',
+ b_preview_integration = "Abilita l'anteprima del punteggio",
+ b_view_nemesis_deck = "Guarda mazzo",
+ b_toggle_jokers = "Mostra Jolly Nemesi",
+ b_skip_tutorial = "Salta Tutorial",
+ k_yes = "Sì",
+ k_no = "No",
+ k_are_you_sure = "Sei sicuro?",
+ k_has_multiplayer_content = "Contenuto Multiplayer",
+ k_forces_lobby_options = "Opzioni fisse",
+ k_forces_gamemode = "Modalità forzata",
+ k_values_are_modifiable = "* I valori sono modificabili",
+ k_rulesets = "Regole",
+ k_gamemodes = "Modalità",
+ k_competitive = "Competitive",
+ k_other = "Varie",
+ k_battle = "Battaglia",
+ k_challenge = "Sfide",
+ k_info = "Descrizione",
+ k_continue_singleplayer_tooltip = "Questo sovrascriverà la tua attuale partita in Giocatore singolo",
+ k_enemy_score = "Punteggio del Nemico",
+ k_enemy_hands = "Mani rimanenti del Nemico: ",
+ k_coming_soon = "In Arrivo!",
+ k_wait_enemy = "Attendi che il nemico finisca...",
+ k_wait_enemy_reach_this_blind = "Attendi che il nemico arrivi a questo buio...",
+ k_lives = "Vite",
+ k_lost_life = "Hai perso una vita",
+ k_total_lives_lost = " Vite perse in totale ($4 ciascuna)",
+ k_attrition_name = "Logoramento",
+ k_enter_lobby_code = "Inserisci codice Lobby",
+ k_paste = "Incolla dagli Appunti",
+ k_username = "Nome utente:",
+ k_enter_username = "Inserisci Nome utente",
+ k_customize_preview = "Personalizza il testo d'anteprima:",
+ k_join_discord = "Unisciti al ",
+ k_discord_msg = "Puoi segnalare bugs e trovare giocatori con la quale giocare lì",
+ k_enter_to_save = "Premi Invio per salvare",
+ k_in_lobby = "Nella Lobby",
+ k_connected = "Connesso al Servizio",
+ k_warn_service = "AVVISO: Impossibile Trovare Servizio Multigiocatore",
+ k_set_name = "Imposta il tuo nome utente nel menu principale! (Mods > Multiplayer > Configurazione)",
+ k_mod_hash_warning = "I giocatori hanno mod diverse o versioni di mod diverse! Questo può causare errori!",
+ k_steamodded_warning = "I giocatori hanno diverse versioni di Steamodded installate. Questo può causare ai semi di cambiare.",
+ k_warning_unlock_profile = 'ATTENZIONE! Stai giocando con un profilo non totalmente sbloccato!\nSe questa partita è classificata/torneo, per favore crea un nuovo profilo e premi "Sblocca tutto" nelle impostazioni del profilo',
+ k_warning_nemesis_unlock = "ATTENZIONE! Il tuo avversario sta giocando con un profilo non totalmente sbloccato!\nPer favore se questa partita è classificata/torneo AVVISALO ADESSO!",
+ k_warning_no_order = "Un giocatore ha l'opzione (Abilita il Jolly \"L'ordine\") abilitata, mentre l'altro no. Questo renderà i vostri seed diversi.",
+ k_warning_cheating1 = "È probabile che il tuo avversario stia barando.",
+ k_warning_cheating2 = "Se questa è una partita classificata per favore invia il messaggio '%s' in chat e poi apri un ticket in #support nel server discord",
+ k_warning_banned_mods = "Uno o più giocatori stanno usando mod bandite. Queste mod non sono permesse nelle partite classificate.",
+ k_message1 = "Un momento, mia madre ha scolato la pasta",
+ k_message2 = "Un secondo, devo andare a spegnere il ragù",
+ k_message3 = "Un momento, mi sta chiamando mamma",
+ k_message4 = "Torno subito, il mio gatto sta andando a fuoco",
+ k_message5 = "Aspetta, penso di aver lasciato acceso il forno",
+ k_message6 = "Fermo! La mia roccia domestica è appena scappata",
+ k_message7 = "Un secondo, le mie piante mi stanno chiedendo da bere",
+ k_message8 = "Torno subito, i miei calzini stanno complottando contro di me",
+ k_message9 = "Scusa, il mio WiFi sta avendo una crisi esistenziale",
+ k_lobby_options = "Opzioni Lobby",
+ k_connect_player = "Giocatori connessi:",
+ k_opts_only_host = "Solo l'host della Lobby può cambiare queste opzioni",
+ k_lobby_general = "Generale",
+ k_lobby_gameplay = "Gameplay",
+ k_lobby_modifiers = "Modificatori",
+ k_lobby_advanced = "Avanzate",
+ k_opts_pvp_start_round = "Il PVP inizia all'ante",
+ k_opts_pvp_timer = "Timer",
+ k_opts_showdown_starting_antes = "La resa dei conti inizia all'ante",
+ k_opts_pvp_timer_increment = "Incremento del timer",
+ k_opts_pvp_countdown_seconds = "Secondi del countdown PvP",
+ k_bl_life = "Vita",
+ k_bl_or = "o",
+ k_bl_death = "Morte",
+ k_bl_mostchips = "chi fa più punti vince",
+ k_current_seed = "Seed attuale: ",
+ k_random = "Casuale",
+ k_standard = "Standard",
+ k_sandbox = "Sandbox",
+ k_sandbox_description = "Delle regole identiche a quelle Blitz,\nma qualcuno ha dato il caffè alle carte e ora si sentono loquaci.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "Le regole vanilla rimuovono tutto il contenuto Multigiocatore,\npermettendoti di giocare il gioco com'era originariamente pensato.\n\nQueste regole includono alcune modifiche Multigiocatore\ncome il timer che può essere disabilitato nelle opzioni della Lobby",
+ k_blitz = "Blitz",
+ k_blitz_description = 'Le regole Blitz includono carte e caratteristiche che incitano ad \nusare il tempo come una risorsa.\n\nAlcune carte sono bilanciate per adattarle al Multigiocatore:\n- "Scheda non valida" è modificata\n- "Giustizia" è rimossa\n- Le "Carte di vetro" sono modificate\n\n(Guarda la scheda dei ban e delle aggiunte/modifiche per più info)',
+ k_traditional = "Tradizionale",
+ k_traditional_description = 'Le regole tradizionali rimuovono le carte del Multigiocatore\nche trattano il tempo come una risorsa,\npermettendoti un gioco ancora più metodico.\n\nAlcune carte sono bilanciate per adattarle al Multigiocatore:\n- "Scheda non valida" è modificata\n- "Giustizia" è rimossa\n- Le "Carte di vetro" sono modificate\n\n(Guarda la scheda dei ban e delle aggiunte/modifiche per più info)',
+ k_majorleague = "Lega Maggiore",
+ k_majorleague_description = "Queste sono le regole ufficiali per la Lega Maggiore di\nBalatro Multigiocatore.\n\nQueste regole sono identiche a quelle Vanilla con alcune eccezioni:\n- L'opzione (Abilita il Jolly \"L'ordine\") è disabilitata\n- Il timer è impostato su 180 secondi\n- La prima volta che il timer arriva a 0 non perderai una vita",
+ k_minorleague = "Lega Minore",
+ k_minorleague_description = "Queste sono le regole ufficiali per la Lega Minore di\nBalatro Multigiocatore.\n\nQueste regole sono identiche a quelle Vanilla con alcune eccezioni:\n- L'opzione (Abilita il Jolly \"L'ordine\") è abilitata\n- Il timer è impostato su 180 secondi\n- La prima volta che il timer arriva a 0 non perderai una vita",
+ k_ranked = "Classificata",
+ k_ranked_description = "Queste sono le regole ufficiali per le partite classificate di\nBalatro Multigiocatore.\n\nQueste regole sono identiche a quelle Blitz con alcune eccezioni:\n- L'opzione (Abilita il Jolly \"L'ordine\") è abilitata\n- Devi usare la versione di Steamodded consigliata",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Delle regole settimanali progettate da @dr_monty_the_snek nel\nserver discord, che sono state aggiunte alla mod definitivamente.\n\nQueste regole bandiscono 48 Jolly, consumabili, patti, ecc...",
+ k_attrition = "Logoramento",
+ k_attrition_description = "Dopo il primo ante ogni buio Boss è un buio Nemesi. Non c'è tempo per prepararsi. Questa modalità ti forza ad essere pronto alla battaglia sin dall'inizio.",
+ k_showdown = "Resa dei conti",
+ k_showdown_description = "Dopo i primi 2 anti, ogni buio è un buio Nemesi. Questa modalità ti dà tempo di prepararti prima della battaglia.",
+ k_survival = "Sopravvivenza",
+ k_survival_description = "Il giocatore che batte il buio più lontano vince. Non ci sono bui nemesi. Questa modalità è un test per la tua abilità per aumentare gradualmente il record di punteggio con le mani vanilla.",
+ k_weekly = "Settimanale",
+ k_weekly_description = "Delle regole speciali che cambiano ad intervallo settimanale o bisettimanale. Immagino dovrai scoprire cosa fanno! Attualmente: ",
+ k_smallworld = "Small World",
+ k_smallworld_description = "Delle regole pesantemente sperimentali, dove 3/4 di tutto è bandito\n a caso per qualche motivo",
+ k_destabilized = "Destabilizzato",
+ k_oops_ex = "Aww, dang it!",
+ k_asteroids = "Asteroidi",
+ k_amount_short = "Qt.",
+ k_filed_ex = "Archiviato!",
+ k_timer = "Timer",
+ k_mods_list = "Lista Mods",
+ k_enemy_jokers = "Jolly della Nemesi",
+ k_your_jokers = "I tuoi Jolly",
+ k_nemesis_deck = "Mazzo della Nemesi",
+ k_your_deck = "Il tuo mazzo",
+ k_the_order_credit = "*Crediti a @MathIsFun_",
+ k_the_order_integration_desc = "Questo modificherà la creazione delle carte in modo che non sia basata sull'ante ma utilizzi una singola riserva per ogni tipo/rarità",
+ k_preview_credit = "*Crediti a @Fantom, @Divvy",
+ k_preview_integration_desc = "Questo abiliterà l'anteprima del punteggio prima di giocare una mano",
+ k_requires_restart = "*Richiede un riavvio per avere effetto",
+ k_new_weekly_ruleset = "Delle nuove regole settimanali sono disponibili!",
+ k_currently_colon = "Attualmente: ",
+ k_sync_locally = "Sincronizza localmente (riavvia il gioco)",
+ k_bans = "Ban",
+ k_reworks = "Aggiunte/Modifiche",
+ k_ruleset_disabled_the_order_required = '"L\'ordine" è necessario',
+ k_ruleset_disabled_the_order_banned = '"L\'ordine" è bandito',
+ k_ruleset_not_found = "Regole sconosciute",
+ k_tutorial_not_complete = "Devi completare il tutorial prima di poter giocare il Multigiocatore",
+ k_created_by = "Creato da",
+ k_major_contributors = "Maggiori contribuzioni da",
+ ml_enemy_loc = {
+ "Posizione",
+ "nemico",
+ },
+ ml_mp_kofi_message = {
+ " ",
+ " ",
+ "Questa mod e server è sviluppata e mantenuta",
+ "da una persona, se ti è piaciuta considera",
+ },
+ ml_lobby_info = {
+ "Info",
+ "Lobby",
+ },
+ loc_ready = "Pronto per il PvP",
+ loc_selecting = "Selezionando un buio",
+ loc_shop = "Facendo acquisti",
+ loc_playing = "Giocando ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Disegno: #1#",
+ },
+ a_mp_code = {
+ "Codice: #1#",
+ },
+ a_mp_idea = {
+ "Idea: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Bui Saltati Avanti",
+ },
+ a_mp_skips_behind = {
+ "#1# Bui Saltati Indietro",
+ },
+ a_mp_skips_tied = {
+ "Uguali",
+ },
+ k_banned_objs = "#1# Banditi",
+ k_no_banned_objs = "Non ci sono #1# Banditi",
+ k_reworked_objs = "#1# Aggiunti/Modificati",
+ k_no_reworked_objs = "Non ci sono #1# Aggiunti/Modificati",
+ k_ruleset_disabled_smods_version = "Versione SMODS #1# necessaria",
+ k_failed_to_join_lobby = "Errore nel connettersi alla lobby: #1#",
+ k_ante_number = "Ante #1#",
+ k_ante_range = "Ante #1#-#2#", --Per esempio, "Ante 1-2"
+ k_ante_min = "Ante #1#+", --Per esempio, "Ante 2+"
+ k_credits_list = "#1# e altri ancora!", --#1# viene rimpiazzato con una lista di nomi
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Scheda non valida{} è {C:dark_edition}modificata",
+ },
+ ch_c_glass_cards_rework = {
+ "Le {C:attention}Carte di vetro{} sono {C:dark_edition}modificate",
+ },
+ ch_c_mp_score_instability = {
+ "Il punteggio sbilanciato è {C:purple}destabilizzato{} ulteriormente:",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(ex: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} -> {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:inactive}Minimo di {C:attention}1 {C:mult}Molt",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:inactive}Minimo di {C:attention}0 {C:chips}Fiche",
+ },
+ ch_c_mp_ante_scaling = {
+ "{C:red}X#1#{} dimensione base del Buio",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Standard",
+ c_mp_sandbox = "Sandbox",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Torneo",
+ c_mp_weekly = "Settimanale",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Mazzo con errori di stampa",
+ c_mp_legendaries = "Legendari",
+ c_mp_psychosis = "Psicosi",
+ c_mp_scratch = "Da zero",
+ c_mp_twin_towers = "Torri gemelle",
+ c_mp_in_the_red = "In rosso",
+ c_mp_paper_money = "La radice di ogni male",
+ c_mp_high_hand = "Mano alta",
+ c_mp_chore_list = "Lista dei lavoretti",
+ c_mp_oops_all_jokers = "Oops! Tutti Jolly",
+ c_mp_divination = "Divinazione",
+ c_mp_skip_off = "Salta tutto",
+ c_mp_lets_go_gambling = "Let's go gambling!",
+ c_mp_speed = "Velocità",
+ c_mp_balancing_act = "Atto di bilanciamento",
+ },
+ },
+}
diff --git a/localization/ja.lua b/localization/ja.lua
new file mode 100644
index 00000000..ec4cfe09
--- /dev/null
+++ b/localization/ja.lua
@@ -0,0 +1,784 @@
+-- Localization by @koukichi_kkc
+-- 気になる点がありましたらサーバー内でもDMでもお気軽にご相談ください!
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "ギャンブルタグ",
+ text = {
+ "次回のジョーカーの商品を、{C:green}#2# 分の #1#{} の確率で",
+ "無料の {C:red}レアジョーカー{} にする",
+ },
+ },
+ },
+ Joker = {
+ j_broken = {
+ name = "エラー",
+ text = {
+ "このカードは現在使用しているMODのバージョンでは未実装、",
+ "またはデータが壊れている可能性があります",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "盾役ジョーカー",
+ text = {
+ "相手より {C:red,E:1}ライフ{} が少ないとき",
+ "差1つにつき",
+ "チップ {C:chips}+#1#{}",
+ "{C:inactive}(現在 チップ {C:chips}+#2#{C:inactive})",
+ "{C:inactive}(チップ数はステークにより異なる)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "おサボり",
+ text = {
+ "{C:attention}ブラインド{} をスキップした回数が",
+ "{X:purple,C:white}相手{} と比べて1つ多くなる毎に",
+ "ハンド {C:blue}+#1#{} ディスカード {C:red}+#2#{} ",
+ "{C:inactive}(現在 {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive})",
+ "{C:inactive}({X:purple,C:white}相手{} {C:inactive}#5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let’s ギャンブル!",
+ text = {
+ "{C:green}#2#分の#1#{} の確率で",
+ "{X:mult,C:white}X#3#{} と {C:money}$#4#{} 獲得する",
+ "PvPブラインド中では",
+ "{C:green}#6#分の#5#{} の確率で",
+ "{X:purple,C:white}相手{} が {C:money}$#7#{} 獲得する",
+ "こともある",
+ },
+ },
+ j_mp_speedrun = {
+ name = "タイムアタック",
+ text = {
+ "{X:purple,C:white}相手{} より先に {C:attention}PvPブラインド{} に",
+ "到達した場合、{C:spectral}スペクトル{}カードを1つ作る",
+ "{C:inactive}(空きが必要)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "結合双生ジョーカー",
+ text = {
+ "{C:attention}PvPブラインド{} で",
+ "{X:purple,C:white}相手{} の残り{C:blue}ハンド{} 1つにつき",
+ "倍率 {X:mult,C:white}X#1#{}",
+ "{C:inactive}(最大 倍率 {X:mult,C:white}X#2#{C:inactive})",
+ "{C:inactive}(現在 倍率 {X:mult,C:white}X#3#{C:inactive})",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "守銭奴",
+ text = {
+ "ラウンド終了時",
+ "{X:purple,C:white}相手{} が直近のショップで消費したお金",
+ "{C:money}$#2#{} ごとに {C:money}$#1#{} 獲得する",
+ },
+ },
+ j_mp_taxes = {
+ name = "税務係",
+ text = {
+ "{X:purple,C:white}相手{} がジョーカーを売るたびに",
+ "倍率 {C:mult}+#1#{}",
+ "{C:inactive}(現在 {C:mult}+#2#{C:inactive})",
+ },
+ },
+ j_mp_magnet = {
+ name = "マグネット",
+ text = {
+ "{C:attention}#1#{} ラウンド後",
+ "このジョーカーを売ると",
+ "{X:purple,C:white}相手{} の最も売値が高い {C:attention}ジョーカー{}を {C:attention}複製{} する",
+ "{C:inactive}(現在 {C:attention}#2#{C:inactive}/#3#)",
+ },
+ },
+ j_mp_pizza = {
+ name = "ピッツァ",
+ text = {
+ "自分のディスカード{C:red}+#1#{}",
+ "{X:purple,C:white}相手{} のディスカード {C:red}+#2#{}",
+ "{C:inactive}({C:attention}PvPブラインド{} {C:inactive}終了後に消滅する)",
+ },
+ },
+ j_mp_pacifist = {
+ name = "平和主義者",
+ text = {
+ "{C:attention}PvPブラインドでない{} とき",
+ "倍率 {X:mult,C:white}X#1#{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "ハンギングチャド",
+ text = {
+ "プレイしたカードで、",
+ "{C:attention}最初{} と {C:attention}2番目{} にスコアされたものを",
+ "再発動する",
+ },
+ },
+ j_mp_bloodstone = {
+ name = "ブラッドストーン",
+ text = {
+ "出した {C:hearts}ハートのカード{} が",
+ "得点されるたびに",
+ "{C:green}#2#分の#1#{} の確率で 倍率 {X:mult,C:white}X#3#{}",
+ },
+ },
+ j_mp_cloud_9 = {
+ name = "クラウド9",
+ text = {
+ "ラウンド終了時",
+ "初期デッキにある {C:attention}9のカード{}1枚につき {C:money}$1{} {C:inactive}(最高 {C:money}$4{}{C:inactive})",
+ "プレイ中に追加した{C:attention}9のカード{} 1枚につき {C:money}$#1#{}獲得する",
+ "{C:inactive}(現在合計 {C:money}$#2#{}{C:inactive})",
+ },
+ },
+ j_mp_magnet_sandbox = {
+ name = "マグネット",
+ text = {
+ "{C:attention}#1#{} ラウンド後",
+ "このジョーカーを売ると",
+ "{X:purple,C:white}相手{} の最も売値が高い {C:attention}ジョーカー{}を {C:attention}複製{} する",
+ "ただし {C:attention}#3#{} ラウンド後には",
+ "磁性が壊れて {C:attention}ただのゴミになってしまう!",
+ "{C:inactive}(あと {C:attention}#2#/#1# {C:inactive})",
+ },
+ },
+ j_mp_cloud_9_sandbox = {
+ name = "ぶっトぶ快感",
+ text = {
+ "",
+ "私は {C:attention}9{} を専門に栽培している農家だ。",
+ "キミも一緒に {C:attention}育てないか?",
+ "{C:attention}稼ぎはいいぞ?",
+ "{C:inactive}({C:green}#2# 分の #1#{} {C:inactive}の確率)",
+ "{C:inactive}(現在 {C:money}$#3#{}{C:inactive})",
+ },
+ },
+ j_mp_lucky_cat_sandbox = {
+ name = "いたずら招き猫",
+ text = {
+ "ラッキーカードでお金を当ててくれるのはうれしいのニャ!",
+ "でももっと高得点もとってほしいニャ...",
+ "そうニャ! {C:attention}発動に成功したらガラスカードにしてあげる{} ニャ!",
+ "喜んでもらえること間違いなしなのニャ!",
+ "{C:inactive}(現在 倍率 {X:mult,C:white} X#2# {C:inactive})",
+ },
+ },
+ j_mp_constellation_sandbox = {
+ name = "きみなにざっち",
+ text = {
+ "キミ何座?",
+ "たまごっちみたいに {C:attention}ごはんをあげ続けないと",
+ "{C:attention}どんどん弱っていっちゃうよ!",
+ "{C:inactive}(現在 倍率 {X:mult,C:white} X#1# {C:inactive})",
+ },
+ },
+ j_mp_bloodstone_sandbox = {
+ name = "ブラッドストーン",
+ text = {
+ "初心に帰って楽しもう!",
+ "リリース開始時に割と不評だった効果に逆戻りだ!",
+ "{C:hearts}ハートのカード{} {C:inactive}がスコアされた時、",
+ "{C:inactive}{C:green}#2# 分の #1#{} {C:inactive}の確率で 倍率 {X:mult,C:white} X2 ",
+ },
+ },
+ j_mp_juggler_sandbox = {
+ name = "ジャグラー",
+ text = {
+ "ジャグリングは自分ができる限界の個数で",
+ "キメるのが一番カッコイイと思ってるんだ!",
+ "{C:attention}キミは5枚が限界なんだろ?{} 僕に見せてよ!",
+ "{C:attention}手を抜いたら帰っちゃうからね!",
+ "{C:inactive}(現在 ハンドサイズ {C:attention}+#1#{C:inactive})",
+ },
+ },
+ j_mp_mail_sandbox = {
+ name = "迷惑されメール",
+ text = {
+ "油性ペンで {C:attention}#2#{} と書かれている...",
+ "誰かのイタズラで対象の数字が固定されてしまった!!",
+ },
+ },
+ j_mp_hit_the_road_sandbox = {
+ name = "道路工事看板",
+ text = {
+ "{X:blue,C:white}ご迷惑をおかけします{}",
+ "{C:blue}ジャック のカードで",
+ "{C:blue}道路をつくっています",
+ "{C:blue}20XX 年 〇 月 × 日 まで",
+ "{C:blue}時間帯 8:00 ~ 17:00",
+ "{C:inactive}(現在 倍率{X:mult,C:white} X#2# {C:inactive})",
+ },
+ },
+ j_mp_misprint_sandbox = {
+ name = "ミスプリント宝くじ",
+ text = {
+ "ちょっと運試し!",
+ "{C:attention}買わないと倍率の値がいくつかわからないぞ!",
+ "{C:inactive}倍率 ({V:1}#1#{C:inactive})",
+ },
+ },
+ j_mp_castle_sandbox = {
+ name = "チャペル",
+ text = {
+ "スーツをテーマにした結婚式のご依頼をいただいた際には、",
+ "花びらの代わりに {V:1}#1#のカード{} {C:attention}を捨てて{} シャワーをいたしました。",
+ "それがお二人にとっての一番の愛の贈り物なのですから。",
+ "{C:inactive}(現在 チップ {C:chips}+#2#{C:inactive})",
+ },
+ },
+ j_mp_runner_sandbox = {
+ name = "熱血ランナー",
+ text = {
+ "そこのキミ! 最高の役って何だと思う?",
+ "やっぱストレートだよな! 分かってんな~お前!",
+ "じゃあ、{C:attention}ストレート以外出されても助けてやんねぇから!",
+ "よろしくな!",
+ "{C:inactive}(現在 チップ {C:chips}+#1#{C:inactive})",
+ },
+ },
+ j_mp_order_sandbox = {
+ name = "反フェイス同盟「オーダー」",
+ text = {
+ "{C:attention}フェイスカードなんてなくても",
+ "{C:attention}私たち数字がストレートになって力を合わせれば",
+ "きっとこの勝負に勝てるんだ!!",
+ },
+ },
+ j_mp_photograph_sandbox = {
+ name = "人見知りな写真家",
+ text = {
+ "私は人物の写真には一際自信があるんですが、",
+ "{C:attention}一度に2人以上で来られると全く力が出なくて...",
+ "ご希望の際はお一人ずつでお願いします",
+ },
+ },
+ j_mp_ride_the_bus_sandbox = {
+ name = "リフジンバス",
+ text = {
+ "本日も当バスをご利用いただきありがとうございます。",
+ "当バスでは、{C:attention}お客様のフェイスカードのスコアを確認した場合、",
+ "{C:attention}バスが消滅いたします。",
+ "ご理解、ご協力をお願いいたします。",
+ "{C:inactive}(現在 倍率 {C:mult}+#1#{C:inactive})",
+ },
+ },
+ j_mp_loyalty_card_sandbox = {
+ name = "偽りのポイントカード",
+ text = {
+ "{C:attention}連続での役のご利用{} でポイントが貯まります!",
+ "今回の役は {C:attention}#1#{} !",
+ "{C:inactive}注意:効果が発動するまでの回数の表記に",
+ "{C:inactive}不具合が発生しています",
+ "{C:inactive}(発動まで {C:attention}#2#/#3#{} {C:inactive})",
+ },
+ },
+ j_mp_faceless_sandbox = {
+ name = "フェイスカードソムリエ",
+ text = {
+ "よりプレミアムなプレイ体験をお楽しみいただくため、",
+ "{C:attention}フェイスカード3種類を一度に回収させて頂いた場合にのみ",
+ "{C:attention}通常より多額の資金{} をご提供しております。",
+ },
+ },
+ j_mp_square_sandbox = {
+ name = "四角形信者",
+ text = {
+ "四角形は最もバランスのとれた神聖な形です。",
+ "四方位、四季、四肢、キリストの十字架など、",
+ "この世は全て4の要素からなる物から構成されているのです。",
+ "{C:attention}4枚ずつ出していただかなければ助力は致しません。",
+ "{C:inactive}(現在 チップ {C:chips}+#1#{C:inactive})",
+ },
+ },
+ j_mp_throwback_sandbox = {
+ name = "迷惑系Youtuber",
+ text = {
+ "オレサマは逃げることでメシを食ってるんだ!",
+ "{C:attention}大胆な逃げ方をすればするほど数が伸びて",
+ "サイコーにアガるんだよ!!",
+ "{C:inactive}(現在 倍率 {X:mult,C:white} X#1# {C:inactive})",
+ },
+ },
+ j_mp_vampire_sandbox = {
+ name = "異界の銀行員",
+ text = {
+ "お前のデッキの {C:attention}便利なカードを石に{} して",
+ "俺らの通貨として使ってやる!!",
+ "{C:inactive}(現在 倍率{X:mult,C:white} X#2# {C:inactive})",
+ },
+ },
+ j_mp_baseball_sandbox = {
+ name = "ベースボールカード",
+ text = {
+ "ちょっとワクワクした?",
+ "{C:attention}普通のとなーんにも変わってないよ!",
+ },
+ },
+ j_mp_steel_joker_sandbox = {
+ name = "ベテランのスチール検品係",
+ text = {
+ "{C:attention}出してくれたスチールカードの",
+ "{C:attention}二重チェック{}は欠かさないぞ!",
+ },
+ },
+ j_mp_satellite_sandbox = {
+ name = "サテライト",
+ text = {
+ "この人工衛星は {C:attention}惑星カードを燃料に",
+ "資金を提供しております。",
+ "ご協力をお願いいたします。",
+ "{C:inactive}(現在 {C:money}$#1#{C:inactive})",
+ },
+ },
+ j_mp_error_sandbox = {
+ name = "繧ク繝ァ繝シ繧ォ繝シ",
+ text = {
+ -- "PREVIEW DISABLED",
+ "{X:purple,C:white,s:0.85}なニカが{} {X:purple,C:white,s:0.85}おカしイ...",
+ -- "PREVIEW DISABLED",
+ -- "PREVIEW DISABLED",
+ -- "{C:inactive}(CURRENTLY {C:money}$7{C:inactive})",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "小惑星",
+ text = {
+ "{X:purple,C:white}相手{} の一番高い",
+ "{C:legendary,E:1}役{} のレベルを #1# 下げる",
+ "{C:inactive}({C:attention}PvPブラインド開始時{} {C:inactive}に効果が発動する)",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "ライバル",
+ text = {
+ "スコアの高い方が勝ち!",
+ "負けるとライフを1失う",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "ファントム",
+ text = {
+ "{C:attention}エターナル{} と {C:dark_edition}ネガティブ{} を併せ持つ",
+ "作成も破壊も {X:purple,C:white}相手{} にしかできない",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "グラスカード",
+ text = {
+ "倍率 {X:mult,C:white} X#1# {}",
+ "ただし、{C:green}#3#分の#2#{} の確率で",
+ "破壊されてしまう",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "グラスカード",
+ text = {
+ "倍率 {X:mult,C:white} X#1# {}",
+ "ただし、{C:green}#3#分の#2#{} の確率で",
+ "破壊されてしまう",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "カクテルデッキ",
+ text = {
+ "他のデッキの効果を",
+ "ランダムに {C:attention}3つ{} 付与する",
+ },
+ },
+ b_mp_gradient = {
+ name = "グラデーションデッキ",
+ text = {
+ "数字によって発動する",
+ "ジョーカーの条件に対して、",
+ "トランプの数字の {C:attention}誤差が1{} あっても",
+ "発動するようになる",
+ },
+ },
+ b_mp_indigo = {
+ name = "あい色デッキ",
+ text = {
+ "ブースターパック開封時の",
+ "選択可能枚数 {C:attention}+1",
+ },
+ },
+ b_mp_orange = {
+ name = "オレンジデッキ",
+ text = {
+ "{C:attention,T:p_mp_standard_giga}ギガスタンダードパック{} を開封し、",
+ "{C:tarot,T:c_hanged_man}吊された男{} を {C:attention}2枚{} 持った状態で",
+ "ゲームスタート",
+ },
+ },
+
+ b_mp_oracle = {
+ name = "神託デッキ",
+ text = {
+ "{C:spectral,T:c_medium}ミディアム{} と {C:attention,T:v_clearance_sale}クリアランスセール{} を",
+ "持った状態でゲームスタート",
+ "所持金を最大 {C:money}$50{} までしか",
+ "持つことができない",
+ },
+ },
+ b_mp_sibyl = {
+ name = "古の巫女デッキ",
+ text = {
+ "{C:spectral,T:c_medium}ミディアム{} を持った状態で",
+ "ゲームスタート",
+ "その他の {C:spectral}スペクトルカード{}、{C:planet}惑星カード{}、",
+ "{C:attention}スタンダードパック{} は一切登場しない",
+ },
+ },
+ b_mp_violet = {
+ name = "バイオレットデッキ",
+ text = {
+ " バウチャーの商品数 {C:attention}+1{}",
+ "{C:attention}アンティ1{} の時のみ",
+ "バウチャーの値段が {C:attention}50%OFF{}",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "ペルケオデッキ",
+ text = {
+ "{C:attention,T:j_perkeo}ペルケオ{} の効果を持つ",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "相手",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "キミの唯一無二のライバルだ。",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "ギガスタンダードパック",
+ text = {
+ "{C:attention}#2#枚{} の {C:attention}トランプ{} の中から",
+ "{C:attention}#1#枚{} 選んでデッキに追加することができる",
+ "{C:inactive}(スキップすることはできない)",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "プラネットステーク",
+ text = {
+ "{C:attention}オレンジステーク{} のアニキ",
+ "{C:blue}ブルーステーク{} のディスカード {C:red}-1{} を",
+ "ナシにしてくれる優しいヤツ",
+ },
+ },
+ stake_mp_spectral = {
+ name = "スペクトルステーク",
+ text = {
+ "{C:planet}プラネットステーク{} の効果に加え、",
+ "ショップに {C:money}レンタルジョーカー{} が並ぶことがある",
+ "ノルマスコアの上昇がゴールドステークより大きくなる",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "スペクトルステーク+",
+ text = {
+ "{C:planet}スペクトルステーク{} の効果に加え、",
+ "ノルマスコアの上昇がさらに大きくなる",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "ファントム",
+ },
+ dictionary = {
+ b_singleplayer = "シングルプレイ",
+ b_join_lobby = "ロビーに参加",
+ b_join_lobby_clipboard = "クリップボードから参加",
+ b_return_lobby = "ロビーに戻る",
+ b_reconnect = "再接続",
+ b_create_lobby = "ロビーの作成",
+ b_start_lobby = "このモードで作成",
+ b_ready = "準備OK!",
+ b_unready = "解除",
+ b_leave_lobby = "タイトルへ戻る",
+ b_mp_discord = "公式Discordに参加",
+ b_start = "スタート",
+ b_wait_for_host_start = {
+ "ホストが開始するまで",
+ "お待ちください",
+ },
+ b_wait_for_players = {
+ "参加者を",
+ "待っています...",
+ },
+ b_wait_for_guest_ready = {
+ "全員の準備完了まで",
+ "お待ちください",
+ },
+ b_lobby_options = "ロビー設定",
+ b_copy_clipboard = "クリップボードにコピー",
+ b_view_code = "ロビーIDを表示",
+ b_copy_code = "コピー",
+ b_leave = "タイトルへ戻る",
+ b_opts_cb_money = "ライフ減少時に$を受け取る",
+ b_opts_no_gold_on_loss = "通常ラウンドでノルマ未達の場合にブラインド報酬を受け取らない",
+ b_opts_death_on_loss = "通常ラウンドでノルマ未達の場合にライフを1失う",
+ b_opts_start_antes = "PvP開始アンティ",
+ b_opts_diff_seeds = "お互いに別シードでプレイ",
+ b_opts_lives = "ライフ",
+ b_opts_multiplayer_jokers = "マルチプレイオリジナルカードを有効",
+ b_opts_player_diff_deck = "お互いに別デッキ、別ステークでプレイ",
+ b_opts_normal_bosses = "通常のボスブラインドの制限ありでPvPを行う",
+ b_opts_timer = "タイマーを使用する",
+ b_opts_disable_preview = "電卓MODを無効",
+ b_opts_the_order = "「The Order」MODを有効",
+ b_reset = "リセット",
+ b_set_custom_seed = "シード値を指定",
+ b_mp_kofi_button = "サポートページへ",
+ b_unstuck = "詰み防止処置(β)",
+ b_unstuck_blind = "PvPブラインドに進まなかったとき",
+ b_misprint_display = "山札の一番上のカードを表示",
+ b_players = "プレイヤー",
+ b_lobby_info = "ロビー情報",
+ b_continue_singleplayer = "シングルプレイで再開",
+ b_the_order_integration = "「The Order」MODを有効",
+ b_preview_integration = "電卓MODを有効",
+ b_view_nemesis_deck = "デッキを見る",
+ b_toggle_jokers = "ジョーカー切替",
+ b_skip_tutorial = "チュートリアルをスキップ",
+ k_yes = "はい",
+ k_no = "いいえ",
+ k_are_you_sure = "本当によろしいですか?",
+ k_has_multiplayer_content = "マルチプレイオリジナルアイテム",
+ k_forces_lobby_options = "ロビー設定の強制",
+ k_forces_gamemode = "ゲームモードの強制",
+ k_values_are_modifiable = "※PvP開始アンティは設定で変更可能です",
+ k_rulesets = "ルールセット",
+ k_gamemodes = "ゲームモード",
+ k_competitive = "バトル",
+ k_other = "その他",
+ k_battle = "バトル",
+ k_challenge = "チャレンジ",
+ k_info = "説明",
+ k_continue_singleplayer_tooltip = "シングルプレイの中断データは失われます",
+ k_enemy_score = "相手のスコア",
+ k_enemy_hands = "相手の残りハンド ",
+ k_coming_soon = "Coming Soon!",
+ k_wait_enemy = "相手のプレイが終わるまでお待ちください...",
+ k_wait_enemy_reach_this_blind = "相手の結果をお待ちください...",
+ k_lives = "ライフ",
+ k_lost_life = "ライフ減少",
+ k_total_lives_lost = " 失ったライフ数 (1つにつき$4)",
+ k_attrition_name = "消耗戦",
+ k_enter_lobby_code = "ロビーIDを入力",
+ k_paste = "クリップボードからペースト",
+ k_username = "ユーザーネーム",
+ k_enter_username = "ニックネームを入力",
+ k_customize_preview = "電卓機能のテキスト変更",
+ k_join_discord = "Balatro Multiplayer Discordサーバー",
+ k_discord_msg = "MODについての最新情報をお届け中!",
+ k_enter_to_save = "Enterで保存",
+ k_in_lobby = "ロビー内",
+ k_connected = "マルチプレイサービス接続中",
+ k_warn_service = "マルチプレイサービスが見つかりません。",
+ k_set_name = "メインメニューからユーザーネームを設定できます。 (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "異なるバージョンのMultiPlayerModを利用しているユーザーがいるため、不具合が発生する可能性があります",
+ k_steamodded_warning = "異なるバージョンのSteamoddedをインストールしているプレイヤーがいます。このままスタートすると、一部プレイヤーが別のシードでプレイすることになります。",
+ k_warning_unlock_profile = "現在使用しているプロフィールは、全コレクションがアンロックされていません。新しいプロフィールを作成し、プロフィール設定ですべてのアンロックを解除したデータを使用してください。",
+ k_warning_nemesis_unlock = "あなたの対戦相手は、全コレクションがアンロックされていないプロフィールで参加しています。新しいプロフィールを作成し、プロフィールの設定ですべてのアンロックを解除するよう指示してください。",
+ k_warning_no_order = "参加者の全員が「The Order MOD」を有効、または無効にしていません。このままスタートすると、一部プレイヤーが別のシードでプレイすることになります。",
+ k_warning_cheating1 = "これが表示される場合、対戦相手が不正を行っている可能性があります。",
+ k_warning_cheating2 = "ランク戦であれば、Discordサーバーにて「%s」というメッセージを送り、supportチャンネルでサポートチケットを開いて報告してください。",
+ k_message1 = "Hold on, my mom made pizza pops",
+ k_message2 = "One sec, i gotta grab my slow cooker pork roast",
+ k_message3 = "One moment, getting a call from my mom",
+ k_message4 = "Brb, my cat is on fire",
+ k_message5 = "Wait, I think I left the stove on",
+ k_message6 = "Hold up, my pet rock just ran away",
+ k_message7 = "One sec, my plants are asking for water",
+ k_message8 = "Brb, my socks are plotting against me",
+ k_message9 = "Sorry, my WiFi is having an existential crisis",
+ k_lobby_options = "ロビー設定",
+ k_connect_player = "参加者一覧",
+ k_opts_only_host = "設定を変更できるのはホストのみです",
+ k_opts_gm = "詳細設定",
+ k_lobby_general = "一般",
+ k_lobby_gameplay = "ゲーム設定",
+ k_lobby_modifiers = "詳細設定",
+ k_lobby_advanced = "高度な設定",
+ k_opts_pvp_start_round = "PvP初戦アンティ",
+ k_opts_pvp_timer = "タイマーの秒数",
+ k_opts_showdown_starting_antes = "PvP初戦アンティ(バーサスルール限定)",
+ k_opts_pvp_timer_increment = "ブラインドスキップ時のタイマー追加秒数",
+ k_opts_pvp_countdown_seconds = "PvP開始時のカウントダウンタイマー",
+ k_bl_life = "Life",
+ k_bl_or = "or",
+ k_bl_death = "Death",
+ k_bl_mostchips = "(PvPブラインド)",
+ k_current_seed = "シード値: ",
+ k_random = "ランダム",
+ k_standard = "スタンダード",
+ k_standard_description = "マルチプレイオリジナルカードやPvPブラインドが追加された標準的なルールです。",
+ k_sandbox = "サンドボックス",
+ k_sandbox_description = "ちょっと一息。このモードでは開発中の新アイテムをお試しできます。\n(詳細については「追加、修正されたアイテム」のタブをご確認ください)",
+ k_vanilla = "バニラ",
+ k_vanilla_description = "マルチプレイオリジナルカードやカードの調整なしのルールです。",
+ k_blitz = "クイック",
+ k_blitz_description = "リアルタイムで効果を及ぼすジョーカー(「タイムアタック」「結合双生ジョーカー」)\nが出現するルールです。\nスピードを重視したプレイをしたい人におすすめです。\n\nこのルールセットでは、マルチプレイのメタ戦術緩和のため一部のカードが調整されています。\n・ジョーカー「ハンギングチャド」の調整\n・タロット「正義」の削除\n・ガラスカードの倍率の調整\n\n(詳細については「出現しないアイテム」と「追加、修正されたアイテム」のタブをご確認ください)",
+ k_traditional = "じっくり",
+ k_traditional_description = "リアルタイムで効果を及ぼすジョーカー(「タイムアタック」「結合双生ジョーカー」)\nが出現しないルールです。\nある程度自分のペースで戦略を立ててプレイしたい人におすすめです。\n\nこのルールセットでは、マルチプレイのメタ戦術緩和のため一部のカードが調整されています。\n・ジョーカー「ハンギングチャド」の調整\n・タロット「正義」の削除\n・ガラスカードの倍率の調整\n\n(詳細については「出現しないアイテム」と「追加、修正されたアイテム」のタブをご確認ください)",
+ k_majorleague = "メジャーリーグ",
+ k_majorleague_description = "海外のBalatro配信者、ZainoTVさんが主催するトーナメント大会\n「Major League Balatro」の公式ルールセットです。\n\nこのルールセットは、下記のルールを除き「バニラ」ルールと同じです。\n・The Order MOD禁止\n・タイマー 180秒\n・初めてタイマーが0秒に達した際はライフを失わない",
+ k_minorleague = "マイナーリーグ",
+ k_minorleague_description = "海外のBalatro配信者、ZainoTVさんが主催するトーナメント大会\n「Minor League Balatro」の公式ルールセットです。\n\nこのルールセットは、下記のルールを除き「バニラ」ルールと同じです。\n・The Order MOD必須\n・タイマー 180秒\n・初めてタイマーが0秒に達した際はライフを失わない",
+ k_ranked = "レート戦",
+ k_ranked_description = "公式Discord内で遊べるレート戦で使用する公式ルールセットです。\n\nこのルールセットは、下記のルールを除き「クイック」ルールと同じです。\n・The Order MODが必須\n・推奨されているバージョンのSteamoddedが必須\n\n推奨バージョンについては公式Discordの「updates」チャンネルをご確認ください。\n(省略表記として「smods」と書かれている場合もあります)",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "DiscordサーバーでDr.Monty(@dr_monty_the_snek)さんが作成したルールです。\nかなり多くの強力なカードが出現しなくなっています。",
+ k_attrition = "消耗戦",
+ k_attrition_description = "アンティ2以降のボスブラインドがPvPブラインドになっている状態で戦うモードです。",
+ k_showdown = "バーサス",
+ k_showdown_description = "アンティ2のボスブラインド以降の全てのブラインドがPvPブラインドになっている状態で戦うモードです。",
+ k_survival = "サバイバル",
+ k_survival_description = "PvPブラインドがないモードです。\n通常のBalatroと同じルールで、相手がクリアしたブラインドより先のブラインドをクリアした方の勝ちです。",
+ k_weekly = "ウィークリー",
+ k_weekly_description = "1~2週間ごとに変更される特別なルールセットです。どんなルールかは見てからのお楽しみ! 現在 ",
+ k_smallworld = "小さな世界",
+ k_smallworld_description = "ゲーム内のほぼすべての要素のうち、4分の3がランダムに出現しなくなるルールです。(β)",
+ k_destabilized = "アンバランス",
+ k_oops_ex = "ハズレ!",
+ k_asteroids = "小惑星",
+ k_amount_short = "amt.",
+ k_filed_ex = "提出しました!",
+ k_timer = "タイマー",
+ k_mods_list = "使用しているMOD",
+ k_enemy_jokers = "相手のジョーカー",
+ k_your_jokers = "自分のジョーカー",
+ k_nemesis_deck = "相手のデッキ",
+ k_your_deck = "自分のデッキ",
+ k_the_order_credit = "製作 @MathIsFun_",
+ k_the_order_integration_desc = "ショップに並ぶカードの内部テーブルがアンティごとに変更ではなく、常に1つの内部テーブルだけを使用するMODが適用されます",
+ k_preview_credit = "製作 @Fantom, @Divvy",
+ k_preview_integration_desc = "カードを出す前にスコアがわかるようになります (確率を含むカードがある場合は最小値と最大値が表示されます)",
+ k_requires_restart = "有効にするには再起動が必要です",
+ k_new_weekly_ruleset = "ウィークリールールセットが新登場!",
+ k_currently_colon = "現在 ",
+ k_sync_locally = "ローカルで同期 (ゲームを再起動します)",
+ k_bans = "出現しないアイテム",
+ k_reworks = "追加、修正されたアイテム",
+ k_ruleset_disabled_the_order_required = "The Order MOD必須のモードです",
+ k_ruleset_disabled_the_order_banned = "The Order MOD禁止のモードです",
+ k_ruleset_not_found = "不明なルールセット",
+ k_tutorial_not_complete = "マルチプレイヤーをプレイするためには、チュートリアルを完了させる必要があります",
+ k_created_by = "製作 ",
+ k_major_contributors = "協力 ",
+ ml_enemy_loc = {
+ "相手の",
+ "プレイ状況",
+ },
+ ml_mp_kofi_message = {
+ "このMODは個人製作で成り立っています。",
+ "気に入っていただけた方は、",
+ "こちらからサポートを",
+ "よろしくお願いします!",
+ },
+ ml_lobby_info = {
+ "ロビー",
+ "情報",
+ },
+ loc_ready = "準備OK!",
+ loc_selecting = "ブラインド選択",
+ loc_shop = "ショップ",
+ loc_playing = "",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "イラスト #1#",
+ },
+ a_mp_code = {
+ "制作 #1#",
+ },
+ a_mp_idea = {
+ "考案 #1#",
+ },
+ a_mp_skips_ahead = {
+ "より#1#回多い",
+ },
+ a_mp_skips_behind = {
+ "より#1#回少ない",
+ },
+ a_mp_skips_tied = {
+ "と同数",
+ },
+ k_banned_objs = "出現しない#1#",
+ k_no_banned_objs = "出現しない#1#はありません",
+ k_reworked_objs = "追加、修正された#1#",
+ k_no_reworked_objs = "追加、修正された#1#はありません",
+ k_ruleset_disabled_smods_version = "Steammodded バージョン #1# が必須です。",
+ k_failed_to_join_lobby = "ロビーの入室に失敗しました #1#",
+ k_ante_number = "アンティ #1#",
+ k_ante_range = "アンティ #1#、#2#", -- For example, "Ante 1-2"
+ k_ante_min = "アンティ #1#以降", -- For example, "Ante 2+"
+ k_credits_list = "#1# など...", -- #1# gets replaced with a list of names
+ },
+ v_text = {
+
+ ch_c_hanging_chad_rework = {
+ "{C:attention}ハンギングチャド{}は{C:dark_edition}マルチ用に改良されています。",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}グラスカード{}は{C:dark_edition}マルチ用に改良されています。",
+ },
+ ch_c_mp_score_instability = {
+ "チップと倍率が {C:purple}アンバランス{} になる",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(例: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} -> {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:mult}倍率{} の最小値 {C:attention}1",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:chips}チップ{} の最小値 {C:attention}0",
+ },
+ ch_c_mp_ante_scaling = {
+ "ノルマスコア {C:red}#1#倍{}",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "スタンダード",
+ c_mp_sandbox = "サンドボックス",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "トーナメント",
+ c_mp_weekly = "ウィークリー",
+ c_mp_vanilla = "バニラ",
+ c_mp_misprint_deck = "バグシードデッキ",
+ c_mp_legendaries = "レジェンドたち",
+ c_mp_psychosis = "精神病",
+ c_mp_scratch = "1から作ろう",
+ c_mp_twin_towers = "2本の柱",
+ c_mp_in_the_red = "赤字経営",
+ c_mp_paper_money = "お札",
+ c_mp_high_hand = "1度きり",
+ c_mp_chore_list = "チェックリスト",
+ c_mp_oops_all_jokers = "ぜんぶジョーカーだ!",
+ c_mp_divination = "まやかし占い師",
+ c_mp_skip_off = "おサボり",
+ c_mp_lets_go_gambling = "Let's ギャンブル!",
+ c_mp_speed = "タイムアタック対決",
+ c_mp_balancing_act = "ニセプラズマ",
+ },
+ },
+}
diff --git a/localization/ko.lua b/localization/ko.lua
new file mode 100644
index 00000000..4ebd894f
--- /dev/null
+++ b/localization/ko.lua
@@ -0,0 +1,1041 @@
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "갬블링 태그",
+ text = {
+ "{C:green}#1# / #2#{} 확률",
+ "상점에서 무료",
+ "{C:red}레어 조커{} 획득",
+ },
+ },
+ tag_mp_juggle_sandbox = {
+ name = "저글 태그",
+ text = {
+ "다음 {C:attention}PvP 블라인드{}에서",
+ "손패 크기 {C:attention}+#1#{}",
+ },
+ },
+ tag_mp_investment_sandbox = {
+ name = "인베스트먼트 태그",
+ text = {
+ "보스 블라인드를 처치한 뒤 획득:",
+ "{C:money}$#1#{} + {C:money}$#2#{} (앤티당)",
+ "{C:inactive}(현재 {C:money}$#3#{C:inactive})",
+ },
+ },
+ },
+
+ Joker = {
+ j_broken = {
+ name = "BROKEN",
+ text = {
+ "이 카드는 고장났거나,",
+ "현재 사용 중인 모드 버전에",
+ "아직 구현되지 않았습니다.",
+ },
+ },
+
+ j_mp_defensive_joker = {
+ name = "디펜시브 조커",
+ text = {
+ "{X:purple,C:white}Nemesis{}보다 {C:red,E:1}라이프{}가",
+ "적을 때마다 {C:chips}+#1#{} 칩",
+ "{C:inactive}(현재 {C:chips}+#2#{C:inactive} 칩)",
+ "{C:inactive}(스테이크에 따라 달라짐)",
+ },
+ },
+
+ j_mp_skip_off = {
+ name = "스킵-오프",
+ text = {
+ "{X:purple,C:white}Nemesis{}보다 더 많이 스킵한",
+ "추가 {C:attention}블라인드{} 1개당",
+ "{C:blue}+#1#{} 핸드, {C:red}+#2#{} 디스카드",
+ "{C:inactive}(현재 {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+
+ j_mp_lets_go_gambling = {
+ name = "렛츠 고 갬블링",
+ text = {
+ "{C:green}#1# / #2#{} 확률로",
+ "{X:mult,C:white}X#3#{} 배수 + {C:money}$#4#{}",
+ "{C:green}#5# / #6#{} 확률로",
+ "{C:attention}PvP 블라인드{}에서",
+ "{X:purple,C:white}Nemesis{}에게 {C:money}$#7#{} 지급",
+ },
+ },
+
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "{X:purple,C:white}Nemesis{}보다 먼저",
+ "{C:attention}PvP 블라인드{}에 도달하면",
+ "무작위 {C:spectral}스펙트럴{} 카드 1장 생성",
+ "{C:inactive}(공간이 있어야 함)",
+ },
+ },
+
+ j_mp_conjoined_joker = {
+ name = "컨조인드 조커",
+ text = {
+ "{C:attention}PvP 블라인드{} 중일 때,",
+ "{X:purple,C:white}Nemesis{}의 남은 {C:blue}핸드{} 1개당",
+ "{X:mult,C:white}X#1#{} 배수 획득",
+ "{C:inactive}(최대 {X:mult,C:white}X#2#{C:inactive}, 현재 {X:mult,C:white}X#3#{C:inactive})",
+ },
+ },
+
+ j_mp_penny_pincher = {
+ name = "페니 핀처",
+ text = {
+ "라운드 종료 시, {X:purple,C:white}Nemesis{}가",
+ "해당 상점에서 {C:attention}지난 앤티{}에 사용한 금액",
+ "{C:money}$#2#{}마다 {C:money}$#1#{} 획득",
+ },
+ },
+
+ j_mp_taxes = {
+ name = "택스",
+ text = {
+ "마지막 {C:attention}PvP 블라인드{} 이후",
+ "{X:purple,C:white}Nemesis{}가 {C:attention}판매{}한 카드 1장당",
+ "{C:mult}+#1#{} 배수 획득",
+ "{C:attention}PvP 블라인드{} 선택 시 갱신",
+ "{C:inactive}(현재 {C:mult}+#2#{C:inactive})",
+ },
+ },
+
+ j_mp_pizza = {
+ name = "피자",
+ text = {
+ "다음 {C:attention}PvP 블라인드{} 종료 시",
+ "이 조커를 소모하고",
+ "당신에게 {C:red}+#1#{} 디스카드,",
+ "{X:purple,C:white}Nemesis{}에게 {C:red}+#2#{} 디스카드를",
+ "이번 앤티 동안 부여",
+ },
+ },
+
+ j_mp_pacifist = {
+ name = "패시피스트",
+ text = {
+ "{C:attention}PvP 블라인드{}가 아닐 때",
+ "{X:mult,C:white}X#1#{} 배수",
+ },
+ },
+
+ j_mp_hanging_chad = {
+ name = "행잉 채드",
+ text = {
+ "점수 계산에 사용된",
+ "{C:attention}첫 번째{}와 {C:attention}두 번째{}로 낸 카드를",
+ "{C:attention}추가로 #1#회{} 재발동",
+ },
+ },
+
+ j_mp_bloodstone = {
+ name = "블러드스톤",
+ text = {
+ "{C:green}#1# / #2#{} 확률로",
+ "{C:hearts}하트{} 문양의 플레이된 카드가",
+ "점수 계산 시 {X:mult,C:white}X#3#{} 배수 제공",
+ },
+ },
+
+ j_mp_magnet_sandbox = {
+ name = "마그넷",
+ text = {
+ "{C:attention}#1#{} 라운드 후 이 카드를 판매하면",
+ "{X:purple,C:white}Nemesis{}의 최고 판매가 {C:attention}조커{}를 {C:attention}복사{}",
+ "{C:attention}#3#{} 라운드 후 극성(polarity)이 반전되어",
+ "쓸모없는 고철이 되어버림!!!!",
+ "{C:inactive}(현재 {C:attention}#2#{C:inactive}/#1# 라운드)",
+ },
+ },
+
+ j_mp_cloud_9_sandbox = {
+ name = "클라우드 9",
+ text = {
+ "숫자 단일재배 농부",
+ "당신의 다양한 덱을",
+ "수익성 좋은 9번 농장으로 바꿔버림!!!!",
+ "{C:inactive}({C:green}#1# / #2#{} {C:inactive}확률, 현재 {C:money}$#3#{}{C:inactive})",
+ },
+ },
+
+ j_mp_lucky_cat_sandbox = {
+ name = "럭키 캣",
+ text = {
+ "행운 → 취약성 파이프라인 운영자",
+ "럭키 캣이 글래스 캣이 되어",
+ "지수적으로 강해진다!!!!",
+ "{C:inactive}(현재 {X:mult,C:white}X#2#{C:inactive})",
+ },
+ },
+
+ j_mp_constellation_sandbox = {
+ name = "컨스텔레이션",
+ text = {
+ "행성 관리 불안장애",
+ "다마고치에게 먹이를 줘야 한다",
+ "안 그러면 시들어버림!!!!",
+ "{C:inactive}(현재 {X:mult,C:white}X#1#{C:inactive})",
+ },
+ },
+
+ j_mp_bloodstone_sandbox = {
+ name = "블러드스톤",
+ text = {
+ "{V:1}패치노트 퇴행 증후군",
+ "출시일 트라우마로 되돌아가",
+ "추억의 {X:mult,C:white}X#3#{} 파워 스파이크!!!!",
+ "{C:inactive}({C:green}#1# / #2#{} {C:inactive}확률)",
+ },
+ },
+
+ j_mp_juggler_sandbox = {
+ name = "저글러",
+ text = {
+ "손패 크기 완벽주의자",
+ "모든 카드를",
+ "언제나 공중에 띄워야만 한다!!!!",
+ "{C:inactive}(현재 손패 크기 {C:attention}+#1#{C:inactive})",
+ },
+ },
+
+ j_mp_mail_sandbox = {
+ name = "메일-인 리베이트",
+ text = {
+ "버린 {C:attention}#2#{} 1장당",
+ "{C:money}$#1#{} 획득",
+ "{s:0.8}랭크는 절대 바뀌지 않음",
+ },
+ },
+
+ j_mp_hit_the_road_sandbox = {
+ name = "히트 더 로드",
+ text = {
+ "버린 {C:attention}잭{} 1장당",
+ "이 조커가 {X:mult,C:white}X0.75{} 배수를 획득",
+ "버린 잭은 {C:attention}파괴{}됨",
+ "{C:inactive}(현재 {X:mult,C:white}X#2#{C:inactive})",
+ },
+ },
+
+ j_mp_misprint_sandbox = {
+ name = "미스프린트",
+ text = {
+ "{V:1}#1#{} 배수",
+ "{C:attention}구매 시 값이 공개됨{}",
+ "{C:green}인쇄 오류는 누적된다{}",
+ },
+ },
+
+ j_mp_castle_sandbox = {
+ name = "캐슬",
+ text = {
+ "버린 {V:1}#1#{} 1장당",
+ "이 조커가 {C:chips}#3{} 칩 획득",
+ "문양은 구매 시 고정",
+ "{C:inactive}(현재 {C:chips}+#2#{C:inactive} 칩)",
+ },
+ },
+
+ j_mp_runner_sandbox = {
+ name = "러너",
+ text = {
+ "연속 카드 우월주의자",
+ "다른 모든 포커 핸드는",
+ "열등하다고 믿는다!!!!",
+ "{C:inactive}(현재 {C:chips}+#1#{C:inactive})",
+ },
+ },
+
+ j_mp_order_sandbox = {
+ name = "더 오더",
+ text = {
+ "낸 패에 {C:attention}스트레이트{}가 있으면 {X:mult,C:white}X3{} 배수",
+ "연속으로 {C:attention}스트레이트{}를 낼 때마다 {X:mult,C:white}X#1#{} 배수 획득",
+ "다른 패를 내면 초기화",
+ "{C:inactive}(현재 {X:mult,C:white}X#2#{C:inactive})",
+ },
+ },
+
+ j_mp_photograph_sandbox = {
+ name = "포토그래프",
+ text = {
+ "한 손당 단 한 번의 완벽한 장면만을",
+ "노리는 단발 촬영 사진가!!!!",
+ },
+ },
+
+ j_mp_ride_the_bus_sandbox = {
+ name = "라이드 더 버스",
+ text = {
+ "페이스 카드 금주 프로그램",
+ "페이스 카드 한 장만 나와도",
+ "버스에서 쫓겨난다!!!!",
+ "{C:inactive}(현재 {C:mult}+#1#{C:inactive} 배수)",
+ },
+ },
+
+ j_mp_loyalty_card_sandbox = {
+ name = "로열티 카드",
+ text = {
+ "{C:attention}#1#{}를 {C:attention}#3#{}번 낼 때마다",
+ "{X:mult,C:white}X6{} 배수",
+ "{C:inactive}(#2#/#3#)",
+ },
+ },
+
+ j_mp_faceless_sandbox = {
+ name = "페이스리스 조커",
+ text = {
+ "엘리트 페이스 카드 소믈리에",
+ "장인의",
+ "3종 시음 플라이트를 큐레이팅해",
+ "프리미엄 폐기 경험을 제공한다!!!!",
+ },
+ },
+
+ j_mp_square_sandbox = {
+ name = "스퀘어 조커",
+ text = {
+ "낸 패가 정확히 {C:attention}4{}장일 때",
+ "이 조커가 {C:chips}+#2#{} 칩 획득",
+ "{C:attention}4장 패에서만 적용{}",
+ "{C:inactive}(현재 {C:chips}+#1#{C:inactive} 칩)",
+ },
+ },
+
+ j_mp_throwback_sandbox = {
+ name = "스로우백",
+ text = {
+ "이번 런에서 스킵한 {C:attention}블라인드{} 1개당",
+ "기본 배수 {X:mult,C:white}X#2#{}",
+ "스킵 직후 다음 블라인드에 {X:mult,C:white}X#3#{} 배수",
+ "블라인드를 스킵하지 않으면 {X:mult,C:white}X#4#{} 배수 감소",
+ "{C:inactive}(현재 {X:mult,C:white}X#1#{C:inactive})",
+ },
+ },
+
+ j_mp_vampire_sandbox = {
+ name = "뱀파이어",
+ text = {
+ "점수 계산에 사용된 {C:attention}강화 카드{} 1장당",
+ "이 조커가 {X:mult,C:white}X#1#{} 배수 획득",
+ "플레이된 강화 카드는 {C:attention}스톤{}으로 변함",
+ "스톤 카드는 플레이 시 {C:money}$#3#{} 획득",
+ "{C:inactive}(현재 {X:mult,C:white}X#2#{C:inactive})",
+ },
+ },
+
+ j_mp_baseball_sandbox = {
+ name = "베이스볼 카드",
+ text = {
+ "{C:green}언커먼{} 조커는 각각",
+ "{X:mult,C:white}X#1#{} 배수 제공",
+ },
+ },
+
+ j_mp_steel_joker_sandbox = {
+ name = "스틸 조커",
+ text = {
+ "플레이된 스틸 카드를",
+ "{C:attention}재발동{}",
+ },
+ },
+
+ j_mp_satellite_sandbox = {
+ name = "새틀라이트",
+ text = {
+ "만성 위성 열화 불안",
+ "행성 업그레이드를 꾸준히 하지 않으면",
+ "인프라가 서서히 무너져내린다!!!!",
+ "{C:inactive}(현재 {C:money}$#1#{C:inactive})",
+ },
+ },
+
+ j_mp_idol_sandbox_zealot = {
+ name = "질럿 아이돌",
+ text = {
+ "플레이된 {C:attention}#1#{}마다",
+ "점수 계산 시 {X:mult,C:white}X#2#{} 배수 제공",
+ "{s:0.8}카드는 라운드마다 바뀜",
+ },
+ },
+ j_mp_idol_sandbox_collector = {
+ name = "메타 아이돌",
+ text = {
+ "가장 흔한 카드가 점수 계산 시",
+ "{X:mult,C:white}X#3#{} 배수 제공",
+ "({X:mult,C:white}+X#4#{} : 덱 내 해당 카드 1장당)",
+ "{C:inactive}(현재 {C:attention}#1#{} / {V:1}#2#{})",
+ },
+ },
+
+ j_mp_error_sandbox = {
+ name = "????",
+ text = {
+ "{X:purple,C:white,s:0.85}뭔가{} {X:purple,C:white,s:0.85}잘못됐다",
+ },
+ },
+ },
+
+ Planet = {
+ c_mp_asteroid = {
+ name = "애스터로이드",
+ text = {
+ "{C:attention}PvP 블라인드{} 시작 시",
+ "{X:purple,C:white}Nemesis{}의",
+ "가장 높은 레벨의 {C:legendary,E:1}포커 핸드{}에서",
+ "레벨 {C:attention}#1#{} 감소",
+ },
+ },
+ },
+
+ Blind = {
+ bl_mp_nemesis = {
+ name = "네메시스",
+ text = {
+ "다른 플레이어와 대결",
+ "칩이 더 많은 쪽이 승리",
+ },
+ },
+ },
+
+ Edition = {
+ e_mp_phantom = {
+ name = "팬텀",
+ text = {
+ "{C:attention}이터널{} 및 {C:dark_edition}네거티브{}",
+ "{X:purple,C:white}Nemesis{}에 의해 생성되고 파괴됨",
+ },
+ },
+ },
+
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "글래스 카드",
+ text = {
+ "{X:mult,C:white}X#1#{} 배수",
+ "{C:green}#2# / #3#{} 확률로",
+ "카드 파괴",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "글래스 카드",
+ text = {
+ "{X:mult,C:white}X#1#{} 배수",
+ "{C:green}#2# / #3#{} 확률로",
+ "카드 파괴",
+ },
+ },
+ },
+
+ Back = {
+ b_mp_cocktail = {
+ name = "칵테일 덱",
+ text = {
+ "{C:attention}3{}개의 다른 덱 효과를",
+ "무작위로 복사",
+ },
+ },
+
+ b_mp_gradient = {
+ name = "그라디언트 덱",
+ text = {
+ "카드는 모든 {C:attention}조커{} 효과에 대해",
+ "랭크가 {C:attention}1{} 높거나",
+ "{C:attention}1{} 낮은 것으로 취급됨",
+ },
+ },
+
+ b_mp_heidelberg = {
+ name = "하이델베르크 덱",
+ text = {
+ "{C:attention}상점{} 종료 시",
+ "보유 중인 무작위 {C:attention}소모품{} 카드",
+ "{C:attention}1{}장에",
+ "{C:dark_edition}네거티브{} 복사본 생성",
+ },
+ },
+
+ b_mp_indigo = {
+ name = "인디고 덱",
+ text = {
+ "모든 부스터 팩에서",
+ "카드를 {C:attention}+1{}장 더 선택",
+ "부스터 팩은 {C:attention}스킵 불가{}",
+ },
+ },
+
+ b_mp_oracle = {
+ name = "오라클 덱",
+ text = {
+ "런 시작 시 {C:spectral,T:c_medium}미디엄{}과",
+ "{C:attention,T:v_clearance_sale}클리어런스 세일{} 획득",
+ "보유 금액 상한:",
+ "{C:money}$50{} + 현재 이자 상한",
+ },
+ },
+
+ b_mp_orange = {
+ name = "오렌지 덱",
+ text = {
+ "런 시작 시",
+ "{C:attention,T:p_mp_standard_giga}기가 스탠다드 팩{}과",
+ "{C:attention}2{}장의",
+ "{C:tarot,T:c_hanged_man}행드 맨{} 획득",
+ },
+ },
+
+ b_mp_violet = {
+ name = "바이올렛 덱",
+ text = {
+ "상점에 {C:attention}+1{} 바우처",
+ "{C:attention}앤티 1{} 동안",
+ "바우처 {C:attention}50%{} 할인",
+ },
+ },
+
+ b_mp_white = {
+ name = "화이트 덱",
+ text = {
+ "{X:purple,C:white}Nemesis{}의",
+ "현재 덱과 조커 구성을 확인",
+ "{C:inactive}(PvP 블라인드 시 갱신)",
+ },
+ },
+ },
+
+ Other = {
+ current_nemesis = {
+ name = "네메시스",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "당신의 유일한 네메시스",
+ },
+ },
+
+ p_mp_standard_giga = {
+ name = "기가 스탠다드 팩",
+ text = {
+ "최대 {C:attention}#2#{}장의",
+ "{C:attention}플레이 카드{} 중",
+ "{C:attention}#1#{}장 선택하여",
+ "덱에 추가",
+ "{C:attention}스킵 불가{}",
+ },
+ },
+
+ mp_transmutations = {
+ name = "변환",
+ text = {
+ "{C:purple,s:1.1}다음으로 변환됨:",
+ },
+ },
+ mp_internal_sell_value = {
+ name = "판매가",
+ text = {
+ "{C:money,s:1.3}$#1#",
+ },
+ },
+
+ mp_sticker_persistent = {
+ name = "영구",
+ text = {
+ "파괴될 수 없음",
+ "판매 시 {C:red}${} 소모",
+ "라운드 종료 시",
+ "{C:red}$3{}만큼 비용 증가",
+ },
+ },
+
+ mp_sticker_unreliable = {
+ name = "불안정",
+ text = {
+ "{C:attention}마지막 핸드{}에서는",
+ "발동하지 않음",
+ },
+ },
+
+ mp_sticker_draining = {
+ name = "흡수",
+ text = {
+ "{X:mult,C:white}X0.75{} 배율",
+ },
+ },
+ },
+
+ Stake = {
+ stake_mp_planet = {
+ name = "플래닛 스테이크",
+ text = {
+ "{C:black}블랙 스테이크{} 효과 적용, 추가로:",
+ "상점에 {C:attention}퍼리셔블{} 조커 등장 가능",
+ "{C:inactive,s:0.8}(5라운드 후 디버프)",
+ "요구 점수가",
+ "{C:attention}앤티{}마다 더 빠르게 증가",
+ },
+ },
+
+ stake_mp_spectral = {
+ name = "스펙트럴 스테이크",
+ text = {
+ "{C:planet}플래닛 스테이크{} 효과 적용, 추가로:",
+ "{C:money}렌탈{} 조커가 상점에 등장",
+ "요구 점수가",
+ "{C:attention}앤티{}마다 더 빠르게 증가",
+ },
+ },
+
+ stake_mp_spectralplus = {
+ name = "스펙트럴+ 스테이크",
+ text = {
+ "{C:planet}스펙트럴 스테이크{} 효과 적용, 추가로:",
+ "요구 점수가",
+ "{C:attention}앤티{}마다 훨씬 더 빠르게 증가",
+ },
+ },
+ stake_mp_plastic = {
+ name = "플라스틱 스테이크",
+ text = {
+ "{C:money}$10{}당 이자 {C:money}$1{} 획득",
+ "{C:inactive,s:0.8}(최대 {C:money,s:0.8}$50{C:inactive,s:0.8})",
+ "{s:0.8}화이트 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_pebble = {
+ name = "페블 스테이크",
+ text = {
+ "요구 점수가",
+ "{C:attention}앤티{}마다 더 빠르게 증가",
+ "{s:0.8}플라스틱 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_ferrite = {
+ name = "페라이트 스테이크",
+ text = {
+ "특정 조커가 {C:attention}영구{} 상태가 됨",
+ "{C:inactive,s:0.8}(파괴 불가, 판매 비용 증가)",
+ "{s:0.8}페블 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_pyrite = {
+ name = "파이라이트 스테이크",
+ text = {
+ "리롤 가격이",
+ "리롤할 때마다 {C:money}$2{}씩 증가",
+ "{s:0.8}페라이트 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_jade = {
+ name = "제이드 스테이크",
+ text = {
+ "요구 점수가",
+ "{C:attention}앤티{}마다 더 빠르게 증가",
+ "{s:0.8}파이라이트 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_crystal = {
+ name = "크리스탈 스테이크",
+ text = {
+ "특정 조커가 {C:attention}불안정{} 상태가 됨",
+ "{C:inactive,s:0.8}({C:attention,s:0.8}마지막 핸드{C:inactive,s:0.8}에서는 발동하지 않음)",
+ "{s:0.8}제이드 스테이크 효과 적용",
+ },
+ },
+
+ stake_mp_antimatter = {
+ name = "안티매터 스테이크",
+ text = {
+ "특정 조커가 {C:attention}흡수{} 상태가 됨",
+ "{C:inactive,s:0.8}({X:mult,C:white,s:0.8}X0.75{C:inactive,s:0.8} 배율)",
+ "{s:0.8}크리스탈 스테이크 효과 적용",
+ },
+ },
+ },
+
+ Spectral = {
+ c_mp_ouija_sandbox = {
+ name = "위자",
+ text = {
+ "무작위 카드 {C:attention}#1#{}장 파괴 후",
+ "남은 모든 카드를",
+ "무작위 {C:attention}랭크{} 하나로 변환",
+ },
+ },
+
+ c_mp_ectoplasm_sandbox = {
+ name = "엑토플라즘",
+ text = {
+ "무작위 {C:attention}조커{} 1장에",
+ "{C:dark_edition}네거티브{} 부여",
+ "다음 중 하나 무작위 적용:",
+ "{C:red}-1{} 핸드, {C:red}-1{} 버림, 또는 {C:red}-1{} 손패 크기",
+ },
+ },
+ },
+ },
+
+ misc = {
+ labels = {
+ mp_phantom = "팬텀",
+ },
+
+ challenge_names = {
+ c_mp_standard = "스탠다드",
+ c_mp_sandbox = "샌드박스",
+ c_mp_badlatro = "배드라트로",
+ c_mp_tournament = "토너먼트",
+ c_mp_weekly = "위클리",
+ c_mp_vanilla = "바닐라",
+ },
+
+ dictionary = {
+ b_singleplayer = "싱글플레이",
+ b_join_lobby = "로비 참가",
+ b_join_lobby_clipboard = "클립보드로 참가",
+ b_return_lobby = "로비로 돌아가기",
+ b_reconnect = "재접속",
+ b_create_lobby = "로비 생성",
+ b_start_lobby = "로비 시작",
+ b_ready = "준비",
+ b_unready = "준비 해제",
+ b_leave_lobby = "로비 나가기",
+ b_mp_discord = "Balatro Multiplayer 디스코드 서버",
+ b_start = "시작",
+ b_wait_for_host_start = {
+ "대기 중:",
+ "호스트 시작",
+ },
+ b_wait_for_players = {
+ "대기 중:",
+ "플레이어",
+ },
+ b_wait_for_guest_ready = {
+ "대기 중:",
+ "게스트 준비",
+ },
+ b_lobby_options = "로비 옵션",
+ b_copy_clipboard = "클립보드로 복사",
+ b_view_code = "코드 보기",
+ b_copy_code = "코드 복사",
+ b_leave = "나가기",
+
+ b_opts_cb_money = "생명를 잃으면 컴백 머니 지급",
+ b_opts_no_gold_on_loss = "라운드 패배 시 블라인드 보상 없음",
+ b_opts_death_on_loss = "PvP가 아닌 라운드 패배 시 생명 1개 잃음",
+ b_opts_start_antes = "시작 앤티",
+ b_opts_diff_seeds = "플레이어마다 시드가 다름",
+ b_opts_lives = "Lives",
+ b_opts_multiplayer_jokers = "멀티플레이어 카드 활성화",
+ b_opts_player_diff_deck = "플레이어마다 덱이 다름",
+ b_opts_normal_bosses = "보스 블라인드 효과 활성화",
+ b_opts_timer = "타이머 활성화",
+ b_opts_disable_preview = "점수 미리보기 비활성화",
+ b_opts_the_order = "The Order 활성화",
+ b_opts_legacy_smallworld = "레거시 Small World 메커니즘",
+
+ b_reset = "초기화",
+ b_set_custom_seed = "커스텀 시드 설정",
+ b_mp_kofi_button = "Ko-fi로 후원하기",
+ b_unstuck = "막힘 해제",
+ b_unstuck_blind = "PvP 밖에서 막힘",
+ b_misprint_display = "덱의 다음 카드 표시",
+ b_players = "플레이어",
+ b_lobby_info = "로비 정보",
+ b_continue_singleplayer = "싱글플레이로 계속",
+ b_the_order_integration = "The Order 연동 활성화",
+ b_preview_integration = "점수 미리보기 활성화",
+ b_view_nemesis_deck = "덱 보기",
+ b_toggle_jokers = "조커 토글",
+ b_skip_tutorial = "튜토리얼 건너뛰기",
+
+ k_yes = "예",
+ k_no = "아니요",
+ k_are_you_sure = "정말로 하시겠습니까?",
+ k_has_multiplayer_content = "멀티플레이어 콘텐츠 포함",
+ k_forces_lobby_options = "로비 옵션 강제",
+ k_forces_gamemode = "게임모드 강제",
+ k_values_are_modifiable = "* 값은 변경될 수 있습니다",
+ k_rulesets = "룰셋",
+ k_gamemodes = "게임모드",
+ k_competitive = "경쟁",
+ k_other = "기타",
+ k_battle = "배틀",
+ k_challenge = "챌린지",
+ k_info = "정보",
+
+ k_continue_singleplayer_tooltip = "이 작업은 현재 싱글플레이 런을 덮어씁니다",
+ k_enemy_score = "현재 상대 점수",
+ k_enemy_hands = "상대 남은 핸드: ",
+ k_coming_soon = "Coming Soon!",
+ k_wait_enemy = "상대가 끝낼 때까지 대기 중...",
+
+ k_lives = "Lives",
+ k_lost_life = "생명를 잃었습니다",
+ k_total_lives_lost = " 총 잃은 Lives ($4씩)",
+ k_comeback_money_sandbox = " 컴백 머니 ($3 × 클리어한 앤티)",
+
+ k_attrition_name = "어트리션",
+ k_enter_lobby_code = "로비 코드 입력",
+ k_paste = "클립보드에서 붙여넣기",
+ k_username = "유저네임:",
+ k_enter_username = "유저네임 입력",
+ k_customize_preview = "미리보기 문구 커스터마이즈:",
+ k_join_discord = "참여하기: ",
+ k_discord_msg = "버그 신고와 함께 같이 플레이할 사람을 찾을 수 있어요",
+ k_enter_to_save = "Enter를 눌러 저장",
+ k_in_lobby = "로비에 있음",
+ k_connected = "서비스에 연결됨",
+ k_warn_service = "경고: 멀티플레이어 서비스를 찾을 수 없음",
+ k_set_name = "메인 메뉴에서 유저네임을 설정하세요! (Mods > Multiplayer > Config)",
+
+ k_mod_hash_warning = "플레이어들의 모드 또는 모드 버전이 다릅니다! 문제가 발생할 수 있어요!",
+ k_steamodded_warning = "플레이어들의 Steamodded 버전이 다릅니다. 시드가 달라질 수 있어요.",
+ k_warning_unlock_profile = "현재 플레이 중인 프로필이 완전히 해금되지 않았습니다. 랭크/토너먼트 게임이라면 새 프로필을 만들고 프로필 설정에서 'unlock all'을 눌러주세요.",
+ k_warning_nemesis_unlock = "상대가 완전히 해금되지 않은 프로필로 플레이 중입니다. 새 프로필 생성 후 프로필 설정에서 'unlock all'을 누르도록 안내해주세요.",
+ k_warning_no_order = "한 플레이어는 The Order 연동이 켜져 있고, 다른 플레이어는 꺼져 있습니다. 이 경우 시드가 달라질 수 있어요.",
+ k_warning_cheating1 = "이 메시지가 보인다면, 상대가 치트를 쓰고 있을 수 있습니다.",
+ k_warning_cheating2 = "랭크 게임이라면 '%s' 메시지를 보낸 뒤 #support에 지원 티켓을 열어주세요.",
+ k_warning_banned_mods = "한 명 이상이 금지된 모드를 설치했습니다. 이런 모드는 랭크 게임에서 허용되지 않습니다.",
+
+ k_message1 = "잠깐만, 우리 엄마가 피자팝을 만들어줬어",
+ k_message2 = "1초만, 슬로우쿠커 돼지구이 좀 챙길게",
+ k_message3 = "잠시만, 엄마한테 전화 왔어",
+ k_message4 = "잠깐 비움, 우리 고양이가 불타고 있어",
+ k_message5 = "잠깐, 나 가스불 안 껐나?",
+ k_message6 = "잠깐만, 우리 애완용 돌이 도망갔어",
+ k_message7 = "1초만, 내 식물들이 물 달래",
+ k_message8 = "잠깐 비움, 내 양말들이 반란을 모의 중이야",
+ k_message9 = "미안, 우리 와이파이가 실존적 위기를 겪는 중이야",
+
+ k_lobby_options = "로비 옵션",
+ k_connect_player = "연결된 플레이어:",
+ k_opts_only_host = "로비 호스트만 이 옵션을 변경할 수 있습니다",
+ k_lobby_general = "일반",
+ k_lobby_gameplay = "게임플레이",
+ k_lobby_modifiers = "수정치",
+ k_lobby_advanced = "고급",
+
+ k_opts_pvp_start_round = "PvP가 앤티에서 시작",
+ k_opts_pvp_timer = "타이머",
+ k_opts_showdown_starting_antes = "쇼다운이 앤티에서 시작",
+ k_opts_pvp_timer_increment = "타이머 증가량",
+ k_opts_pvp_countdown_seconds = "PvP 카운트다운(초)",
+ k_bl_life = "생명",
+ k_bl_or = "또는",
+ k_bl_death = "죽음",
+ k_bl_mostchips = "칩이 가장 많은 쪽이 승리",
+ k_current_seed = "현재 시드: ",
+ k_random = "무작위",
+ k_standard = "스탠다드",
+ k_sandbox = "샌드박스",
+ k_sandbox_description = "질투심 많은 아이돌 셋이당신의 런을 두고 경쟁합니다.\n조커 일부가 특이한 효과로 교체되며\n게임 규칙이 크게 달라집니다.\n\n(자세한 내용은 위키 참고)",
+
+ k_vanilla = "바닐라",
+ k_vanilla_description = "Balatro의 오리지널 경험.\n멀티플레이어 전용 조커와밸런스 변경 없이\n기본 게임 그대로 플레이합니다.\n(멀티플레이어 기능은 옵션에서 비활성화 가능)",
+
+ k_blitz = "스탠다드",
+ k_blitz_description = "균형 잡힌 멀티플레이어 룰셋.\n멀티플레이어 조커와밸런스 변경을 포함하며\n로비 설정을 자유롭게 조절할 수 있습니다.\n(자세한 내용은 금지/리워크 탭 참고)",
+
+ k_traditional = "트래디셔널",
+ k_traditional_description = "시간 압박 없는 멀티플레이어 룰셋.\n멀티플레이어 조커와밸런스 변경을 포함하지만\n시간 기반 요소는 제거됩니다.\n(자세한 내용은 금지/리워크 탭 참고)",
+
+ k_majorleague = "메이저 리그",
+ k_majorleague_description = "공식 메이저 리그 룰셋.\n\n경쟁 설정의 바닐라 카드로\n빠른 템포의 대전을 진행합니다.",
+
+ k_minorleague = "마이너 리그",
+ k_minorleague_description = "공식 마이너 리그 룰셋.\n\n메이저 리그보다\n조금 여유 있는 타이머로\n경쟁전을 진행합니다.",
+
+ k_ranked = "랭크",
+ k_ranked_description = "공식 경쟁 룰셋.\n\n설정이 고정된 스탠다드 규칙으로\n실력을 겨루는 모드입니다.\n\n(권장 Steamodded 버전 필요)",
+
+ k_badlatro = "배드라트로",
+ k_badlatro_description = "디스코드 커뮤니티에서 제작된\n주간 룰셋입니다.\n\n다수의 카드와 아이템이\n대규모로 금지됩니다.",
+
+ k_attrition = "어트리션",
+ k_attrition_description = "첫 앤티 이후부터\n모든 보스 블라인드가\n네메시스 블라인드가 됩니다.\n\n시작부터 전투를 준비해야 합니다.",
+
+ k_showdown = "쇼다운",
+ k_showdown_description = "처음 2개의 앤티 이후\n모든 블라인드가\n네메시스 블라인드가 됩니다.\n\n전투 전 준비 시간이 주어집니다.",
+
+ k_survival = "서바이벌",
+ k_survival_description = "가장 먼 블라인드를\n돌파한 플레이어가 승리합니다.\n\n네메시스 블라인드는 없으며\n점진적인 성장에 집중합니다.",
+
+ k_weekly = "위클리",
+ k_weekly_description = "주기적으로 변경되는\n특별 룰셋입니다.\n\n이번 주 규칙을 직접 확인하세요!",
+
+ k_smallworld = "스몰 월드",
+ k_smallworld_description = "조커, 소모품, 바우처,\n태그의 대부분이\n매 게임 무작위로 금지됩니다.\n\n중복은 허용됩니다.",
+
+ k_speedlatro = "스피드라트로",
+ k_speedlatro_description = "각 PvP 블라인드 사이에\n147초의 빠른 타이머가 적용됩니다.\n\n빠른 판단이 요구됩니다.",
+
+ k_cost_up = "비용 증가",
+ k_destabilized = "불안정화",
+ k_oops_ex = "웁스!",
+ k_asteroids = "소행성",
+ k_amount_short = "수량",
+ k_filed_ex = "접수 완료!",
+ k_timer = "타이머",
+ k_mods_list = "모드 목록",
+ k_enemy_jokers = "상대 조커",
+ k_your_jokers = "내 조커",
+ k_nemesis_deck = "네메시스 덱",
+ k_your_deck = "내 덱",
+ k_customization = "커스터마이즈",
+ k_the_order_credit = "*제작자: @MathIsFun_",
+ k_the_order_integration_desc = "카드 생성을 앤티 기반이 아닌 단일 풀로 패치하여 모든 타입/희귀도를 공유합니다",
+ k_preview_credit = "*제작자: @Fantom, @Divvy",
+ k_preview_integration_desc = "핸드를 내기 전에 점수 미리보기를 활성화합니다",
+ k_requires_restart = "*적용하려면 재시작이 필요합니다",
+ k_cocktail_select = "포함할 덱 카드를 선택",
+ k_cocktail_shiftclick = "Shift-클릭: 포일 처리 (포일 덱은 항상 선택됨)",
+ k_cocktail_rightclick = "우클릭: 전체 선택",
+ k_bans = "금지",
+ k_reworks = "리워크",
+ k_edit = "편집",
+ k_ruleset_disabled_the_order_required = "The Order 필요",
+ k_ruleset_disabled_the_order_banned = "The Order 금지됨",
+ k_ruleset_not_found = "알 수 없는 룰셋",
+ k_tutorial_not_complete = "멀티플레이어를 플레이하려면 튜토리얼을 완료해야 합니다",
+ k_created_by = "제작",
+ k_major_contributors = "주요 기여자",
+ ml_enemy_loc = {
+ "상대",
+ "위치",
+ },
+ k_hide_mp_content = "멀티플레이어 콘텐츠 숨기기*",
+ k_applies_singleplayer_vanilla_rulesets = "*싱글플레이 및 바닐라 룰셋에도 적용",
+ k_timer_sfx = "타이머 효과음",
+ ml_mp_kofi_message = {
+ "이 모드와 게임 서버는",
+ "한 사람이 개발하고",
+ "유지보수하고 있습니다.",
+ "마음에 드신다면 후원을 고려해주세요",
+ },
+
+ k_bl_life = "라이프",
+ loc_ready = "PvP 준비 완료",
+ loc_selecting = "블라인드 선택 중",
+ loc_shop = "쇼핑 중",
+ loc_playing = "플레이 중 ",
+ },
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "아트: #1#",
+ },
+ a_mp_code = {
+ "코드: #1#",
+ },
+ a_mp_idea = {
+ "아이디어: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# 스킵 앞섬",
+ },
+ a_mp_skips_behind = {
+ "#1# 스킵 뒤처짐",
+ },
+ a_mp_skips_tied = {
+ "동률",
+ },
+
+ k_banned_objs = "금지된 #1#",
+ k_no_banned_objs = "금지된 #1# 없음",
+ k_reworked_objs = "리워크된 #1#",
+ k_no_reworked_objs = "리워크된 #1# 없음",
+ k_ruleset_disabled_smods_version = "SMODS 버전 #1# 필요",
+ k_failed_to_join_lobby = "로비 참가 실패: #1#",
+
+ k_ante_number = "앤티 #1#",
+ k_ante_range = "앤티 #1#-#2#",
+ k_ante_min = "앤티 #1#+",
+ k_credits_list = "#1# 외 다수!",
+ },
+
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}행잉 채드{}가 {C:dark_edition}리워크{}되었습니다",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}글래스 카드{}가 {C:dark_edition}리워크{}되었습니다",
+ },
+ ch_c_mp_score_instability = {
+ "불균형한 점수가 {C:purple}더 불안정{}해집니다:",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(예: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} → {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:inactive}{C:attention}최소 1 {C:mult}배수",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:inactive}{C:attention}최소 0 {C:chips}칩",
+ },
+
+ ch_c_mp_ante_scaling = {
+ "{C:red}기본 블라인드 크기 X#1#",
+ },
+ ch_c_mp_no_shop_planets = {
+ "{C:planet}플래닛{}이 {C:attention}상점{}에 더 이상 등장하지 않음",
+ },
+ ch_c_mp_only_medium = {
+ "모든 {C:spectral}스펙트럴{} 카드가 {C:spectral}미디엄{}이 됨",
+ },
+ ch_c_mp_only_purple_seals = {
+ "모든 {C:attention}실{}이 {C:purple}퍼플 실{}이 됨",
+ },
+
+ ch_c_mp_sibyl_CREDITS = {
+ "{C:inactive}(아트: {C:attention}Ganpan14O{C:inactive})",
+ },
+
+ ch_c_mp_polymorph_spam = {
+ "블라인드 선택 시, 보유 중인 모든 {C:attention}조커{}와 {C:attention}소모품{}이",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED1 = {
+ "컬렉션에서 다음 {C:attention}N{}번째 카드로 변환되며,",
+ },
+ ch_c_mp_polymorph_spam_EXTENDED2 = {
+ "{C:attention}N{}은 현재 슬롯 위치입니다",
+ },
+ },
+
+ challenge_names = {
+ c_mp_standard = "스탠다드",
+ c_mp_sandbox = "샌드박스",
+ c_mp_badlatro = "배드라트로",
+ c_mp_tournament = "토너먼트",
+ c_mp_weekly = "위클리",
+ c_mp_vanilla = "바닐라",
+ c_mp_misprint_deck = "미스프린트 덱",
+ c_mp_legendaries = "레전더리",
+ c_mp_psychosis = "사이코시스",
+ c_mp_scratch = "맨바닥부터",
+ c_mp_twin_towers = "트윈 타워",
+ c_mp_in_the_red = "적자",
+ c_mp_paper_money = "지폐",
+ c_mp_high_hand = "하이 핸드",
+ c_mp_chore_list = "집안일 목록",
+ c_mp_oops_all_jokers = "웁스! 전부 조커",
+ c_mp_divination = "점술",
+ c_mp_skip_off = "스킵-오프",
+ c_mp_lets_go_gambling = "렛츠 고 갬블링",
+ c_mp_speed = "스피드",
+ c_mp_balancing_act = "균형 잡기",
+ c_mp_salvaged_sibyl = "구출된 시빌",
+ c_mp_polymorph_spam = "폴리모프 스팸",
+ },
+}
diff --git a/localization/mi.lua b/localization/mi.lua
new file mode 100644
index 00000000..5cf9121c
--- /dev/null
+++ b/localization/mi.lua
@@ -0,0 +1,327 @@
+-- Localization by Being a Nerd in Māori | He mea whakamāori nā Being a Nerd in Māori
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "BROKEN",
+ text = {
+ "This card is either broken or",
+ "not implemented in the current",
+ "version of a mod you are using.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Hako Parahau",
+ text = {
+ "Ka tāpirihia te {C:chips}+#1#{} Kapa ki",
+ "tēnei Hako mō ia {C:red,E:1}Oranga{} kua hinga",
+ "{C:inactive}(I tēnei wā he {C:chips}+#2#{C:inactive} Kapa)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Mahue ki te Mahue",
+ text = {
+ "{C:blue}+#1#{} Ringa me te {C:red}+#2#{} Whiu",
+ "mō ia Mahue {C:attention}Ārai{} mēnā e nui",
+ "ake ana ō Mahue i tō {X:purple,C:white}Hoariri{}",
+ "{C:inactive}(I tēnei wā {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Kōkiri te Peti!",
+ text = {
+ "He {C:green}#1#/#2#{} te tūpono ka",
+ "tīkina te {X:mult,C:white}X#3#{} Rea me te {C:money}$#4#{}",
+ "He {C:green}#5#/#6#{} te tūpono ka",
+ "whiwhi tō {X:purple,C:white}Hoariri{} i te {C:money}$#7#",
+ },
+ },
+ j_mp_speedrun = {
+ name = "TĀKAROTERE",
+ text = {
+ "Mēnā ka tae atu koe ki te",
+ "{C:attention}whawhai{} i mua i tō {X:purple,C:white}Hoariri{},,",
+ "ka tīkina tētahi Kāri {C:spectral}Tūmatarau{}",
+ "{C:inactive}(Me whai wāhi)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Hako Kotahi",
+ text = {
+ "Hei te wā ka {C:attention}whawhai{} ki tō",
+ "{X:purple,C:white}Hoariri{}, ka tāpirihia te {X:mult,C:white}X#1#{} Rea mō",
+ "ia {C:blue}Ringa{} e toe ana ki tō {X:purple,C:white}Hoariri",
+ "{C:inactive}(Kāore e nui ake i te {X:mult,C:white}X#2#{C:inactive}, I tēnei wā he {X:mult,C:white}X#3#{C:inactive})",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Tāhae Tāra",
+ text = {
+ "Hei te toa, ka tīkina",
+ "te {C:money}$#1#{} mo ia {C:money}$#2#{} i whakamahia",
+ "e tō {X:purple,C:white}Hoariri{} i te toa kua pahure",
+ },
+ },
+ j_mp_taxes = {
+ name = "Tāke",
+ text = {
+ "Mēnā ka hoko atu tō {X:purple,C:white}Hoariri",
+ "i tētahi kāri, ka {C:mult}+#1#{} Rea",
+ "{C:inactive}(I tēnei wā he {C:mult}+#2#{C:inactive} Rea)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Autō",
+ text = {
+ "Whai muri i te {C:attention}#1#{} rauna,",
+ "hokona atu tēnei Hako, ā,",
+ "ka {C:attention}tōruatia{} te {C:attention}Hako{} utu",
+ "nui rawa atu o tō {X:purple,C:white}Hoariri{}",
+ "{C:inactive}(I tēnei wā he {C:attention}#2#{C:inactive}/#3# rauna)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Parehe",
+ text = {
+ "{C:red}+#1#{} Whiu mō ia Kaitākaro",
+ "{C:red}-#2#{} Whiu hei te wā ka kōwhirihia",
+ "e tētahi Kaitākaro i te Ārai",
+ "Mēnā ka Mahue tō {X:purple,C:white}Hoariri{}, ka kaingia",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Kaiwhakamārie",
+ text = {
+ "{X:mult,C:white}X#1#{} Rea mēnā kāore e",
+ "{C:attention}whawhai{} ana ki tō {X:purple,C:white}Hoariri{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Mātangatanga",
+ text = {
+ "Ka purei anō te kāri {C:attention}tuatahi{} me te",
+ "{C:attention}kāri tuarua{} i te wā ka tukuna kia",
+ "{C:attention}#1#{} anō ngā wā",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Tūroa",
+ text = {
+ "Whakahekea te taumata",
+ "mō te {C:legendary,E:1}ringa poka{} kaha ",
+ "rawa atu o tō {X:purple,C:white}Hoariri{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Te Hoariri",
+ text = {
+ "Ka whawhai kōrua ko tō",
+ "Hoariri, ko wai ka eke?",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Mariko",
+ text = {
+ "He {C:attention}Mutunga Kore{}, {C:dark_edition}Tōraro{} anō hoki",
+ "He mea hanga, urupatu hoki e tō {X:purple,C:white}Hoariri{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Kāri Karāhe",
+ text = {
+ "{X:mult,C:white} X#1# {} Rea",
+ "He {C:green}#2#/#3#{} te tūpono ka",
+ "pakaru te kāri",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Kāri Karāhe",
+ text = {
+ "{X:mult,C:white} X#1# {} Rea",
+ "He {C:green}#2#/#3#{} te tūpono ka",
+ "pakaru te kāri",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Te Hoariri",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Koinei tō Hoariri",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Mariko",
+ },
+ dictionary = {
+ b_singleplayer = "Takitahi",
+ b_join_lobby = "Kuhu i te Rūma",
+ b_return_lobby = "Hoki ki te Rūma",
+ b_reconnect = "Hono ki te Ipurangi",
+ b_create_lobby = "Hangāia te Rūma",
+ b_start_lobby = "Tīmatahia",
+ b_ready = "Kia rite!",
+ b_unready = "Kāore e rite",
+ b_leave_lobby = "Wehe i te Rūma",
+ b_mp_discord = "Discord o Balatro Multiplayer",
+ b_start = "KŌKIRI",
+ b_wait_for_host_start = {
+ "E TATARI ANA KI",
+ "TE KAIWHAKAHAERE",
+ },
+ b_wait_for_players = {
+ "E TATARI ANA KI",
+ "NGĀ KAITĀKARO",
+ },
+ b_lobby_options = "KŌWHIRINGA",
+ b_copy_clipboard = "Kape",
+ b_view_code = "KUPU HUNA",
+ b_copy_code = "KAPE HUNA",
+ b_leave = "WEHE",
+ b_opts_cb_money = "Ka riro pūtea i ia hinganga",
+ b_opts_no_gold_on_loss = "Ki te hinga, kāore e whai Whiwhinga",
+ b_opts_death_on_loss = "Ki te hinga ki te Ārai Iti/Nui, ka hinga tētahi Oranga",
+ b_opts_start_antes = "Te Kōeke Tīmata",
+ b_opts_diff_seeds = "Ka rerekē tō ia Kaitākaro Kākano",
+ b_opts_lives = "Oranga",
+ b_opts_multiplayer_jokers = "Tākarohia ngā Hako MULTIPLAYER",
+ b_opts_player_diff_deck = "He rerekē ngā putu o ia kaitākaro",
+ b_opts_normal_bosses = "Ka whai pūkenga ngā Ārai Matua",
+ b_reset = "Kōwhiri Anō",
+ b_set_custom_seed = "Kōwhirihia te Kākano",
+ b_mp_kofi_button = "Tēnā tautokona au ki te Ko-fi",
+ b_unstuck = "Āwhinatia (Hapa)",
+ b_unstuck_arcana = "Kua mau ki te Huinga",
+ b_unstuck_blind = "Kei waho au i te whawhai",
+ b_misprint_display = "Whakaatuhia te Kāri kei te runga o tō putu",
+ b_players = "Ngā Kaitākaro",
+ b_continue_singleplayer = "Haere takitahi tonu",
+ b_the_order_integration = "Whakamahia ''The Order''",
+ k_enemy_score = "Te tatau o tō Hoariri",
+ k_enemy_hands = "Ngā Ringa e toe ana: ",
+ k_coming_soon = "Hei ākuanei!",
+ k_wait_enemy = "E tatari ana ki tō Hoariri...",
+ k_lives = "Oranga",
+ k_lost_life = "Kua riro",
+ k_total_lives_lost = "Ngā Oranga kua riro ($4 ia Oranga)",
+ k_attrition_name = "Kakari Roa",
+ k_enter_lobby_code = "Whakaurua te Kupu Huna",
+ k_paste = "Whakaurua te Kākano",
+ k_username = "Tō Ingoa:",
+ k_enter_username = "Whakaurua tō Ingoa",
+ k_join_discord = "Whakatapoko ki te ",
+ k_discord_msg = "I reira, rīpoata ai i ngā hapa o te mūrere",
+ k_enter_to_save = "Pēhia te 'Enter' kia pupuri",
+ k_in_lobby = "Kei te Rūma",
+ k_connected = "Kua hono ki te Ipurangi",
+ k_warn_service = "KIA TŪPATO: Kāore e hono ana ki a Balatro Multiplayer",
+ k_set_name = "Kōwhiria tō Ingoa ki te Tahua Matua! (Mūrere > Multiplayer > Config)",
+ k_mod_hash_warning = "He rerekē ngā Mūrere ō ētahi Kaitākaro. Ka puta pea ētahi raru!",
+ k_lobby_options = "Kōwhiringa",
+ k_connect_player = "Ngā Kaitākaro:",
+ k_opts_only_host = "Kei te Kaiwhakahaere anake te tikanga",
+ k_opts_gm = "Tīnihia ngā Ture",
+ k_bl_life = "Ko wai ka eke?",
+ k_bl_or = "",
+ k_bl_death = "Ko wai ka hinga?",
+ k_current_seed = "Te Kākano: ",
+ k_random = "Tupurangi",
+ k_standard = "Takirua",
+ k_standard_description = "Ka tāpirihia ngā Kāri MULTIPLAYER, ka tini hoki ētahi o ngā Kāri o Balatro.",
+ k_vanilla = "Kēmu Noa",
+ k_vanilla_description = "Kāore e tāpirihia ngā Kāri MULTIPLAYER, ka whakamahi noa i ngā Kāri o Balatro.",
+ k_weekly = "Kēmu-ā-Wiki",
+ k_weekly_description = "Ka tini ngā ture o tēnei kēmu i ia wiki, rua wiki rānei. Mā te tākaro ka kite! I tēnei wā: ",
+ k_tournament = "Tōnamana",
+ k_tournament_description = "Mō ngā Tōnamana, he orite ki te Kēmu Noa engari kāore he Kōwhiringa.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "He kēmu nā @dr_monty_the_snek mai i te Discord i hanga, ā, kua tāpirihia ki te MULTIPLAYER.",
+ k_oops_ex = "Aue!",
+ k_timer = "Matawā",
+ k_mods_list = "Ngā Mūrere",
+ k_enemy_jokers = "Tō Hoariri Hako",
+ k_the_order_credit = "*He mea hanga e @MathIsFun_",
+ k_the_order_integration_desc = "Ka tīnihia te kōwhiringa o ngā Kāri kia kaua e whiri-ā-kōeke, ā, ka whakamahia te puna kotahi mō ia momo",
+ k_requires_restart = "*Me whakatūwhera anō i te kēmu kia tīnihia",
+ ml_enemy_loc = {
+ "Te wāhi o",
+ "tō Hoariri",
+ },
+ ml_mp_kofi_message = {
+ "Kōtahi anake te tāngata e",
+ "whakahaere, e tuarā ana ",
+ "i te Balatro Multiplayer.",
+ "Mēnā e pīrangi ana,",
+ },
+ ml_lobby_info = {
+ "Kōwhiringa",
+ "Rūma",
+ },
+ loc_ready = "Kua rite",
+ loc_selecting = "E kōwhiiri Ārai ana",
+ loc_shop = "E hokohoko ana",
+ loc_playing = "Kei te ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Pikitia: #1#",
+ },
+ a_mp_code = {
+ "Waehere: #1#",
+ },
+ a_mp_idea = {
+ "Whakaaro: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Mahue kei mua",
+ },
+ a_mp_skips_behind = {
+ "#1# Mahue kei muri",
+ },
+ a_mp_skips_tied = {
+ "Kua taurite",
+ },
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "Kua {C:dark_edition}tīnihia{} te {C:attention}Mātngatanga{}",
+ },
+ ch_c_glass_cards_rework = {
+ "Kua {C:dark_edition}tīnihia{} ngā {C:attention}Kāri Karāhe",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Takirua",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Tōnamana",
+ c_mp_weekly = "Kēmu-ā-Wiki",
+ c_mp_vanilla = "Kēmu Noa",
+ c_mp_misprint_deck = "Putu Hapa",
+ c_mp_legendaries = "Te Kō a te Kōkako",
+ c_mp_psychosis = "Mate Ahotea",
+ c_mp_scratch = "I te Kore",
+ c_mp_twin_towers = "Pou Takirua",
+ c_mp_in_the_red = "Kei te Whero",
+ c_mp_paper_money = "Pūtea Pepa",
+ c_mp_high_hand = "Ringa Raka",
+ c_mp_chore_list = "Rārangi Mahi",
+ c_mp_oops_all_jokers = "Aue! He Hako Katoa",
+ c_mp_divination = "Te Matakitenga",
+ c_mp_skip_off = "Mahue ki te Mahue",
+ c_mp_lets_go_gambling = "Kōkiri te Peti",
+ c_mp_speed = "Te Horo",
+ },
+ },
+}
diff --git a/localization/nl.lua b/localization/nl.lua
new file mode 100644
index 00000000..26e463af
--- /dev/null
+++ b/localization/nl.lua
@@ -0,0 +1,377 @@
+-- Localization by @blablabla_c on discord
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "KAPOT",
+ text = {
+ "Deze kaart is kapot of",
+ "niet geïmplementeerd in de huidige versie",
+ "van een mod die je gebruikt.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Verdedigende Joker",
+ text = {
+ "{C:chips}+#1#{} fiches voor elk {C:red,E:1}leven{}",
+ "minder dan je {X:purple,C:white}Aartsvijand{}",
+ "{C:inactive}(Momenteel {C:chips}+#2#{C:inactive} fiches)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Over-Slag",
+ text = {
+ "{C:blue}+#1#{} Handen en {C:red}+#2#{} weggooimogelijkheden",
+ "per {C:attention}Blind{} die je meer hebt overgeslagen",
+ "dan jouw {X:purple,C:white}Aartsvijand{}",
+ "{C:inactive}(Momenteel {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Let's Go Gambling",
+ text = {
+ "{C:green}#1# in #2#{} kans om",
+ "{X:mult,C:white}X#3#{} Multi en {C:money}$#4#{}",
+ "{C:green}#5# in #6#{} kans om",
+ "je {X:purple,C:white}Aartsvijand{} {C:money}$#7#{} te geven",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Als je een {C:attention}PvP Blind",
+ "bereikt voor je {X:purple,C:white}Aartsvijand{},",
+ "creëer een willekeurige {C:spectral}Spectrale{} kaart",
+ "{C:inactive}(Moet ruimte voor zijn)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Siamese Joker",
+ text = {
+ "Tijdens een {C:attention}PvP Blind{}, krijg",
+ "{X:mult,C:white}X#1#{} Multi voor elke {C:blue}Hand{}",
+ "die je {X:purple,C:white}Aartsvijand{} nog over heeft",
+ "{C:inactive}(Maximum {X:mult,C:white}X#2#{C:inactive} Multi, Momenteel {X:mult,C:white}X#3#{C:inactive} Multi)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Geldwolf",
+ text = {
+ "Aan het einde van een ronde, krijg {C:money}$#1#{} voor",
+ "elke {C:money}$#2#{} dat je {X:purple,C:white}Aartsvijand{} heeft",
+ "gespendeerd in de overeenkomstige winkel {C:attention}vorige ante{}",
+ "{C:inactive}(Volgende uitbetaling: {C:money}$#3#{C:inactive})",
+ -- payout = uitbetaling?
+ },
+ },
+ j_mp_taxes = {
+ name = "Belastingen",
+ text = {
+ "{C:mult}+#1#{} Multi voor elke kaart dat je",
+ "{X:purple,C:white}Aartsvijand{} deze run {C:attention}sold{} deze run, updates",
+ "wanneer de {C:attention}PvP Blind{} is geselecteerd",
+ "{C:inactive}(Momenteel {C:mult}+#2#{C:inactive} Multi,",
+ "{C:inactive}zal {C:mult}+#3#{C:inactive} Multi zijn)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Magneet",
+ text = {
+ "Na {C:attention}#1#{} ronde,",
+ "verkoop deze kaart om je {X:purple,C:white}Aartsvijands{}",
+ "{C:attention}Joker{} met hoogste verkoopwaarde",
+ "te {C:attention}kopiëren{}",
+ "{C:inactive}(Momenteel {C:attention}#2#{C:inactive}/#3# rondes)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "{C:red}+#1#{} weggooimogelijkheden en",
+ "{C:red}#2#{} weggooimogelijkheden voor je Aartsvijand",
+ "Opgegeten na de {C:attention}PvP Blind{}",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifist",
+ text = {
+ "{X:mult,C:white}X#1#{} Multi wanneer",
+ "je niet in een {C:attention}PvP Blind{} bent",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Ongeldige Stem",
+ text = {
+ "Reactiveer de {C:attention}eerste{} en {C:attention}tweede{}",
+ "gespeelde kaart die scoort",
+ "{C:attention}#1#{} keer extra",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroïde",
+ text = {
+ "Verwijder #1# level van je",
+ "{X:purple,C:white}Aartsvijands'{} hoogste level",
+ "{C:legendary,E:1}poker hand{}",
+ "aan het begin van de {C:attention}PvP Blind{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Je Aartsvijand",
+ text = {
+ "Neem het op tegen een andere speler,",
+ "meeste fiches wint",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Spook",
+ text = {
+ "{C:attention}Eeuwig{} en {C:dark_edition}Negatief{}",
+ "Gecreëerd en vernietigd door je {X:purple,C:white}Aartsvijand{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Glazen Kaart",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# in #3#{} kans om",
+ "kaart te vernietigen",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Glazen Kaart",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "{C:green}#2# in #3#{} kans om",
+ "kaart te vernietigen",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Aartsvijand",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Je enige echte Aartsvijand",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Spook",
+ },
+ dictionary = {
+ b_singleplayer = "Singleplayer",
+ b_join_lobby = "Sluit je aan bij een lobby",
+ b_return_lobby = "Ga terug naar lobby",
+ b_reconnect = "Opnieuw verbinding maken",
+ b_create_lobby = "Creëer Lobby",
+ b_start_lobby = "Start Lobby",
+ b_ready = "Klaar",
+ b_unready = "Niet meer klaar",
+ b_leave_lobby = "Verlaat de lobby",
+ b_mp_discord = "Balatro Multiplayer Discord Server",
+ b_start = "START",
+ b_wait_for_host_start = {
+ "WACHTEN OP",
+ "HOST OM TE STARTEN",
+ },
+ b_wait_for_players = {
+ "WACHTEN OP",
+ "SPELERS",
+ },
+ b_lobby_options = "LOBBY OPTIES",
+ b_copy_clipboard = "Kopieer naar klembord",
+ b_view_code = "BEKIJK CODE",
+ b_copy_code = "KOPIEER CODE",
+ b_leave = "VERLAAT",
+ b_opts_cb_money = "Geef comeback $ bij levensverlies",
+ b_opts_no_gold_on_loss = "Krijg geen blind beloning na verlies van ronde",
+ b_opts_death_on_loss = "Verlies een leven op non-PvP ronde verlies",
+ b_opts_start_antes = "Beginnende Antes",
+ b_opts_diff_seeds = "Spelers hebben verschillende seeds",
+ b_opts_lives = "Levens",
+ b_opts_multiplayer_jokers = "Schakel Multiplayer kaarten in",
+ b_opts_player_diff_deck = "Spelers hebben verschillende Kaartspellen",
+ b_opts_normal_bosses = "Schakel Blind van een baas effecten in",
+ b_opts_timer = "Schakel Timer in",
+ b_reset = "Reset",
+ b_set_custom_seed = "Stel Aangepaste Seed In",
+ b_mp_kofi_button = "mij te steunen op Ko-Fi",
+ b_unstuck = "Kom los",
+ b_unstuck_blind = "Vast buiten PvP",
+ b_misprint_display = "Toon de volgende kaart van je Kaartspel",
+ b_players = "Spelers",
+ b_lobby_info = "Lobby Informatie",
+ b_continue_singleplayer = "Speel verder in Singleplayer",
+ b_the_order_integration = "Schakel De Orde Integratie in",
+ b_view_nemesis_deck = "Toon Aartsvijands Kaartspel",
+ b_toggle_jokers = "Schakel Jokers in",
+ -- What's the context of toggle jokers?
+ k_continue_singleplayer_tooltip = "Dit zal je huidige Singleplayer run overschrijden",
+ k_enemy_score = "Huidige score van je vijand",
+ k_enemy_hands = "Handen van vijand over: ",
+ k_coming_soon = "Binnenkort Beschikbaar!",
+ k_wait_enemy = "Wachten tot vijand is geëindigt...",
+ k_wait_enemy_reach_this_blind = "Wachten tot de vijand aan deze blind geraakt...",
+ k_lives = "Levens",
+ k_lost_life = "Leven verloren",
+ k_total_lives_lost = " Aantal Levens Verloren ($4 per stuk)",
+ k_attrition_name = "Standaard",
+ k_enter_lobby_code = "Voer de Lobby Code in",
+ k_paste = "Plak Van Klembord",
+ k_username = "Gebruikersnaam:",
+ k_enter_username = "Voer gebruikersnaam in",
+ k_join_discord = "Join de ",
+ k_discord_msg = "Je kan daar eventuele bugs melden en kan er spelers vinden om tegen te spelen",
+ k_enter_to_save = "Druk op enter om op te slaan",
+ k_in_lobby = "In de lobby",
+ k_connected = "Verbonden met Service",
+ k_warn_service = "WAARSCHUWING: Kan de multiplayer service niet vinden",
+ k_set_name = "Verander je gebruikersnaam in het hoofdmenu! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "Spelers hebben verschillende mods of mod versies! Dit kan problemen veroorzaken!",
+ k_warning_unlock_profile = "Het profiel waarmee je aan het spelen bent is niet volledig vrijgespeeld. Als dit een geklasseerd spel of een spel in een toernooi is, maak een nieuw profiel aan en druk op alles vrijspelen in profiel instellingen.",
+ k_warning_nemesis_unlock = "Je tegenstander is aan het spelen op een profiel dat niet volledig vrijgespeeld is. Help ze aub met een nieuw profiel aan te maken en op alles vrijspelen te drukken in profiel instellingen.",
+ k_warning_no_order = "Eén speler heeft De Orde Integratie ingeschakelt terwijl de andere niet. Dit zal er voor zorgen dat de seeds verschillend zullen zijn.",
+ k_warning_cheating1 = "Als je dit ziet, kan het zijn dat je tegenstander aan het valsspelen is.",
+ k_warning_cheating2 = "Als dit een geklasseerd spel is, zend aub het bericht '%s' en open een support ticket in #support.",
+ -- Idk if this has to be translated, because ranked games are in English
+ k_message1 = "Hold on, my mom made pizza pops",
+ k_message2 = "One sec, i gotta grab my slow cooker pork roast",
+ k_message3 = "One moment, getting a call from my mom",
+ k_message4 = "Brb, my cat is on fire",
+ k_message5 = "Wait, I think I left the stove on",
+ k_message6 = "Hold up, my pet rock just ran away",
+ k_message7 = "One sec, my plants are asking for water",
+ k_message8 = "Brb, my socks are plotting against me",
+ k_message9 = "Sorry, my WiFi is having an existential crisis",
+ -- Didn't translate the k_messages
+ k_lobby_options = "Lobby Opties",
+ k_connect_player = "Verbonden Spelers:",
+ k_opts_only_host = "Enkel de Lobby Host kan deze opties veranderen",
+ k_opts_gm = "Spelmodus Modificaties",
+ k_opts_pvp_start_round = "PVP Start op Ante",
+ k_opts_pvp_timer = "Timer",
+ k_opts_showdown_starting_antes = "Showdown Start op Ante",
+ k_opts_pvp_timer_increment = "Timer Toename",
+ k_bl_life = "Leven",
+ k_bl_or = "of",
+ k_bl_death = "Dood",
+ k_bl_mostchips = "Meeste fiches wint",
+ k_current_seed = "Huidige seed: ",
+ k_random = "Willekeurig",
+ k_standard = "Standaard",
+ k_standard_description = "De standaard regels, inclusief Multiplayer kaarten en veranderingen aan Balatro zelf om beter te passen binnen Multiplayer.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "De vanilla regels, geen Multiplayer kaarten, geen veranderingen aan Balatro zelf.",
+ k_weekly = "Wekelijks",
+ k_weekly_description = "Speciale regels die wekelijks of om de week veranderen. Je moet zelf maar uitzoeken wat het deze week doet! Momenteel: ",
+ k_tournament = "Tournament",
+ k_tournament_description = "De tournament regels, dit doet hetzelfde als de standaard regels maar de instellingen kunnen niet worden gewijzigd.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Origineel wekelijkse regels bedacht door @dr_monty_the_snek in de discord server die permanent aan de mod zijn toegevoegd.",
+ k_attrition = "Attrition",
+ k_attrition_description = "Every boss blind is a Nemesis blind. No time to prepare. This gamemode forces you to be battle-ready from the start.",
+ k_showdown = "Showdown",
+ k_showdown_description = "Na de eerste 2 antes, elke blind is een Aartsvijand blind. Deze spelmodus geeft je tijd om je klaar te maken voor het vechten.",
+ k_survival = "Survival",
+ k_survival_description = "De speler die de verste blind verslaat, wint. Geen Aartsvijand blinds. Deze spelmodus is een test van je vaardigheden om geleidelijk de hoogste score op te bouwen met Vanilla handen.",
+ k_oops_ex = "Oeps!",
+ k_asteroids = "Asteroïds",
+ k_amount_short = "A.",
+ k_filed_ex = "Gevuld!",
+ -- What's the context of filled?
+ k_timer = "Timer",
+ k_mods_list = "Lijst van Mods",
+ k_enemy_jokers = "Tegenstanders' Jokers",
+ k_your_jokers = "Jouw Jokers",
+ k_nemesis_deck = "Aartsvijands' Deck",
+ k_your_deck = "Jouw Deck",
+ k_the_order_credit = "*Credits naar @MathIsFun_",
+ k_the_order_integration_desc = "Dit zal de creatie van kaarten patchen om niet gebaseerd te zijn op antes en één pool voor elk type/zeldzaamheid.",
+ k_requires_restart = "*Vereist een restart om te werken",
+ k_bans = "Verboden",
+ k_reworks = "Toevoegingen/Herwerkingen",
+ ml_enemy_loc = {
+ "Locatie",
+ "Tegenstander",
+ },
+ ml_mp_kofi_message = {
+ "Deze mod en spel server is",
+ "ontwikkeld en onderhouden door",
+ "één persoon, als",
+ "je het leuk vindt, overweeg",
+ },
+ ml_lobby_info = {
+ "Lobby",
+ "Informatie",
+ },
+ loc_ready = "Klaar voor PvP",
+ loc_selecting = "Blind aan het selecteren",
+ loc_shop = "Shoppen",
+ loc_playing = "Speelt ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Kunst: #1#",
+ },
+ a_mp_code = {
+ "Code: #1#",
+ },
+ a_mp_idea = {
+ "Idee: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Skips Vooruit",
+ },
+ a_mp_skips_behind = {
+ "#1# Skips Achter",
+ },
+ a_mp_skips_tied = {
+ "Evenveel",
+ },
+ k_banned_objs = "Verboden #1#",
+ k_no_banned_objs = "Niet Verboden #1#",
+ k_reworked_objs = "Toegevoegd/Herwerkt #1#",
+ k_no_reworked_objs = "Toegevoegd/Herwerkt #1#",
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Ongeldige Stem{} is {C:dark_edition}herwerkt",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Glazen Kaarten{} zijn {C:dark_edition}herwerkt",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Standaard",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Tournament",
+ c_mp_weekly = "Weekelijks",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Foutgedrukte Deck",
+ c_mp_legendaries = "Legendarische",
+ c_mp_psychosis = "Psychose",
+ c_mp_scratch = "Van het begin",
+ c_mp_twin_towers = "Twin Towers",
+ c_mp_in_the_red = "In het Rood",
+ c_mp_paper_money = "Papieren Geld",
+ c_mp_high_hand = "Hoge Hand",
+ c_mp_chore_list = "Taken Lijst",
+ c_mp_oops_all_jokers = "Oops! Enkel Jokers",
+ c_mp_divination = "Waarzeggerij",
+ c_mp_skip_off = "Over-Slag",
+ c_mp_lets_go_gambling = "Let's Go Gambling",
+ c_mp_speed = "Snelheid",
+ },
+ },
+}
diff --git a/localization/pl.lua b/localization/pl.lua
new file mode 100644
index 00000000..17c585b2
--- /dev/null
+++ b/localization/pl.lua
@@ -0,0 +1,428 @@
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "BŁĄD",
+ text = {
+ "Ta karta jest zepsuta lub nie",
+ "została dodana to wersji moda,",
+ "który jest aktualnie używany.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Joker Obrońca",
+ text = {
+ "{C:chips}+#1#{} żet. o każde {C:red,E:1}życie{}",
+ "mniej niż twój {X:purple,C:white}Nemesis{}",
+ "{C:inactive}(obecnie {C:chips}+#2#{C:inactive} żet.)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Gra w Klasy",
+ text = {
+ "{C:blue}+#1#{} do Rąk i {C:red}+#2#{} do Zrzutek",
+ "o każdą dodatkową {C:attention}Przeszkadzajkę{} pominiętą",
+ "w porównaniu do twojego {X:purple,C:white}Nemesis{}",
+ "{C:inactive}(obecnie {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Hazardujmy",
+ text = {
+ "{C:green}#1# na #2#{} szans na",
+ "mnoz. {X:mult,C:white}X#3#{} i {C:money}$#4#{}",
+ "{C:green}#5# na #6#{} szans na",
+ "danie twojemu {X:purple,C:white}Nemesis{} {C:money}$#7#",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Jeśli dotrzesz do {C:attention}Przeszkadzajki PVP",
+ "przed swoim {X:purple,C:white}Nemesis{},",
+ "stwórz losową kartę {C:spectral}Ducha{}",
+ "{C:inactive}(wymaga miejsca)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Złączony Joker",
+ text = {
+ "Gdy jesteś w {C:attention}Przeszkadzajkę PVP{}, ",
+ "mnoż. {X:mult,C:white}X#1#{} za każdą {C:blue}Rękę{},",
+ "które pozostały twojemu {X:purple,C:white}Nemesis{}",
+ "{C:inactive}(maks. mnoż. {X:mult,C:white}X#2#{C:inactive}, obeceny mnoż. {X:mult,C:white}X#3#{C:inactive})",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Skąpiec",
+ text = {
+ "Gdy wstąpisz do sklepu, zdobądź",
+ "{C:money}$#1#{} za każde {C:money}$#2#{}",
+ "twój {X:purple,C:white}Nemesis{} wydał w poprzedniej rundzie",
+ },
+ },
+ j_mp_taxes = {
+ name = "Podatki",
+ text = {
+ "Gdy twój {X:purple,C:white}Nemesis{} sprzeda",
+ "kartę, dodaj {C:mult}+#1#{} do mnoż.",
+ "{C:inactive}(Obecnie {C:mult}+#2#{C:inactive} do mnoż.)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Magnes",
+ text = {
+ "Po {C:attention}#1#{} rundach,",
+ "sprzedaj tę kartę aby {C:attention}zduplikować{}",
+ "{C:attention}Jokera{} z największą wartością sprzedaży",
+ "twoiego {X:purple,C:white}Nemesis",
+ "{C:inactive}(Obecnie {C:attention}#2#{C:inactive}/#3# rund)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "{C:red}+#1#{} do zrzutek dla wszystkich graczy",
+ "{C:red}-#2#{} do zrzutkek gdy którykolwiek gracz",
+ "wybierze przeszkadzajkę",
+ "Zniszcona gdy twój {X:purple,C:white}Nemesis{}",
+ "pominie przeszkadzajkę",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Joker Pacyfista",
+ text = {
+ "Mnoż. {X:mult,C:white}X#1#{} gdy",
+ "nie jesteś w {C:attention}Przeszkadzajce PVP{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Na włosku",
+ text = {
+ "Aktywuj ponownie {C:attention}pierwszą{}",
+ "i drugą zagraną kartę w punktacji",
+ "jeszcze {C:attention}#1#{} raz(y)",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroida",
+ text = {
+ "Odbierz #1# poziom od",
+ "{C:legendary,E:1}układu pokerowego{}",
+ "z największym poziomem",
+ "twojego {X:purple,C:white}Nemesis{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Twój Nemesis",
+ text = {
+ "Zmierz się z rywalem,",
+ "zdobądź więcej żetonów",
+ "niż przeciwnik",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Widmo",
+ text = {
+ "{C:attention}Wieczny{} oraz {C:dark_edition}Negatyw{}",
+ "Ta karta jest stworzona i może być zniszczona",
+ "przez twoiego {X:purple,C:white}Nemesis{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Szklany Joker",
+ text = {
+ "Ten joker zdobywa {X:mult,C:white} X#1# {}",
+ "do mnoż. za każdą zniszczoną",
+ "{C:attention}Kartę Szklaną",
+ "{C:inactive}(obecnie {X:mult,C:white} X#2# {C:inactive} do mnoż.)",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Szklany Joker",
+ text = {
+ "Ten joker zdobywa {X:mult,C:white} X#1# {}",
+ "do mnoż. za każdą zniszczoną",
+ "{C:attention}Kartę Szklaną",
+ "{C:inactive}(obecnie {X:mult,C:white} X#2# {C:inactive} do mnoż.)",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Koktajlowa talia",
+ text = {
+ "Kopiuje efekty",
+ "{C:attention}3{} innych",
+ "losowych talii",
+ },
+ },
+ b_mp_gradient = {
+ name = "Gradientowa talia",
+ text = {
+ "Karty są także traktowane jak",
+ "jedna ranga {C:attention}wyższa{} lub {C:attention}niższa",
+ "przez wszystkie efekty {C:attention}jokerów{}",
+ },
+ },
+ b_mp_indigo = {
+ name = "Talia indigo",
+ text = {
+ "Wybierz {C:attention}1{} dodatkową kartę",
+ "w paczkach wzmacniających",
+ },
+ },
+ b_mp_orange = {
+ name = "Pomarańczowa talia",
+ text = {
+ "Rozpoczynacz podejście z",
+ "{C:attention,T:p_mp_standard_giga}gigapaczką standardową{} oraz",
+ "{C:attention}2{} kopiami {C:tarot,T:c_hanged_man}Wisielca",
+ },
+ },
+ b_mp_oracle = {
+ name = "Wyroczniowa talia",
+ text = {
+ "Rozpoczynacz podejście z",
+ "kartą {C:spectral,T:c_medium}Medium",
+ "i {C:attention,T:v_clearance_sale}Wyprzedarz",
+ "Limit pieniędzy to {C:money}$50",
+ },
+ },
+ b_mp_violet = {
+ name = "Fioletowa talia",
+ text = {
+ "{C:attention}+1{} kupon w sklepach.",
+ "W wejściu {C:attention}1{}, kupony",
+ "mają zniżkę {C:attention}50%{}",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "Heidelbergowa talia",
+ text = {
+ "Tworzy {C:dark_edition}Negatyw{}",
+ "{C:attention}1{} losowej posiadanej",
+ "{C:attention}zużywalnej{} karty",
+ "na końcu zakupów w {C:attention}sklepie",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Nemesis",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Twój jeden i jedyny Nemesis",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Gigapaczka standardowa",
+ text = {
+ "Wybierz {C:attention}#1#{} z",
+ "{C:attention}#2#{} kart {C:attention}rozgrywających{}, by",
+ "dodać je do swojej talii",
+ "{C:attention}Niepomijalna{}",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "Planetowa stawka",
+ text = {
+ "Fajniejsza, starsza siostra {C:attention}pomarańczowej{},",
+ "{C:attention}stawki{} która łaskawie oddała ci",
+ "{C:red}zrzutkę wsparcia emocjonalnego{}, bo",
+ "nawet ona nie jest taka okrutna",
+ },
+ },
+ stake_mp_spectral = {
+ name = "Stawka ducha",
+ text = {
+ "Stosuje efekty {C:planet}planetowej stawki{} plus:",
+ "{C:money}Wyporzyczane{} jokery pojawiają się,",
+ "Wymagany wynik skaluje się",
+ "jeszcze szybciej dla każdego {C:attention}wejścia",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "Stawka ducha+",
+ text = {
+ "Stosuje efekty {C:planet}stawki ducha{} plus:",
+ "Wymagany wynik skaluje się",
+ "jeszcze szybciej dla każdego {C:attention}wejścia",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Widmo",
+ },
+ dictionary = {
+ b_singleplayer = "Tryb Jednoosobowy",
+ b_join_lobby = "Dołącz do Lobby",
+ b_return_lobby = "Powrót do Lobby",
+ b_reconnect = "Połącz ponownie",
+ b_create_lobby = "Stwórz Lobby",
+ b_start_lobby = "Rozpocznij Grę",
+ b_ready = "Potwierdź",
+ b_unready = "Anuluj Gotowość",
+ b_leave_lobby = "Opuść Lobby",
+ b_mp_discord = "Serwera Discord Balatro Multiplayer",
+ b_start = "START",
+ b_wait_for_host_start = {
+ "CZEKANIE NA",
+ "ROZPOCZĘCZIE GRY",
+ },
+ b_wait_for_players = {
+ "CZEKANIE NA",
+ "GRACZY",
+ },
+ b_lobby_options = "OPCJE GRY",
+ b_copy_clipboard = "Skopiuj do schowka",
+ b_view_code = "POKAŻ KOD",
+ b_copy_code = "SKOPIUJ KOD",
+ b_leave = "OPUŚĆ LOBBY",
+ b_opts_cb_money = "Dostawanie $ po straconym życiu",
+ b_opts_no_gold_on_loss = " Brak nagrody od przeszkadzajki jeśli życie zostało stracone",
+ b_opts_death_on_loss = "Tracenie żyć od zwykłych przeszkadzajek",
+ b_opts_start_antes = "Wstęp Pierwszej Rundy",
+ b_opts_diff_seeds = "Gracze mogą mieć różne rozstawnienia",
+ b_opts_lives = "Życia",
+ b_opts_multiplayer_jokers = "Włącz Karty Trybu Wieloosoobowego",
+ b_opts_player_diff_deck = "Gracze mogą mieć różne talie",
+ b_opts_normal_bosses = "Włącz efekty zdonlości Przeszkadzajek Bossowych",
+ b_reset = "Resetuj",
+ b_set_custom_seed = "Wprowadź rozstawnienie niestandardowe",
+ b_mp_kofi_button = "Wspieraj mnie na Ko-fi",
+ b_unstuck = "Utknięnto?",
+ b_unstuck_blind = "Utknięnto poza PVP?",
+ b_misprint_display = "Wyświetl następną karte w talii",
+ b_players = "Gracze",
+ b_continue_singleplayer = "Kontynuuj w Trybie Jednosobowym",
+ b_the_order_integration = "Włącz Integracje modu The Order ",
+ k_enemy_score = "Wynik przeciwnika",
+ k_enemy_hands = "Pozostałe ręce przeciwnika: ",
+ k_coming_soon = "Wkrótce!",
+ k_wait_enemy = "Czekanie na koniec ruchu przeciwnika...",
+ k_lives = "Życia",
+ k_lost_life = "Stracono życie",
+ k_total_lives_lost = " Łacznie Stracone Życia (4$ każde)",
+ k_attrition_name = "Starcie",
+ k_enter_lobby_code = "Wpisz Kod do Lobby",
+ k_paste = "Wklej ze schowka",
+ k_username = "Psuedonim:",
+ k_enter_username = "Stwórz psuedonim",
+ k_join_discord = "Dołącz do ",
+ k_discord_msg = "Możesz tam zgłośić błedy z modem oraz znaleść innych graczy",
+ k_enter_to_save = "Naciśnij Enter aby zapisać",
+ k_in_lobby = "W lobby",
+ k_connected = "Połaczono z usługą",
+ k_warn_service = "UWAGA: Nie można znaleść usługi Multiplayer",
+ k_set_name = "Stwórź swoją nazwę w menu głownym! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "Gracze mają różne mody lub/oraz wersje! To może sprawić problem!",
+ k_lobby_options = "Opcje gry",
+ k_connect_player = "Gracze w Lobby:",
+ k_opts_only_host = "Tylko Gospodarz Gry może zmieniać te ustawienia",
+ k_opts_gm = "Modyfikacje",
+ k_opts_pvp_start_round = "PVP rozpoczyna się na rundzie",
+ k_bl_life = "Życie",
+ k_bl_or = "lub",
+ k_bl_death = "Śmierć",
+ k_bl_mostchips = "Zdobądź więcej żetonów",
+ k_current_seed = "Aktualne rozstawienie: ",
+ k_random = "Losowe",
+ k_standard = "Standardowe",
+ k_standard_description = "Standardowy zbiór zasad, posiada karty Multiplayer oraz inne zmiany do bazowej treści, które sprawiają ulepszenia do mety Trybu Wieloosobowego.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "Zbiór zasad Vanilla bez kart Multiplayer i bez zmian do bazowej treści gry.",
+ k_weekly = "Tygodniowe",
+ k_weekly_description = "Wyjątkowy zbiór zasad który się zmienia cotygodniowo lub codwutygodniowo. Obecny zbiór zasad: ",
+ k_tournament = "Turnejowe",
+ k_tournament_description = "Zbiór zasad dla Turnejów, taki sam jak zbiór zasad Standardowy tylko bez możliwości do zmienia opcji lobby.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Cotygodniowy zbiór zasad zaprojektowany przez @dr_monty_the_snek na serwerze discord, który został dodany to tego moda na stałe.",
+ k_oops_ex = "Ups!",
+ k_timer = "Czas",
+ k_mods_list = "Lista Modów",
+ k_the_order_credit = "*Twórca: @MathIsFun_",
+ k_the_order_integration_desc = "Ten mod sprawi, że kreacja kart nie będzie zależna od wstępu i wybór kart będzie ten sam dla wszystkich graczy.",
+ k_requires_restart = "*Wymaga ponownego uruchomienia gry",
+ ml_enemy_loc = {
+ "Lokacja",
+ "prezeciwnika",
+ },
+ ml_mp_kofi_message = {
+ "Ten mod był zaprogramowany",
+ "i jest utrzymywany przez",
+ "jedną osobę, jeżeli tobie się",
+ "spodobał,",
+ },
+ ml_lobby_info = {
+ "Informacje",
+ "o Lobby",
+ },
+ loc_ready = "Gotowość do PVP",
+ loc_selecting = "Wybiera Przeszkadzajkę",
+ loc_shop = "W Sklepie",
+ loc_playing = "Gra w ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Zasoby: #1#",
+ },
+ a_mp_code = {
+ "Programowanie: #1#",
+ },
+ a_mp_idea = {
+ "Pomysł: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Pominięć w Przód",
+ },
+ a_mp_skips_behind = {
+ "#1# Pominięć w Tył",
+ },
+ a_mp_skips_tied = {
+ "Remis",
+ },
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "Joker {C:attention}Na włosku{} został {C:dark_edition}przerobiony",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Karty Szklane{} zostały {C:dark_edition}przerobione",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Standardowe",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Turnejowe",
+ c_mp_weekly = "Tygodniowe",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Talia - Błąd w druku",
+ c_mp_legendaries = "Jokery Legendarne",
+ c_mp_psychosis = "Psychoza",
+ c_mp_scratch = "Od zera",
+ c_mp_twin_towers = "Wieże Bliźniacze",
+ c_mp_in_the_red = "Zadłużony",
+ c_mp_paper_money = "Banknoty",
+ c_mp_high_hand = "Wielka Ręka",
+ c_mp_chore_list = "Obowiązki",
+ c_mp_oops_all_jokers = "Ups! Same Jokery",
+ c_mp_divination = "Wróżbiarstwo",
+ c_mp_skip_off = "Gra w Klasy",
+ c_mp_lets_go_gambling = "Hazardujmy",
+ c_mp_speed = "Speedrun",
+ },
+ },
+}
diff --git a/localization/pt_BR.lua b/localization/pt_BR.lua
new file mode 100644
index 00000000..3f2c05eb
--- /dev/null
+++ b/localization/pt_BR.lua
@@ -0,0 +1,433 @@
+-- Original localization by jinjoguy on Discord.
+-- Futher localization by Brawmario on GitHub.
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "QUEBRADA",
+ text = {
+ "Está carta está quebrada ou ainda",
+ "não foi implementada na versão",
+ "atual de um mod que você ativou.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Curinga Defensivo",
+ text = {
+ "Este curinga ganha {C:chips}+#1#{} Fichas",
+ "a cada {C:red,E:1}vida{} sua a menos",
+ "do que o seu {X:purple,C:white}Rival{}",
+ "{C:inactive}(No momento, {C:chips}+#2#{C:inactive} Fichas)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Amarelinha",
+ text = {
+ "{C:blue}+#1#{} Mão(s) e {C:red}+#2#{} Descarte(s)",
+ "por cada {C:attention}Blind{} ignorado",
+ "em relação ao seu {X:purple,C:white}Rival{}",
+ "{C:inactive}(No momento, {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive})",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Bora Apostar",
+ text = {
+ "Chance de {C:green}#1# em #2#{} de",
+ "{X:mult,C:white}X#3#{} Multi e {C:money}$#4#{}",
+ "Chance de {C:green}#5# em #6#{} de dar",
+ "ao seu {X:purple,C:white}Rival{} {C:money}$#7#{} em um {C:attention}Blind de Duelo{}",
+ },
+ },
+ j_mp_speedrun = {
+ name = "SPEEDRUN",
+ text = {
+ "Se você alcançar um {C:attention}Blind de Duelo{}",
+ "antes do seu {X:purple,C:white}Rival{},",
+ "cria uma carta {C:spectral}Espectral{} aleatória",
+ "{C:inactive}(Deve ter espaço)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Curinga Siamesa",
+ text = {
+ "Enquanto em um {C:attention}Blind de Duelo{}, este curinga",
+ "ganha {X:mult,C:white}X#1#{} Multi para cada {C:blue}Mão{} que",
+ "seu {X:purple,C:white}Rival{} ainda não tenha jogado",
+ "{C:inactive}(Máximo de {X:mult,C:white}X#2#{C:inactive} Multi, no momento {X:mult,C:white}X#3#{C:inactive} Multi)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Caçador de Trocado",
+ text = {
+ "No fim da rodada, ganhe {C:money}$#1#{} para",
+ "cada {C:money}$#2#{} que o seu {X:purple,C:white}Rival{}",
+ "gastou na loja correspondente da {C:attention}última Ante{}",
+ "{C:inactive}(Próximo pagamento: {C:money}$#3#{C:inactive})",
+ },
+ },
+ j_mp_taxes = {
+ name = "Impostos",
+ text = {
+ "Este Curinga ganha {C:mult}+#1#{} Multi para cada carta que seu",
+ "{X:purple,C:white}Rival{} {C:attention}vender{} desde a última {C:attention}Blind de Duelo{},",
+ "atualizando quando um {C:attention}Blind de Duelo{} for selecionado",
+ "{C:inactive}(Atualmente {C:mult}+#2#{C:inactive} Multi)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Imã",
+ text = {
+ "Ápos {C:attention}#1#{} rodadas,",
+ "venda esta carta para {C:attention}Duplicar{}",
+ "o {C:attention}Curinga{} do seu {X:purple,C:white}Rival{}",
+ "com o maior valor de venda",
+ "{C:inactive}(No momento {C:attention}#2#{C:inactive}/#3# rodadas)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "Comido após uma {C:attention}Blind de Duelo{},",
+ "dando {C:red}+#1#{} Descarte(s) para você",
+ "e {C:red}+#2#{} Descarte(s) para seu {X:purple,C:white}Rival{},",
+ "dura até o final da Ante",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Pacifista",
+ text = {
+ "{X:mult,C:white}X#1#{} Multi enquanto não estiver",
+ "em um {C:attention}Blind de Duelo{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Comprovante",
+ text = {
+ "Reativa a {C:attention}primeira{} e a {C:attention}segunda{}",
+ "carta jogada usada em pontuação",
+ "{C:attention}#1#{} vez adicional",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Asteroide",
+ text = {
+ "Elimina #1# nível da",
+ "{C:legendary,E:1}mão de pôquer{}",
+ "mais alta do seu",
+ "{X:purple,C:white}Rival{} no começo de uma {C:attention}Blind de Duelo{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Seu Rival",
+ text = {
+ "Enfrente outro jogador.",
+ "Quem tiver mais fichas vence",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Fantasma",
+ text = {
+ "{C:attention}Eterno{} and {C:dark_edition}Negativo{}",
+ "Criado e destruído pelo o seu {X:purple,C:white}Rival{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Carta de Vidro",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "Chance de {C:green}#2# em #3#{} de",
+ "destruir carta",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Carta de Vidro",
+ text = {
+ "{X:mult,C:white} X#1# {} Multi",
+ "Chance de {C:green}#2# em #3#{} de",
+ "destruir carta",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Baralho Coquetel",
+ text = {
+ "Copia todos os efeitos",
+ "de {C:attention}3{} outros baralhos",
+ "aleatoriamente",
+ },
+ },
+ b_mp_gradient = {
+ name = "Baralho Gradiente",
+ text = {
+ "Cartas também são consideradas",
+ "uma classe {C:attention}acima{} ou {C:attention}abaixo",
+ "para todos os efeitos de {C:attention}Curinga{}",
+ },
+ },
+ b_mp_indigo = {
+ name = "Baralho Índigo",
+ text = {
+ "Escolha {C:attention}1{} carta extra",
+ "em Pacotes de Reforço",
+ },
+ },
+ b_mp_orange = {
+ name = "Baralho Laranja",
+ text = {
+ "Comece a partida com um",
+ "{C:attention,T:p_mp_standard_giga}Pacote Padrão Giga{}, e",
+ "{C:attention}2{} cópias de {C:tarot,T:c_hanged_man}O Enforcado",
+ },
+ },
+ b_mp_oracle = {
+ name = "Baralho Oráculo",
+ text = {
+ "Comece a partida com {C:spectral,T:c_medium}Médium",
+ "e {C:attention,T:v_clearance_sale}Promoção",
+ "Dinheiro é limitado",
+ "a {C:money}$50",
+ },
+ },
+ b_mp_violet = {
+ name = "Baralho Violeta",
+ text = {
+ "{C:attention}+1{} Cupom na loja",
+ "Durante a aposta {C:attention}1{}, Cupons",
+ "são {C:attention}50%{} mais baratos",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Rival",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Seu único rival",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Pacote Padrão Giga",
+ text = {
+ "Escolha {C:attention}#1#{} de até",
+ "{C:attention}#2#{C:attention} cartas{} para",
+ "adicionar ao seu baralho",
+ "{C:attention}Não é possível ignorar{}",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Fantasma",
+ },
+ dictionary = {
+ b_singleplayer = "Um jogador",
+ b_join_lobby = "Entrar em uma Sala",
+ b_return_lobby = "Voltar para a Sala",
+ b_reconnect = "Reconectar",
+ b_create_lobby = "Criar Sala",
+ b_start_lobby = "Iniciar Sala",
+ b_ready = "Pronto",
+ b_unready = "Cancelar",
+ b_leave_lobby = "Sair da Sala",
+ b_mp_discord = "Servidor de Discord do Balatro Multiplayer",
+ b_start = "INICIAR",
+ b_wait_for_host_start = {
+ "ESPERANDO PELO",
+ "ANFITRIÃO COMEÇAR",
+ },
+ b_wait_for_players = {
+ "ESPERANDO POR",
+ "JOGADORES",
+ },
+ b_lobby_options = "OPÇÕES DA SALA",
+ b_copy_clipboard = "Copiar para a área de transferência",
+ b_view_code = "VER CÓDIGO",
+ b_copy_code = "COPIAR CÓDIGO",
+ b_leave = "SAIR",
+ b_opts_cb_money = "Dar $ a cada vida perdida",
+ b_opts_no_gold_on_loss = "Não ganhar recompensas de blind se perder uma rodada",
+ b_opts_death_on_loss = "Perder uma vida se perder uma rodada que não for de duelo",
+ b_opts_start_antes = "Antes Iniciais",
+ b_opts_diff_seeds = "Todos os jogadores têm códigos diferentes",
+ b_opts_lives = "Vidas",
+ b_opts_multiplayer_jokers = "Habilitar cartas multijogador",
+ b_opts_player_diff_deck = "Jogadores têm baralhos diferentes",
+ b_opts_normal_bosses = "Habilitar efeitos de blinds de chefe",
+ b_opts_timer = "Habilitar Temporizador",
+ b_reset = "Reiniciar",
+ b_set_custom_seed = "Personalizar Código",
+ b_mp_kofi_button = "Apoie-me no Ko-fi",
+ b_unstuck = "Desatravancar",
+ b_unstuck_blind = "Travado Fora do Duelo",
+ b_misprint_display = "Mostra a próxima carta do baralho",
+ b_players = "Jogadores",
+ b_lobby_info = "Informações da Sala",
+ b_continue_singleplayer = "Continue no Modo Um Jogador",
+ b_the_order_integration = "Habilitar integração com The Order",
+ b_view_nemesis_deck = "Ver Baralho do Rival",
+ b_toggle_jokers = "Alternar Curingas",
+ k_continue_singleplayer_tooltip = "Isto irá sobrescrever sua partida atual no modo Um Jogador",
+ k_enemy_score = "Pontuação do Inimigo",
+ k_enemy_hands = "Mãos Restantes do Inimigo: ",
+ k_coming_soon = "Em Breve!",
+ k_wait_enemy = "Esperando o inimigo terminar...",
+ k_wait_enemy_reach_this_blind = "Esperando o inimigo chegar neste blind...",
+ k_lives = "Vidas",
+ k_lost_life = "Perdeu uma vida",
+ k_total_lives_lost = " Total de Vidas Perdidas ($4 para cada)",
+ k_attrition_name = "Atrito",
+ k_enter_lobby_code = "Insira o código da sala",
+ k_paste = "Colar da área de transferência",
+ k_username = "Nome:",
+ k_enter_username = "Insira um nome",
+ k_join_discord = "Entre no ",
+ k_discord_msg = "Você poderá relatar erros e encontrar gente para jogar",
+ k_enter_to_save = "Aperte Enter para salvar",
+ k_in_lobby = "Na sala",
+ k_connected = "Conectado",
+ k_warn_service = "AVISO: Não foi possível encontrar o serviço Multiplayer",
+ k_set_name = "Defina o seu nome de usuário no menu principal! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "Jogadores tem mods diferentes ou mods com versões diferentes! Isso pode causar problemas!",
+ k_warning_unlock_profile = "O perfil em que você está jogando não está completamente desbloqueado. Se esta for uma partida ranqueada ou de torneio, por favor crie um novo perfil e aperte Desbloquear Tudo nas configurações do perfil",
+ k_warning_nemesis_unlock = 'Seu oponente está jogando em um perfil que não está totalmente desbloqueado. Instrua-o a criar um novo perfil e clicar em "Desbloquear tudo" nas configurações do perfil.',
+ k_warning_no_order = 'Um jogador tem a integração com "The Order" habilitada, enquanto o outro não. Isso fará com que os códigos sejam diferentes.',
+ k_warning_cheating1 = "Se você está vendo essa mensagem, seu oponente pode estar trapaceando.",
+ k_warning_cheating2 = "Se essa é uma partida ranqueada, por favor mande a mensagem '%s' e depois abra um ticket de suporte em #support",
+ k_message1 = "Espere, minha mãe fez salgadinho de pizza",
+ k_message2 = "Um segundo, preciso pegar meu assado de porco cozido lentamente",
+ k_message3 = "Um momento, minha mãe está me ligando",
+ k_message4 = "Já volto, meu gato está em chamas",
+ k_message5 = "Espere, eu acho que deixei meu fogão ligado",
+ k_message6 = "Espere, minha pedra de estimação acabou de fugir",
+ k_message7 = "Um segundo, minhas plantas estão pedindo água",
+ k_message8 = "Já volto, minhas meias estão tramando contra mim",
+ k_message9 = "Um segundo, meu WiFi está tendo uma crise existencial",
+ k_lobby_options = "Opções da Sala",
+ k_connect_player = "Jogadores Conectados:",
+ k_opts_only_host = "Apenas o criador da sala pode mudar estas opções",
+ k_opts_gm = "Modos de Jogo",
+ k_opts_pvp_start_round = "Duelo Começa no Ante",
+ k_opts_pvp_timer = "Temporizador",
+ k_opts_pvp_timer_increment = "Incremento do Temporizador",
+ k_opts_showdown_starting_antes = "Confronto Começa no Ante",
+ k_bl_life = "Vida",
+ k_bl_or = "ou",
+ k_bl_death = "Morte",
+ k_bl_mostchips = "Mais fichas ganha",
+ k_current_seed = "Código atual: ",
+ k_random = "Aleatório",
+ k_standard = "Padrão",
+ k_standard_description = "O conjunto de regras padrão, inclui cartas Multijogador e alterações ao jogo base para se adequar ao meta Multijogador.",
+ k_vanilla = "Vanilla",
+ k_vanilla_description = "O conjunto de regras original, sem cartas Multijogador, sem modificações ao conteúdo do jogo base.",
+ k_weekly = "Semanal",
+ k_weekly_description = "Um conjunto de regras especial que muda semanalmente ou quinzenalmente. Acho que vai ter que descobrir o que é! Atualmente: ",
+ k_tournament = "Torneio",
+ k_tournament_description = "O conjunto de regras do torneio, é o mesmo que o conjunto de regras normal, mas não se permite alterar as opções da sala.",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "Um conjunto de regras semanal concebido por @dr_monty_the_snek no servidor discord que foi adicionado ao mod permanentemente.",
+ k_attrition = "Atrito",
+ k_attrition_description = "Todo blind de chefe é um blind de duelo.",
+ k_showdown = "Confronto",
+ k_showdown_description = "Depois dos dois primeiros blinds, cada blind é um blind de duelo.",
+ k_survival = "Sobrevivência",
+ k_survival_description = "O jogador que ganhar a blind mais avançada vence.",
+ k_oops_ex = "Opa!",
+ k_asteroids = "Asteroides",
+ k_amount_short = "Qtd.",
+ k_filed_ex = "Declarado!",
+ k_timer = "Temporizador",
+ k_mods_list = "Lista de Mods",
+ k_enemy_jokers = "Curingas do Inimigo",
+ k_your_jokers = "Seus Curingas",
+ k_nemesis_deck = "Baralho do Rival",
+ k_your_deck = "Seu Baralho",
+ k_the_order_credit = "*Crédito ao @MathIsFun_",
+ k_the_order_integration_desc = "Isto irá corrigir a criação de cartas para não ser baseada em ante e usar uma única fila para cada tipo/raridade",
+ k_requires_restart = "*você precisará reiniciar o jogo para aplicar as mudanças",
+ k_bans = "Banimentos",
+ k_reworks = "Adições/Modificações",
+ ml_enemy_loc = {
+ "Localização do",
+ "oponente",
+ },
+ ml_mp_kofi_message = {
+ "Este mod e servidor de jogo é",
+ "desenvolvido e mantido por",
+ "uma pessoa, se",
+ "gostou considere",
+ },
+ ml_lobby_info = {
+ "Lobby",
+ "Info",
+ },
+ loc_ready = "Pronto para Duelar",
+ loc_selecting = "Escolhendo um Blind",
+ loc_shop = "Comprando",
+ loc_playing = "Jogando ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Arte: #1#",
+ },
+ a_mp_code = {
+ "Código: #1#",
+ },
+ a_mp_idea = {
+ "Ideia: #1#",
+ },
+ a_mp_skips_ahead = {
+ "#1# Blinds na frente",
+ },
+ a_mp_skips_behind = {
+ "#1# Blinds atrás",
+ },
+ a_mp_skips_tied = {
+ "Empatado",
+ },
+ k_banned_objs = "#1# Banidos",
+ k_no_banned_objs = "#1# Não Banidos",
+ k_reworked_objs = "#1# Adicionados/Modificados",
+ k_no_reworked_objs = "#1# Não Adicionados/Modificados",
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Comprovante{} foi {C:dark_edition}modificado",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Cartas de Vidro{} foram {C:dark_edition}modificadas",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Padrão",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Torneio",
+ c_mp_weekly = "Semanal",
+ c_mp_vanilla = "Vanilla",
+ c_mp_misprint_deck = "Baralho de Impressão Errada",
+ c_mp_legendaries = "Lendários",
+ c_mp_psychosis = "Psicose",
+ c_mp_scratch = "Do Zero",
+ c_mp_twin_towers = "Torres Gêmeas",
+ c_mp_in_the_red = "No Vermelho",
+ c_mp_paper_money = "Papel Moeda",
+ c_mp_high_hand = "Carta Alta",
+ c_mp_chore_list = "Lista de Tarefas",
+ c_mp_oops_all_jokers = "Opa! Tudo Curingas",
+ c_mp_divination = "Divinação",
+ c_mp_skip_off = "Amarelinha",
+ c_mp_lets_go_gambling = "Bora Apostar",
+ c_mp_speed = "Velocidade",
+ },
+ },
+}
diff --git a/localization/ru.lua b/localization/ru.lua
new file mode 100644
index 00000000..21e2304e
--- /dev/null
+++ b/localization/ru.lua
@@ -0,0 +1,319 @@
+-- Localization by @astryder75, @KilledByLava, @FaLNioNe, @sidmeierscivilizationv and @karta_wada on discord
+return {
+ descriptions = {
+ Joker = {
+ j_broken = {
+ name = "ОШИБКА",
+ text = {
+ "Эта карта либо сломана,",
+ "либо ещё не добавлена",
+ "в данной версии мода.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Защитный джокер",
+ text = {
+ "{C:chips}+#1#{} фишек",
+ "за каждую {C:red,E:1}жизнь{} меньше,",
+ "чем у вашего {X:purple,C:white}противника{}",
+ "{C:inactive}(сейчас: {C:chips}+#2#{C:inactive} фишек)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Проскок",
+ text = {
+ "{C:blue}+#1#{} рука и {C:red}+#2#{} сброс за каждый",
+ "пропущенный {C:attention}блайнд{} больше,",
+ "чем у вашего {X:purple,C:white}противника{}",
+ "{C:inactive}(сейчас: {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Вращайте барабан",
+ text = {
+ "Имеет шанс {C:green}#1# к #2#{}",
+ "дать {X:mult,C:white}X#3#{} множ. и {C:money}$#4#{}",
+ "и имеет шанс {C:green}#5# к #6#{}",
+ "дать {C:money}$#7#{} вашему {X:purple,C:white}противнику{}",
+ },
+ },
+ j_mp_speedrun = {
+ name = "СПИДРАН",
+ text = {
+ "Создаёт случ. {C:spectral}Спектральную{} карту",
+ "при достижении {C:attention}сражения{}",
+ "быстрее вашего {X:purple,C:white}противника{}",
+ "{C:inactive}(должно быть место)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Соединённый джокер",
+ text = {
+ "В {C:attention}сражении{} даёт {X:mult,C:white}X#1#{} множ.",
+ "за каждую оставшуюся {C:blue}руку{}",
+ "у вашего {X:purple,C:white}противника{}",
+ "{C:inactive}(максимум: {X:mult,C:white}X#2#{C:inactive} множ., сейчас: {X:mult,C:white}X#3#{C:inactive} множ.)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Крохобор",
+ text = {
+ "При входе в магазин даёт {C:money}$#1#{}",
+ "за каждые {C:money}$#2#{},",
+ "потраченных вашим {X:purple,C:white}противником{}",
+ "в прошлом магазине",
+ },
+ },
+ j_mp_taxes = {
+ name = "Налоги",
+ text = {
+ "Получает {C:mult}+#1#{} множ.",
+ "при продаже карты вашим {X:purple,C:white}противником{}",
+ "{C:inactive}(сейчас: {C:mult}+#2#{C:inactive} множ.)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Магнит",
+ text = {
+ "{C:attention}Копирует{} джокера",
+ "с наибольшей суммой продажи",
+ "у вашего {X:purple,C:white}противника{} при продаже",
+ "спустя #1#{} раунд(-а)",
+ "{C:inactive}(сейчас: {C:attention}#2#{C:inactive}/#3# раундов)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Пицца",
+ text = {
+ "{C:red}+#1#{} сбросов каждому игроку",
+ "{C:red}-#2#{} сброс при выборе блайнда игроками",
+ "Съедается при пропуске блайнда {X:purple,C:white}противником{}",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Пацифист",
+ text = {
+ "{X:mult,C:white}X#1#{} множ.",
+ "при нахождении не в {C:attention}сражении{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Недонадорванный бюллетень",
+ text = {
+ "Эффекты {C:attention}первой{} и {C:attention}второй{}",
+ "сыгранных карт срабатывают ещё {C:attention}#1#{} раз",
+ "при подсчёте",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Астероид",
+ text = {
+ "Уменьшает уровень",
+ "{C:legendary,E:1}покерной руки{} с наивысшим уровнем",
+ "вашего {X:purple,C:white}противника{} на #1#",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Ваш противник",
+ text = {
+ "Сразитесь с вашим противником,",
+ "игрок с наибольшим счётом побеждает",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Фантомный",
+ text = {
+ "{C:attention}Вечный{} и {C:dark_edition}Негативный{}",
+ "Создан и уничтожен вашим {X:purple,C:white}противником{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Стеклянная карта",
+ text = {
+ "{X:mult,C:white} X#1# {} множ.",
+ "Имеет шанс {C:green}#2# к #3#{},",
+ "что будет уничтожена",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Стеклянная карта",
+ text = {
+ "{X:mult,C:white} X#1# {} множ.",
+ "Имеет шанс {C:green}#2# к #3#{},",
+ "что будет уничтожена",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Соперник",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Ваш единственный противник",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Фантомный",
+ },
+ dictionary = {
+ b_singleplayer = "Одиночная Игра",
+ b_join_lobby = "Подключиться к лобби",
+ b_return_lobby = "Вернуться в лобби",
+ b_reconnect = "Переподключиться",
+ b_create_lobby = "Создать лобби",
+ b_start_lobby = "Начать игру",
+ b_ready = "Приготовиться",
+ b_unready = "Отменить",
+ b_leave_lobby = "Выйти из лобби",
+ b_mp_discord = "Дискорд-серверу Balatro Multiplayer",
+ b_start = "НАЧАТЬ",
+ b_wait_for_host_start = {
+ "ЖДЁМ",
+ "НАЧАЛА ИГРЫ",
+ },
+ b_wait_for_players = {
+ "ЖДЁМ",
+ "ИГРОКОВ",
+ },
+ b_lobby_options = "ПАРАМЕТРЫ ЛОББИ",
+ b_copy_clipboard = "Скопировать",
+ b_view_code = "УВИДЕТЬ КОД",
+ b_copy_code = "КОПИРОВАТЬ КОД",
+ b_leave = "ВЫЙТИ",
+ b_opts_cb_money = "Давать доп. золото при потере жизни",
+ b_opts_no_gold_on_loss = "Не давать золото при поражении в раунде",
+ b_opts_death_on_loss = "Терять жизнь при поражении не в сражении",
+ b_opts_start_antes = "Количество анте до сражений",
+ b_opts_diff_seeds = "У игроков разные сиды",
+ b_opts_lives = "Кол-во жизней",
+ b_opts_multiplayer_jokers = "Включить джокеров из мода",
+ b_opts_player_diff_deck = "У игроков разные колоды",
+ b_reset = "Сбросить",
+ b_set_custom_seed = "Использовать cвой сид",
+ b_mp_kofi_button = "поддержать меня на Ko-fi",
+ b_unstuck = "Выбраться",
+ b_unstuck_arcana = "Выбраться из набора",
+ b_unstuck_blind = "Выбраться из сражения",
+ b_misprint_display = "Показывать следующую карту в колоде",
+ b_players = "Игроки",
+ b_continue_singleplayer = "Продолжить в одиночной игре",
+ k_enemy_score = "Счёт противника",
+ k_enemy_hands = "Кол-во оставшихся рук у противника: ",
+ k_coming_soon = "Скоро!",
+ k_wait_enemy = "Ждём, пока противник завершит...",
+ k_lives = "Жизни",
+ k_lost_life = "Потеряна жизнь",
+ k_total_lives_lost = " жизней потеряно (по $4 каждая)",
+ k_attrition_name = "Истощение",
+ k_enter_lobby_code = "Введите код лобби",
+ k_paste = "Вставить с буфера обмена",
+ k_username = "Имя:",
+ k_enter_username = "Введите имя",
+ k_join_discord = "Присоединяйтесь к ",
+ k_discord_msg = "Там вы можете сообщать об ошибках и находить людей для игры",
+ k_enter_to_save = "Нажмите Enter, чтобы сохранить",
+ k_in_lobby = "В лобби",
+ k_connected = "Подключено к сервисам",
+ k_warn_service = "ПРЕДУПРЕЖДЕНИЕ: Не удалось найти сервисы",
+ k_set_name = "Введите своё имя в главном меню! (Mods > Multiplayer > Config)",
+ k_mod_hash_warning = "У игроков разные моды, либо разные версии модов! Может привести к ошибкам!",
+ k_lobby_options = "Параметры лобби",
+ k_connect_player = "Игроки в лобби:",
+ k_opts_only_host = "Только ведущий может менять эти настройки",
+ k_opts_gm = "Настройки игры",
+ k_bl_life = "Жизнь",
+ k_bl_or = "или",
+ k_bl_death = "Смерть",
+ k_current_seed = "Текущий сид: ",
+ k_random = "Случайный",
+ k_standard = "Стандартный режим",
+ k_standard_description = "Стандартные правила, в которые включены уникальные карты мода и изменения базовой игры.",
+ k_vanilla = "Ванилла",
+ k_vanilla_description = "Ванильные правила, без карт мода, без изменений базовой игры.",
+ k_weekly = "Недельный режим",
+ k_weekly_description = "Специальный режим, меняется раз в одну или две недели. Придётся понять самим, каковы текущие правила! Сейчас: ",
+ k_tournament = "Турнирный режим",
+ k_tournament_description = "Правила турниров, те же правила, что и в стандартном режиме, но параметры лобби менять запрещено.",
+ k_badlatro = "Плохлатро",
+ k_badlatro_description = "Недельный режим, разработанный пользователем дискорд-сервера, @dr_monty_the_snek, и добавленный на постоянной основе.",
+ k_oops_ex = "Упс!",
+ k_timer = "Таймер",
+ k_mods_list = "Список модов",
+ k_enemy_jokers = "Джокеры противника",
+ ml_enemy_loc = {
+ "Статус",
+ "противника",
+ },
+ ml_mp_kofi_message = {
+ "Данный мод и сервер для него",
+ "разработан и обслуживается",
+ "одним человеком, при",
+ "желании вы можете",
+ },
+ loc_ready = "Готов к сражению",
+ loc_selecting = "Выбирает блайнд",
+ loc_shop = "В магазине",
+ loc_playing = "Против ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Дизайн: #1#",
+ },
+ a_mp_code = {
+ "Код: #1#",
+ },
+ a_mp_idea = {
+ "Идея: #1#",
+ },
+ a_mp_skips_ahead = {
+ "Впереди на #1# пропусков",
+ },
+ a_mp_skips_behind = {
+ "Позади на #1# пропусков",
+ },
+ a_mp_skips_tied = {
+ "Равны",
+ },
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Ещё разок{} {C:dark_edition}переработан",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Стеклянные карты{} {C:dark_edition}переработаны",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Стандартный режим",
+ c_mp_badlatro = "Плохлатро",
+ c_mp_tournament = "Турнирный режим",
+ c_mp_weekly = "Недельный режим",
+ c_mp_vanilla = "Ванилла",
+ c_mp_misprint_deck = "Колода с опечаткой",
+ c_mp_legendaries = "Легендарки",
+ c_mp_psychosis = "Психоз",
+ c_mp_scratch = "С чистого листа",
+ c_mp_twin_towers = "Башни-близнецы",
+ c_mp_in_the_red = "In the Red",
+ c_mp_paper_money = "Бумажные деньги",
+ c_mp_chore_list = "Список дел",
+ c_mp_oops_all_jokers = "Упс! Все джокеры",
+ c_mp_divination = "Гадание",
+ c_mp_skip_off = "Проскок",
+ c_mp_lets_go_gambling = "Вращайте барабан",
+ c_mp_high_hand = "Старшая рука",
+ c_mp_speed = "Скорость",
+ },
+ },
+}
diff --git a/localization/stylua.toml b/localization/stylua.toml
new file mode 100644
index 00000000..bdfbf7a0
--- /dev/null
+++ b/localization/stylua.toml
@@ -0,0 +1,8 @@
+indent_type = "Tabs"
+
+# longer lines for localization files
+column_width = 0
+
+quote_style = "AutoPreferDouble"
+
+line_endings = "Unix"
diff --git a/localization/vi.lua b/localization/vi.lua
new file mode 100644
index 00000000..22a13e7e
--- /dev/null
+++ b/localization/vi.lua
@@ -0,0 +1,796 @@
+-- Localization by @theambushingbush
+-- Bản dịch của HuyTheKiller, yêu cầu cài thêm VietnameseBalatro
+-- https://github.com/HuyTheKiller/VietnameseBalatro
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_sandbox_rare = {
+ name = "Nhãn Con Bạc",
+ text = {
+ "Xác suất {C:green}#1# trên #2#{}",
+ "để shop có một {C:red}Joker Hiếm",
+ "miễn phí",
+ },
+ },
+ },
+ Joker = {
+ j_broken = {
+ name = "HỎNG",
+ text = {
+ "Lá này hoặc là bị hỏng, hoặc là",
+ "chưa được thêm vào ở phiên bản",
+ "hiện tại của một mod bạn đang sử dụng.",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "Joker Phòng Ngự",
+ text = {
+ "{C:chips}+#1#{} Chip cho mỗi {C:red,E:1}mạng{}",
+ "ít hơn {X:purple,C:white}Đối_Thủ{}",
+ "{C:inactive}(Hiện tại là {C:chips}+#2#{C:inactive} Chip)",
+ "{C:inactive}(Phụ thuộc mức Cược)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "Nhảy Lò Cò",
+ text = {
+ "{C:blue}+#1#{} Tay bài và {C:red}+#2#{} Lượt bỏ bài",
+ "cho mỗi {C:attention}Blind{} đã bỏ qua",
+ "nhiều hơn {X:purple,C:white}Đối_Thủ{}",
+ "{C:inactive}(Hiện tại là {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive})",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "Cờ Bạc Là Bác Thằng Bần",
+ text = {
+ "Xác suất {C:green}#1# trên #2#{} được",
+ "{X:mult,C:white}X#3#{} Nhân và {C:money}$#4#{}",
+ "Xác suất {C:green}#5# trên #6#{} để cho",
+ "{X:purple,C:white}Đối_Thủ{} {C:money}$#7#{} ở {C:attention}Blind Đối Đầu",
+ },
+ },
+ -- j_mp_hanging_bad = {
+ -- name = "Bêu Riếu",
+ -- text = {
+ -- "Ở {C:attention}Blind{} {X:purple,C:white}Đối Đầu{}",
+ -- "lá đã chơi {C:attention}đầu tiên{} tính điểm",
+ -- "bị {C:attention}vô hiệu{} cho cả hai người chơi",
+ -- },
+ -- },
+ j_mp_speedrun = {
+ name = "NHANH GỌN LẸ",
+ text = {
+ "Nếu bạn đến {C:attention}Blind Đối Đầu",
+ "trước {X:purple,C:white}Đối_Thủ{}, tạo ra",
+ "một lá {C:spectral}Siêu Linh{} ngẫu nhiên",
+ "{C:inactive}(Phải có ô trống)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "Joker Kết Hợp",
+ text = {
+ "Khi ở {C:attention}Blind Đối Đầu{}, thêm",
+ "{X:mult,C:white}X#1#{} Nhân cho mỗi {C:blue}Tay bài{}",
+ "còn lại của {X:purple,C:white}Đối_Thủ{}",
+ "{C:inactive}(Tối đa {X:mult,C:white}X#2#{C:inactive} Nhân, Hiện tại là {X:mult,C:white}X#3#{C:inactive} Nhân)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "Kẻ Trộm Xu",
+ text = {
+ "Ở đầu shop, nhận {C:money}$#1#{}",
+ "cho mỗi {C:money}$#2#{} mà {X:purple,C:white}Đối_Thủ{} đã tiêu",
+ "ở cùng shop trong {C:attention}ante trước đó{}",
+ },
+ },
+ j_mp_taxes = {
+ name = "Thuế",
+ text = {
+ "Rhêm {C:mult}+#1#{} Nhân cho mỗi lá bài",
+ "{X:purple,C:white}Đối_Thủ{} đã bán kể từ {C:attention}Blind Đối Đầu{}",
+ "cập nhật khi {C:attention}Blind Đối Đầu{} được chọn",
+ "{C:inactive}(Hiện tại là {C:mult}+#2#{C:inactive} Nhân)",
+ },
+ },
+ j_mp_magnet = {
+ name = "Nam Châm",
+ text = {
+ "Sau {C:attention}#1#{} ván, bán",
+ "lá này để {C:attention}Sao Chép{}",
+ "{C:attention}Joker{} có giá bán",
+ "cao nhất của {X:purple,C:white}Đối_Thủ{}",
+ "{C:inactive}(Hiện tại là {C:attention}#2#{C:inactive}/#3# ván)",
+ },
+ },
+ j_mp_pizza = {
+ name = "Pizza",
+ text = {
+ "Ở cuối {C:attention}Blind Đối Đầu{} tiếp theo,",
+ "tiêu thụ Joker này và thêm",
+ "{C:red}+#1#{} lượt bỏ bài cho bạn và",
+ "{C:red}+#2#{} lượt bỏ bài cho {X:purple,C:white}Đối_Thủ{} trong ante này",
+ },
+ },
+ j_mp_pacifist = {
+ name = "Yêu Hoà Bình",
+ text = {
+ "{X:mult,C:white}X#1#{} Nhân khi",
+ "không ở {C:attention}Blind Đối Đầu{}",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "Phiếu Đục Lỗ",
+ text = {
+ "Tái kích lá ghi điểm",
+ "{C:attention}đầu tiên{} và {C:attention}thứ hai{}",
+ "thêm {C:attention}#1#{} lần",
+ },
+ },
+ -- j_mp_cloud_9 = {
+ -- name = "Chín Tầng Mây",
+ -- text = {
+ -- "Nhận {C:money}$1{} cho mỗi lá {C:attention}9{} trong bộ bài",
+ -- "(tối đa {C:money}$4{}) và thêm {C:money}$#1#{} cho mỗi",
+ -- "lá {C:attention}9{} thêm ở cuối ván",
+ -- "{C:inactive}(Hiện tại là {C:money}$#2#{}{C:inactive})",
+ -- },
+ -- },
+ j_mp_bloodstone = {
+ name = "Đá Đốm Đỏ",
+ text = {
+ "Xác suất {C:green}#1# trên #2#{} để",
+ "lá bài đã chơi có",
+ "chất {C:hearts}Cơ{} ghi thêm",
+ "{X:mult,C:white} X#3# {} Nhân khi ghi điểm",
+ },
+ },
+ j_mp_magnet_sandbox = {
+ name = "Nam Châm",
+ text = {
+ "Sau {C:attention}#1#{} ván, bán",
+ "lá này để {C:attention}Sao Chép {C:attention}Joker{} có",
+ "giá bán cao nhất của {X:purple,C:white}Đối_Thủ{}",
+ "nghịch đảo từ sau {C:attention}#3#{} ván",
+ "TRỞ THÀNH CỤC SẮT VÔ DỤNG!!!!",
+ "{C:inactive}(Hiện tại là {C:attention}#2#{C:inactive}/#1# ván)",
+ },
+ },
+ j_mp_cloud_9_sandbox = {
+ name = "Chín Tầng Mây",
+ text = {
+ "NÔNG DÂN ĐƠN CANH SỐ HỌC",
+ "biến BỘ BÀI ĐA DẠNG thành",
+ "BÃI TRỒNG CÂY CHÍN QUẢ!!!!",
+ "{C:inactive}(xác suát {C:green}#1# trên #2#{} {C:inactive}, hiện tại là {C:money}$#3#{}{C:inactive})",
+ },
+ },
+ j_mp_lucky_cat_sandbox = {
+ name = "Mèo Thần Tài",
+ text = {
+ "BỘ CHUYỂN ĐỔI VẬN MAY THÀNH MONG MANH",
+ "mèo thần tài trở thành MÈO THUỶ TINH",
+ "với SỨC MẠNH CẤP SỐ NHÂN!!!!",
+ "{C:inactive}(Hiện tại là {X:mult,C:white} X#2# {C:inactive} Nhân)",
+ },
+ },
+ j_mp_constellation_sandbox = {
+ name = "Chòm Sao",
+ text = {
+ "rối loạn lo âu bảo trì hành tinh",
+ "PHẢI CHO TAMAGOCHI ĂN",
+ "nếu không nó HẸO LUÔN ĐÓ!!!!",
+ "{C:inactive}(Hiện tại là {X:mult,C:white} X#1# {C:inactive} Nhân)",
+ },
+ },
+ j_mp_bloodstone_sandbox = {
+ name = "Đá Đốm Đỏ",
+ text = {
+ "HỘI CHỨNG THOÁI LỤI BẢN VÁ",
+ "trả về NỖI ÁM ẢNH NGÀY RA MẮT",
+ "đế lấy SỨC MẠNH SIÊU CẤP HOÀI NIỆM!!!!",
+ "{C:inactive}(Xác suất {C:green}#1# trên #2#{}{C:inactive})",
+ },
+ },
+ j_mp_juggler_sandbox = {
+ name = "Nười Tung Hứng",
+ text = {
+ "CẦU TOÀN LÁ TRÊN TAY",
+ "cần phẩi cho MẤY LÁ BÀI",
+ "LUÔN LUÔN LƠ LỬNG TRÊN KHÔNG!!!!",
+ "{C:inactive}(Hiện tại là {C:attention}+#1#{C:inactive} lá giữ trong tay)",
+ },
+ },
+ j_mp_mail_sandbox = {
+ name = "Tiền Hoàn Thư",
+ text = {
+ "PHIẾU HOÀN TIỀN CỨNG BẬC",
+ "ai đó lỡ viết {C:attention}#2#{} bằng",
+ "BÚT DẦU CMNR!!!!",
+ },
+ },
+ j_mp_hit_the_road_sandbox = {
+ name = "Lên Đường",
+ text = {
+ "ĐƯỜNG CAO TỐC VỨT BỒI",
+ "ném văng {C:attention}Bồi{}",
+ "LÊN NHỰA ĐƯỜNG MÃI MÃI!!!!",
+ "{C:inactive}(Hiện tại là {X:mult,C:white} X#2# {C:inactive} Nhân)",
+ },
+ },
+ j_mp_misprint_sandbox = {
+ name = "Lỗi In",
+ text = {
+ "NGƯỜI CHƠI XỔ SỐ SCHRODINGER",
+ "vé VỪA THẮNG VỪA THUA",
+ "cho đến khi kiểm tra!!!!",
+ "{C:inactive}({V:1}#1#{C:inactive} Nhân)",
+ },
+ },
+ j_mp_castle_sandbox = {
+ name = "Lâu Đài",
+ text = {
+ "EM ƠI, LÂU ĐÀI TÌNH ÁI ĐÓ",
+ "CHẮC KHÔNG CÓ {V:1}#1#{} TRÊN TRẦN GIAN",
+ "ANH ĐƯA EM VÀO BẰNG TIẾNG HÁT",
+ "CHẮP ĐÔI CÁNH NHUNG THIÊN THẦN",
+ "{C:inactive}(Hiện tại là {C:chips}+#2#{C:inactive} Chip)",
+ },
+ },
+ j_mp_runner_sandbox = {
+ name = "Chay Việt Dã",
+ text = {
+ "KHỨA UY QUYỀN CHUYÊN SẢNH",
+ "nghĩ rẳng MỌI TAY POKER",
+ "khác đầu HẠ ĐẲNG!!!!",
+ "{C:inactive}(Hiện tại là {C:chips}+#1#{C:inactive})",
+ },
+ },
+ j_mp_order_sandbox = {
+ name = "Chuẩn Dãy",
+ text = {
+ "NÔNG DÂN TRỖI DẬY lãnh đạo",
+ "những CON SỐ để lật đổ",
+ "BỌN LÁ MẶT ÁP BỨC!!!!",
+ },
+ },
+ j_mp_photograph_sandbox = {
+ name = "Bức Ảnh",
+ text = {
+ "NHIẾP ẢNH GIA PHÁT MỘT chụp được",
+ "MỘT BỨC HOÀN HẢO MỖI TAY BÀI!!!!",
+ },
+ },
+ j_mp_ride_the_bus_sandbox = {
+ name = "Đi Xe Buýt",
+ text = {
+ "CHƯƠNG TRÌNH LÁ MẶT ĐIỀM ĐẠM",
+ "MỘT LÁ MẶT tức là",
+ "CÚT KHỎI XE BUÝT!!!!",
+ "{C:inactive}(Hiện tại là {C:mult}+#1#{C:inactive} Nhân)",
+ },
+ },
+ j_mp_loyalty_card_sandbox = {
+ name = "Thẻ Hội Viên",
+ text = {
+ "CHƯƠNG TRÌNH TAY BÀI THÂN THIẾT",
+ "hãy phản bội {C:attention}#1#{}",
+ "và chống ĐẶT LẠI!!!!",
+ "{C:inactive}(Trung thành trong {C:attention}#2#/#3#{} {C:inactive}tay bài)",
+ },
+ },
+ j_mp_faceless_sandbox = {
+ name = "Joker Vô Diện",
+ text = {
+ "LÁ MẶT HẦU RƯỢU CAO CẤP",
+ "chế ra BA HƯƠNG VỊ BAY",
+ "TÍT LÊN TRỜI cho TRẢI",
+ "NGHIỆM VỨT BỎ CAO CẤP!!!!",
+ },
+ },
+ j_mp_square_sandbox = {
+ name = "Joker Vuông",
+ text = {
+ "BỐN LÁ CẦU TOÀN",
+ "thờ phụng HÌNH HỌC LINH THIÊNG",
+ "CỦA XẾP HÌNH VUÔNG SIÊU CÂN BẰNG!!!!",
+ "{C:inactive}(Hiện tại là {C:chips}+#1#{C:inactive} Chip)",
+ },
+ },
+ j_mp_throwback_sandbox = {
+ name = "Hồi Tổ",
+ text = {
+ "DỊCH VỤ TƯ VẤN NHÁT CẤY CHUYÊN NGHIỆP",
+ "Tôi được TRẢ để chim cút khỏi mọi thứ",
+ "VÀ CÀNG CAO CHẠY XA BAY TÔI CÀNG MẠNH MẼ!!!!",
+ "{C:inactive}(Hiện tại là {X:mult,C:white} X#1# {C:inactive} Nhân)",
+ },
+ },
+ j_mp_vampire_sandbox = {
+ name = "Ma Cà Rồng",
+ text = {
+ "nhà kinh tế cà rồng TẠO RA",
+ "TIỀN TỆ CHUẨN THẠCH",
+ "TỪ SINH LỰC!!!!",
+ "{C:inactive}(Hiện tại là {X:mult,C:white} X#2# {C:inactive} Nhân)",
+ },
+ },
+ j_mp_baseball_sandbox = {
+ name = "Thẻ Bóng Chày",
+ text = {
+ 'THẺ THỂ THAO "GÂY TRANH CÃI"',
+ "đóng giả làm THAY ĐỔI CÂN BẰNG!!!!",
+ },
+ },
+ j_mp_steel_joker_sandbox = {
+ name = "Joker Thép",
+ text = {
+ "CHUYÊN GIA THÉP THỪA THÃI",
+ "mỗi HỢP KIM ĐÃ CHƠI đều",
+ "ĐƯỢC KIỂM LẠI!!!!",
+ },
+ },
+ j_mp_satellite_sandbox = {
+ name = "Vệ Tinh",
+ text = {
+ "lo âu vệ tinh suy tàn quỹ đạo",
+ "HẠ TẦNG TỪ TỪ SỤP ĐỔ KHI KHÔNG CÓ",
+ "NÂNG CẤP HÀNH TINH THƯỜNG XUYÊN!!!!",
+ "{C:inactive}(Hiện tại là {C:money}$#1#{C:inactive})",
+ },
+ },
+ j_mp_error_sandbox = {
+ name = "????",
+ text = {
+ -- "PREVIEW DISABLED",
+ "{B:purple,C:white,s:0.85}Có gì đó{} {B:purple,C:white,s:0.85}sai sai ở đây",
+ -- "PREVIEW DISABLED",
+ -- "PREVIEW DISABLED",
+ -- "{C:inactive}(CURRENTLY {C:money}$7{C:inactive})",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "Thiên Thạch",
+ text = {
+ "Giảm #1# level từ",
+ "{C:legendary,E:1}tay poker{}",
+ "có level cao nhất",
+ "của {X:purple,C:white}Đối_Thủ{}",
+ "ở đầu {C:attention}Blind Đối Đầu{}",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "Đối Đầu",
+ text = {
+ "Gặp một người chơi khác,",
+ "nhiều điểm nhất sẽ chiến thắng",
+ },
+ },
+ -- bl_precision = {
+ -- name = "Kẻ Chuẩn Xác",
+ -- text = {
+ -- "Gặp một người chơi khác,",
+ -- "gần mục tiêu nhất sẽ chiến thắng",
+ -- },
+ -- },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "Bóng Ma",
+ text = {
+ "{C:attention}Vĩnh Hằng{} và {C:dark_edition}Âm Bản{}",
+ "Quyền tạo ra và phá huỷ là của {X:purple,C:white}Đối_Thủ{}",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "Lá Kính",
+ text = {
+ "{X:mult,C:white} X#1# {} Nhân",
+ "Xác suất {C:green}#2# trên #3#{}",
+ "để phá huỷ lá bài",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "Lá Kính",
+ text = {
+ "{X:mult,C:white} X#1# {} Nhân",
+ "Xác suất {C:green}#2# trên #3#{}",
+ "để phá huỷ lá bài",
+ },
+ },
+ },
+ Back = {
+ b_mp_cocktail = {
+ name = "Bộ Bài Cocktail",
+ text = {
+ "Sao chép mọi khả năng",
+ "của {C:attention}3{} bộ bài",
+ "ngẫu nhiên khác",
+ },
+ },
+ b_mp_gradient = {
+ name = "Bộ Bài Màu Dốc",
+ text = {
+ "Lá bài thường đươc xem như",
+ "một bậc {C:attention}cao hơn{} hoặc {C:attention}thấp",
+ "{C:attention}hơn{} cho mọi hiệu ứng {C:attention}Joker",
+ },
+ },
+ b_mp_indigo = {
+ name = "Bộ Bài Chàm",
+ text = {
+ "Chọn thêm {C:attention}1{} lá bài",
+ "từ mọi Gói Bài",
+ },
+ },
+ b_mp_orange = {
+ name = "Bộ Bài Cam",
+ text = {
+ "Bằt đầu trận với",
+ "{C:attention,T:p_mp_standard_giga}Gói Tiêu Chuẩn Cực Đại{}, và",
+ "{C:attention}2{} bản sao của {C:tarot,T:c_hanged_man}Kẻ Treo Ngược",
+ },
+ },
+ b_mp_oracle = {
+ name = "Bộ Bài Sấm Truyền",
+ 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",
+ },
+ },
+ b_mp_violet = {
+ name = "Bộ Bài Tía",
+ text = {
+ "{C:attention}+1{} Ô Phiếu trong shop",
+ "Ở Ante {C:attention}1{}, Phiếu",
+ "được giảm giá {C:attention}50%{}",
+ },
+ },
+ b_mp_heidelberg = {
+ name = "Bộ Bài Heidelberg",
+ text = {
+ "Tạo ra bản sao {C:dark_edition}Âm Bản{} của",
+ "{C:attention}1{} lá {C:attention}tiêu thụ{} ngẫu",
+ "nhiên thuộc sở hữu của bạn",
+ "ở cuối {C:attention}shop",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "Đối Thủ",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "Kẻ địch duy nhất của bạn",
+ },
+ },
+ p_mp_standard_giga = {
+ name = "Gói Tiêu Chuẩn Cực Đại",
+ text = {
+ "Chọn {C:attention}#1#{} trong tối đa",
+ "{C:attention}#2#{C:attention} lá bài {C:attention}Thường{}",
+ "để thêm vào bộ bài",
+ "{C:attention}Không thể bỏ qua{}",
+ },
+ },
+ },
+ Stake = {
+ stake_mp_planet = {
+ name = "Cược Hành Tinh",
+ text = {
+ "Người anh trai ngầu lòi của {C:attention}Cược Cam{}",
+ "này sẽ trả lại cho bạn {C:red}lượt",
+ "{C:red}bỏ bài quý giá{} bỏi vì",
+ "họ không tàn nhẫn đến thế đâu",
+ },
+ },
+ stake_mp_spectral = {
+ name = "Cược Siêu Linh",
+ text = {
+ "Áp dụng {C:planet}Cược Hành Tinh{}, thêm:",
+ "Joker {C:money}Cho thuê{} xuất hiện trong shop",
+ "Điểm yêu cầu tăng",
+ "nhanh hơn sau mỗi {C:attention}Ante",
+ },
+ },
+ stake_mp_spectralplus = {
+ name = "Cược Siêu Linh+",
+ text = {
+ "Áp dụng {C:planet}Cược Siêu Linh{}, thêm:",
+ "Điểm yêu cầu tăng",
+ "nhanh hơn sau mỗi {C:attention}Ante",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "Bóng Ma",
+ },
+ dictionary = {
+ b_singleplayer = "Chơi Đơn",
+ b_join_lobby = "Vào Phòng",
+ b_join_lobby_clipboard = "Vào Từ Bộ Nhớ Đệm",
+ b_return_lobby = "Quay lại Phòng",
+ b_reconnect = "Kết nối lại",
+ b_create_lobby = "Tạo Phòng",
+ b_start_lobby = "Mở Phòng",
+ b_ready = "Sẵn sàng",
+ b_unready = "Huỷ sẵn sàng",
+ b_leave_lobby = "Rời Phòng",
+ b_mp_discord = "Máy Chủ Discord Balatro Multiplayer",
+ b_start = "BẮT ĐẦU",
+ b_wait_for_host_start = {
+ "ĐANG CHỜ CHỦ",
+ "PHÒNG BẮT ĐẦU",
+ },
+ b_wait_for_players = {
+ "ĐANG CHỜ",
+ "NGƯỜI CHƠI",
+ },
+ b_wait_for_guest_ready = {
+ "ĐANG CHỜ NGƯỜI CHƠI",
+ "KHÁC SẴN SÀNG",
+ },
+ b_lobby_options = "TUỲ CHỈNH PHÒNG",
+ b_copy_clipboard = "Sao chép vào bộ nhớ đệm",
+ b_view_code = "XEM MÃ",
+ b_copy_code = "SAO CHÉP MÃ",
+ b_leave = "THOÁT",
+ b_opts_cb_money = "Bù $ cho người chơi mất mạng",
+ b_opts_no_gold_on_loss = "Không nhận thưởng blind khi thua ván",
+ b_opts_death_on_loss = "Mất mạng khi thua blind thường",
+ b_opts_start_antes = "Ante khởi đầu",
+ b_opts_diff_seeds = "Người chơi có Giống khác nhau",
+ 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_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",
+ b_opts_the_order = "Bật The Order",
+ b_opts_legacy_smallworld = "Cơ chế Small World Cổ Điểm",
+ b_reset = "Đặt lại",
+ b_set_custom_seed = "Đặt Giống Tuỳ Chỉnh",
+ b_mp_kofi_button = "Ủng hộ tôi trên Ko-fi",
+ b_unstuck = "Bỏ kẹt",
+ b_unstuck_blind = "Kẹt bên ngoài Đối Đầu",
+ b_misprint_display = "Hiển thị lá tiếp theo trong bộ bài",
+ b_players = "Người chơi",
+ b_lobby_info = "T.Tin Phòng",
+ b_continue_singleplayer = "Tiếp tục trong Chơi Đơn",
+ b_the_order_integration = "Bật Tích Hợp The Order",
+ b_preview_integration = "Bật Xem Trước Điểm",
+ b_view_nemesis_deck = "Xem Bộ Bài",
+ b_toggle_jokers = "Bật Joker",
+ b_skip_tutorial = "Bỏ Qua Hướng Dẫn",
+ k_yes = "Có",
+ k_no = "Không",
+ k_are_you_sure = "Bạn chắc chứ?",
+ 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_values_are_modifiable = "* Giá trị tuỳ biến",
+ k_rulesets = "Luật Lệ",
+ k_gamemodes = "Chế Độ Chơi",
+ k_competitive = "Tranh Đấu",
+ k_other = "Khác",
+ k_battle = "Cuộc Chiến",
+ k_challenge = "Thử Thách",
+ k_info = "Thông Tin",
+ k_continue_singleplayer_tooltip = "Hành động này sẽ ghi đè lên trận chơi đơn hiện tại",
+ k_enemy_score = "Điểm Đối Thủ",
+ k_enemy_hands = "Tay bài còn lại: ",
+ k_coming_soon = "Sắp ra mắt!",
+ 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_lost_life = "Mất 1 mạng",
+ k_total_lives_lost = " Mạng đã mất ($4 mỗi mạng)",
+ k_attrition_name = "Hao Mòn",
+ k_enter_lobby_code = "Nhập Mã Phòng",
+ k_paste = "Dán Từ Bộ Nhớ Đệm",
+ k_username = "Tên:",
+ k_enter_username = "Nhập tên",
+ k_customize_preview = "Tuỳ Chỉnh Chữ Xem Trước:",
+ k_join_discord = "Tham gia ",
+ k_discord_msg = "Bạn có thể báo cáo lỗi cũng như tìm người chơi cùng ở đó",
+ k_enter_to_save = "Nhấn enter để lưu",
+ k_in_lobby = "Trong phòng",
+ k_connected = "Đã kết nối tới Máy chủ",
+ k_warn_service = "CHÚ Ý: Không thể tìm thấy Máy chủ Multiplayer",
+ 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_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.",
+ k_warning_cheating1 = "Nếu bạn thấy cái này, đối thủ có thể đang gian lận.",
+ k_warning_cheating2 = "Nếu đây là trận xếp hạng, hãy gửi tin nhắn '%s' rồi mở một vé hỗ trợ trong #support",
+ k_warning_banned_mods = "Một hoặc nhiều người chơi đã cài mod bị cấm. Những mod này không được phép dùng trong trận xếp hạng.",
+ k_message1 = "Hold on, my mom made pizza pops", --These are suuposed to be "fake excuses" to stall\
+ k_message2 = "One sec, i gotta grab my slow cooker pork roast", --opponents while you're working your way out.\
+ k_message3 = "One moment, getting a call from my mom", --The majority of players use English anyway\
+ k_message4 = "Brb, my cat is on fire", --so I'm not touching any of this.
+ k_message5 = "Wait, I think I left the stove on",
+ k_message6 = "Hold up, my pet rock just ran away",
+ k_message7 = "One sec, my plants are asking for water",
+ k_message8 = "Brb, my socks are plotting against me",
+ k_message9 = "Sorry, my WiFi is having an existential crisis",
+ k_lobby_options = "Tuỳ Chỉnh Phòng",
+ k_connect_player = "Người chơi đã Kết nối:",
+ k_opts_only_host = "Chỉ Chủ Phòng có thể chỉnh sửa những tuỳ chỉnh này",
+ k_lobby_general = "Chung",
+ k_lobby_gameplay = "Kiểu Chơi",
+ k_lobby_modifiers = "Tuỳ Chỉnh",
+ k_lobby_advanced = "Nâng Cao",
+ k_opts_pvp_start_round = "Đối Đầu Bắt đầu ở Ante",
+ k_opts_pvp_timer = "Đếm Ngược",
+ 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_bl_life = "Sống",
+ k_bl_or = "hoặc",
+ k_bl_death = "Chết",
+ k_bl_mostchips = "Nhiều điểm hơn sẽ chiến thắng",
+ k_current_seed = "Giống hiện tại: ",
+ k_random = "Ngẫu nhiên",
+ k_standard = "Tiêu Chuẩn",
+ -- k_standard_description = "Thể thức tiêu chuẩn, bao gồm lá Chơi Mạng và điều chỉnh game gốc để phù hợp với meta Chơi Mạng.",
+ k_sandbox = "Sandbox",
+ 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_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_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.",
+ k_showdown = "Hạ Màn",
+ k_showdown_description = "Sau 2 ante đầu tiên, mọi boss blind đều là Blind Đối Đầu. Thể thức này cho bạn thời gian chuẩn bị trước trận chiến",
+ k_survival = "Sinh Tồn",
+ k_survival_description = "Người chơi đánh bại blind xa nhất sẽ chiến thắng. Không có Blind Đối Đầu. Thể thức này kiểm tra khả năng xây bộ bài từ từ cho đến khi lấy được điểm cao nhất.",
+ 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_oops_ex = "Úi!",
+ k_asteroids = "Thiên Thạch",
+ k_amount_short = "Lượg",
+ k_filed_ex = "Đã Gọt!",
+ k_timer = "Đếm Ngược",
+ k_mods_list = "Danh Sách Mod",
+ k_enemy_jokers = "Joker của Đối Thủ",
+ 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_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_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_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õ",
+ k_tutorial_not_complete = "Bạn phải hoàn thành màn hướng dẫn trước khi chơi Multiplayer",
+ k_created_by = "Tạo nởi",
+ k_major_contributors = "Đóng góp chính bởi",
+ ml_enemy_loc = {
+ "Vị trí",
+ "Đối Thủ",
+ },
+ ml_mp_kofi_message = {
+ "Mod và máy chủ này được",
+ "lập trình và bảo trì bởi",
+ "một người, nếu bạn",
+ "thích nó, hãy",
+ },
+ ml_lobby_info = {
+ "T.Tin",
+ "Phòng",
+ },
+ loc_ready = "Sẵn sàng Đối Đầu",
+ loc_selecting = "Đang chọn Blind",
+ loc_shop = "Đang đi chợ",
+ loc_playing = "Đang đánh ",
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Người vẽ: #1#",
+ },
+ a_mp_code = {
+ "Người tạo: #1#",
+ },
+ a_mp_idea = {
+ "Ý tưởng: #1#",
+ },
+ a_mp_skips_ahead = {
+ "Hơn #1# lần Bỏ Qua",
+ },
+ a_mp_skips_behind = {
+ "Kém #1# lần Bỏ Qua",
+ },
+ a_mp_skips_tied = {
+ "Hoà",
+ },
+ k_banned_objs = "Đã Cấm #1#",
+ k_no_banned_objs = "Không Cấm #1#",
+ k_reworked_objs = "Đã Thêm/Sửa Lại #1#",
+ k_no_reworked_objs = "Không Thêm/Sửa Lại #1#",
+ k_ruleset_disabled_smods_version = "Yêu cần phiên bản SMODS #1#",
+ k_failed_to_join_lobby = "Không thể tham gia phòng: #1#",
+ k_ante_number = "Ante #1#",
+ k_ante_range = "Ante #1#-#2#", -- For example, "Ante 1-2"
+ k_ante_min = "Ante #1#+", -- For example, "Ante 2+"
+ k_credits_list = "#1# và nhiều người nữa!", -- #1# gets replaced with a list of names
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}Phiếu Đục Lỗ{} được {C:dark_edition}làm lại",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}Lá Kính{} được {C:dark_edition}làm lại",
+ },
+ ch_c_mp_score_instability = {
+ "Điểm không cân bằng bị {C:purple}chêch lệch{} nhiều hơn:",
+ },
+ ch_c_mp_score_instability_EXAMPLE = {
+ " {C:inactive}(VD: {C:chips}30{C:inactive}x{C:mult}24{C:inactive} -> {C:chips}36{C:inactive}x{C:mult}18{C:inactive})",
+ },
+ ch_c_mp_score_instability_LOC1 = {
+ " {C:inactive}Tối thiểu {C:attention}1 {C:mult}Nhân",
+ },
+ ch_c_mp_score_instability_LOC2 = {
+ " {C:inactive}Tối thiểu {C:attention}0 {C:chips}Chip",
+ },
+ ch_c_mp_ante_scaling = {
+ "{C:red}X#1#{} điểm Blind sàn",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "Tiêu Chuẩn",
+ c_mp_sandbox = "Sandbox",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "Giải Đấu",
+ c_mp_weekly = "Giải Tuần",
+ c_mp_vanilla = "Cơ Bản",
+ c_mp_misprint_deck = "Bộ Bài Lỗi In",
+ c_mp_legendaries = "Huyền Thoại",
+ c_mp_psychosis = "Rối Loạn Tâm Thần",
+ c_mp_scratch = "Múa Từ Đầu",
+ c_mp_twin_towers = "Tháp Đôi",
+ c_mp_in_the_red = "Vỡ Nợ",
+ c_mp_paper_money = "Tiền Giấy",
+ c_mp_high_hand = "Tay Siêu Bự",
+ c_mp_chore_list = "Danh Sách Việc Nhà",
+ c_mp_oops_all_jokers = "Úi! Toàn Joker",
+ c_mp_divination = "Tiên Tri",
+ c_mp_skip_off = "Nhảy Lò Cò",
+ 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",
+ },
+ },
+}
diff --git a/localization/zh_CN.lua b/localization/zh_CN.lua
new file mode 100644
index 00000000..305f2ee9
--- /dev/null
+++ b/localization/zh_CN.lua
@@ -0,0 +1,442 @@
+-- localization by @tavolga
+return {
+ descriptions = {
+ Tag = {
+ tag_mp_gambling_sandbox = {
+ name = "赌博标签",
+ text = {
+ "{C:green}#1#/#2#{}几率",
+ "商店会有一张免费的",
+ "{C:red}稀有小丑牌{}",
+ },
+ },
+ },
+ Joker = {
+ j_broken = {
+ name = "错误",
+ text = {
+ "这张卡牌要么坏了",
+ "要么在当前模组版本",
+ "没有被添加。",
+ },
+ },
+ j_mp_defensive_joker = {
+ name = "防御小丑",
+ text = {
+ "每少于你的{X:purple,C:white}宿敌{}一条{C:red,E:1}命{}",
+ "添加{C:chips}+#1#{}筹码",
+ "{C:inactive}(目前{C:chips}+#2#{C:inactive}筹码)",
+ "{C:inactive}(取决于当前堵住)",
+ },
+ },
+ j_mp_skip_off = {
+ name = "跳房子", -- translated to hopscotch instead of the original "skip-off" name due to the artwork
+ text = {
+ "每个大于你{X:purple,C:white}宿敌{}跳过{C:attention}盲注{}的次数",
+ "会给予{C:blue}+#1#{}次出牌还有{C:red}+#2#{}次弃牌",
+ "{C:inactive}(目前 {C:blue}+#3#{C:inactive}/{C:red}+#4#{C:inactive}, #5#)",
+ },
+ },
+ j_mp_lets_go_gambling = {
+ name = "咱们去赌博吧",
+ text = {
+ "{C:attention}PVP 盲注{}中,{C:green}#1#/#2#{}几率获得",
+ "{X:mult,C:white}X#3#{}倍率和{C:money}$#4#{}。",
+ "{C:green}#5#/#6#{}几率给你的",
+ "{X:purple,C:white}宿敌{}{C:money}$#7#{}",
+ },
+ },
+ j_mp_speedrun = {
+ name = "速通",
+ text = {
+ "若你领先你的{X:purple,C:white}宿敌{}到达{C:attention}PVP盲注",
+ "随机生成一张{C:spectral}幽灵牌{}",
+ "{C:inactive}(必须有空位)",
+ },
+ },
+ j_mp_conjoined_joker = {
+ name = "连体小丑",
+ text = {
+ "在{C:attention}PVP盲注{}中,根据",
+ "你的{X:purple,C:white}宿敌{}所剩余的{C:blue}出牌{}次数,每个获取",
+ "{X:mult,C:white}X#1#{}倍率",
+ "{C:inactive}(最高{X:mult,C:white}X#2#{C:inactive}倍率,目前{X:mult,C:white}X#3#{C:inactive}倍率)",
+ },
+ },
+ j_mp_penny_pincher = {
+ name = "吝啬鬼",
+ text = {
+ "回合结束时,根据你的{X:purple,C:white}宿敌{}",
+ "在{C:attention}上一个底注{}相应商店",
+ "花的每{C:money}$#2#{}获取{C:money}$#1#{}",
+ },
+ },
+ j_mp_taxes = {
+ name = "税款",
+ text = {
+ "根据你的{X:purple,C:white}宿敌{}在上一个{C:attention}PVP底注{}之后",
+ "{C:attention}售出{}卡牌的次数获得{C:mult}+#1#{}倍率",
+ "{C:attention}PVP底注{}选择时更新",
+ "{C:inactive}(目前{C:mult}+#2#{C:inactive}倍率)",
+ },
+ },
+ j_mp_magnet = {
+ name = "磁铁",
+ text = {
+ "{C:attention}#1#{}回合之后,",
+ "售出此卡牌即可{C:attention}复制{}",
+ "你的{X:purple,C:white}宿敌{}所拥有的",
+ "最高价值的{C:attention}小丑牌{}",
+ "{C:inactive}(当前{C:attention}#2#{C:inactive}/#3#回合)",
+ },
+ },
+ j_mp_pizza = {
+ name = "披萨",
+ text = {
+ "在{C:attention}PVP盲注{}后,",
+ "摧毁次小丑牌,并且在此底注提供",
+ "{C:red}+#1#{} 次弃牌给你还有",
+ "{C:red}+#2#{} 次弃牌给你的{X:purple,C:white}宿敌{}",
+ },
+ },
+ j_mp_pacifist = {
+ name = "和平主义者",
+ text = {
+ "在{C:attention}PvP盲注{}之外时",
+ "{X:mult,C:white}X#1#{}倍率",
+ },
+ },
+ j_mp_hanging_chad = {
+ name = "未断选票",
+ text = {
+ "打出的牌中{C:attention}第一张{}和{C:attention}第二张{}",
+ "记分牌额外触发{C:attention}#1#{}次",
+ },
+ },
+ j_mp_cloud_9 = {
+ name = "9霄云外",
+ text = {
+ "每一个回合结束时你的完整牌组内的每一张{C:attention}9{}",
+ "使你获得{C:money}$1{}(最大{C:money}$4{}),然后每一张多余的{C:attention}9{}",
+ "使你获得{C:money}$#1#{}",
+ "{C:inactive}(当前{C:money}$#2#{}{C:inactive})",
+ },
+ },
+ j_mp_bloodstone = {
+ name = "血石",
+ text = {
+ "打出的{C:hearts}红桃{}花色牌",
+ "在积分时有{C:green}#1#/#2#{}几率",
+ "给予{X:mult,C:white}X#3#{}倍率",
+ "{C:inactive}(包含实验性概率方差){}",
+ },
+ },
+ },
+ Planet = {
+ c_mp_asteroid = {
+ name = "小行星",
+ text = {
+ "在{C:attention}PVP盲注{}开始时",
+ "从你的{X:purple,C:white}宿敌{}的",
+ "最高等级{C:legendary,E:1}牌型{}",
+ "移除#1#个等级",
+ },
+ },
+ },
+ Blind = {
+ bl_mp_nemesis = {
+ name = "你的宿敌",
+ text = {
+ "面对另一个玩家,",
+ "最多筹码获胜",
+ },
+ },
+ },
+ Edition = {
+ e_mp_phantom = {
+ name = "幻影",
+ text = {
+ "拥有{C:attention}永恒{}和{C:dark_edition}负片{}属性",
+ "由你的{X:purple,C:white}宿敌{}创建被并摧毁",
+ },
+ },
+ },
+ Enhanced = {
+ m_mp_display_glass = {
+ name = "玻璃牌",
+ text = {
+ "{X:mult,C:white}X#1#{}倍率",
+ "有{C:green}#2#/#3#{}几率",
+ "摧毁此牌",
+ },
+ },
+ m_mp_sandbox_display_glass = {
+ name = "玻璃牌",
+ text = {
+ "{X:mult,C:white}X#1#{}倍率",
+ "有{C:green}#2#/#3#{}几率",
+ "摧毁此牌",
+ },
+ },
+ },
+ Other = {
+ current_nemesis = {
+ name = "宿敌",
+ text = {
+ "{X:purple,C:white}#1#{}",
+ "你唯一的宿敌",
+ },
+ },
+ },
+ },
+ misc = {
+ labels = {
+ mp_phantom = "幻影",
+ },
+ dictionary = {
+ b_singleplayer = "单人游戏",
+ b_join_lobby = "加入房间",
+ b_return_lobby = "回到房间",
+ b_reconnect = "重新连接",
+ b_create_lobby = "创建房间",
+ b_start_lobby = "开始房间",
+ b_ready = "准备",
+ b_unready = "取消准备",
+ b_leave_lobby = "退出房间",
+ b_mp_discord = "Balatro Multiplayer的Discord服务器",
+ b_start = "开始",
+ b_wait_for_host_start = {
+ "正在等待",
+ "主机开始",
+ },
+ b_wait_for_players = {
+ "正在等待",
+ "玩家",
+ },
+ b_wait_for_guest_ready = {
+ "正在等待",
+ "访客",
+ },
+ b_lobby_options = "房间设置",
+ b_copy_clipboard = "复制到剪贴板",
+ b_view_code = "查看房间号",
+ b_copy_code = "复制房间号",
+ b_leave = "离开",
+ b_opts_cb_money = "丢失生命时获取逆转金钱",
+ b_opts_no_gold_on_loss = "输时不获取盲注奖励",
+ b_opts_death_on_loss = "非PVP盲注输时丢失一条命",
+ b_opts_start_antes = "开始底注",
+ b_opts_diff_seeds = "玩家使用不同种子",
+ b_opts_lives = "生命",
+ b_opts_multiplayer_jokers = "启用多人游戏专属卡牌",
+ b_opts_player_diff_deck = "玩家使用不同牌组",
+ b_opts_normal_bosses = "启用Boss盲注效果",
+ b_opts_timer = "启用计时器",
+ b_reset = "重置",
+ b_set_custom_seed = "设置自定义种子",
+ b_mp_kofi_button = "在 Ko-fi 上支持我",
+ b_unstuck = "解除卡住",
+ b_unstuck_blind = "PvP 盲注之外卡住",
+ b_misprint_display = "显示牌组的下一张牌",
+ b_players = "玩家",
+ b_lobby_info = "房间信息",
+ b_continue_singleplayer = "在单机模式继续",
+ b_the_order_integration = "启用 The Order 集成",
+ b_view_nemesis_deck = "查看牌组",
+ b_toggle_jokers = "切换小丑牌",
+ b_skip_tutorial = "跳过教程",
+ k_yes = "是",
+ k_no = "否",
+ k_has_multiplayer_content = "包含多人模式内容",
+ k_forces_lobby_options = "强制房间设置",
+ k_forces_gamemode = "强制玩法",
+ k_values_are_modifiable = "* 指数可更改",
+ k_rulesets = "规则集",
+ k_gamemodes = "玩法",
+ k_competitive = "竞技",
+ k_other = "其他",
+ k_battle = "战斗",
+ k_challenge = "挑战",
+ k_info = "信息",
+ k_continue_singleplayer_tooltip = "这会覆盖你目前的单人模式的游戏",
+ k_enemy_score = "敌方目前分数",
+ k_enemy_hands = "地方剩余出牌: ",
+ k_coming_soon = "即将推出!",
+ k_wait_enemy = "正在等待敌方完成...",
+ k_wait_enemy_reach_this_blind = "正在等待敌方到达目前盲注...",
+ k_lives = "生命",
+ k_lost_life = "丢失了一条命",
+ k_total_lives_lost = "总共丢失生命 (每条命值$4)",
+ k_attrition_name = "消耗战",
+ k_enter_lobby_code = "输入房间号",
+ k_paste = "从剪贴板粘贴",
+ k_username = "用户名:",
+ k_enter_username = "输入用户名",
+ k_join_discord = "加入 ",
+ k_discord_msg = "你可以在那里报告错误以及寻找其他玩家游玩",
+ k_enter_to_save = "按下回车即可保存",
+ k_in_lobby = "处于房间",
+ k_connected = "已连接到服务",
+ k_warn_service = "警告: 无法找到多人游戏服务",
+ k_set_name = "请在主菜单设置用户名! (模组 > Multiplayer > 配置)",
+ k_mod_hash_warning = "玩家拥有不一致的模组或版本! 这可能会产生错误!",
+ k_steamodded_warning = "玩家安装了不同版本的 Steamodded. 这坑能会导致种子不一致.",
+ k_warning_unlock_profile = "你当前使用的账号尚未完全解锁。若处于排名赛或锦标赛,请创建新账号并在账号设置中选择“解锁全部”",
+ k_warning_nemesis_unlock = "你的对手当前使用的账号尚未完全解锁。请指示他们去创建新账号并在账号设置中选择“解锁全部”",
+ k_warning_no_order = "其中一位玩家开启了 The Order 集成,但另外一位没有开启。这会导致游戏种子不一致",
+ k_warning_cheating1 = "如果你可以看见这个,你的对手可能在作弊.",
+ k_warning_cheating2 = "如果这是一场排名赛, 请发送消息 '%s' 并且在 #support 频道中提交一个支持工单",
+ k_message1 = "等一下,我妈做了点披萨球",
+ k_message2 = "等一下,我得去拿我的慢炖烤猪肉",
+ k_message3 = "稍等一下,我妈在给我打电话",
+ k_message4 = "等等,我猫突然着火了",
+ k_message5 = "哎哟,等一下,我好像炉子忘关了",
+ k_message6 = "啊等等,我的宠物石头突然跑掉了",
+ k_message7 = "稍等,我的植物在管我要水",
+ k_message8 = "等等,我在被我的袜子暗中算计",
+ k_message9 = "不好意思,我的网好像在经历一场存在危机",
+ k_lobby_options = "房间设置",
+ k_connect_player = "已连接玩家:",
+ k_opts_only_host = "只有主机才能更改此设置",
+ k_opts_gm = "玩法修改",
+ k_opts_pvp_start_round = "PVP 开始底注",
+ k_opts_pvp_timer = "计时器",
+ k_opts_showdown_starting_antes = "最终对决开始底注",
+ k_opts_pvp_timer_increment = "计时器增量",
+ k_opts_pvp_countdown_seconds = "PVP 倒计时秒数",
+ k_bl_life = "生命",
+ k_bl_or = "或",
+ k_bl_death = "死亡",
+ k_bl_mostchips = "最多筹码获胜",
+ k_current_seed = "目前种子: ",
+ k_random = "随机",
+ k_standard = "标准",
+ k_sandbox = "的沙盒",
+ k_sandbox_description = "类似于经典模式,但是有人给牌喝了点咖啡,令它们变得好像有点话多。",
+ k_vanilla = "原味香草",
+ k_vanilla_description = "此规则集移除了所有多人游戏内容,使您能够以游戏最初设计的方式进行游玩。\n\n此规则集仍包含多人游戏功能,如计时器。\n\n(可在房间设置中禁用)",
+ k_blitz = "闪电赛",
+ k_blitz_description = "本规则集包含鼓励快速游戏和将时间作为资源使用的卡牌和功能。\n\n部分卡牌在本规则集中经过调整以更好地适应多人游戏环境:\n- 未断选票 经过调整\n- 正义 被移除\n- 玻璃牌 经过调整\n\n(请参阅禁用和修改区域以获取更多信息)",
+ k_traditional = "经典",
+ k_traditional_description = "此规则集移除了多人游戏中以时间作为资源的各项功能。\n\n该规则集允许您畅玩多人游戏内容,同时仍能保持游戏的策略性。\n\n部分卡牌在本规则集中经过调整以更好地适应多人游戏环境:\n- 未断选票 经过调整\n- 正义 被移除\n- 玻璃牌 经过调整\n\n(请参阅禁用和修改区域以获取更多信息)",
+ k_majorleague = "Major League",
+ k_majorleague_description = "这是Major League Balatro的官方规则集。\n\n本规则集与原味香草规则集基本相同,但有以下几点例外:\n- 必须禁用 The Order 集成 功能\n- 计时器设置为180秒\n- 计时器首次归零时,不会失去生命",
+ k_minorleague = "Minor League",
+ k_minorleague_description = "这是Major League Balatro的官方规则集。\n\n本规则集与原味香草规则集基本相同,但有以下几点例外:\n- 必须启用 The Order 集成 功能\n- 计时器设置为180秒\n- 计时器首次归零时,不会失去生命",
+ k_ranked = "排名赛",
+ k_ranked_description = "这是 Balatro Multiplayer 模组 排名赛的官方规则集。\n\n本规则集与闪电赛规则集基本相同,但有以下几点例外:\n- 必须启用 The Order 集成 功能\n- 必须使用推荐的Steamodded版本",
+ k_badlatro = "Badlatro",
+ k_badlatro_description = "由@dr_monty_the_snek在Discord服务器上设计的每周规则集,现已永久添加到模组中。\n\n该规则集禁止使用48张小丑牌、消耗牌、标签等。",
+ k_attrition = "消耗战",
+ k_attrition_description = "第一底注后,每个Boss盲注都是宿敌盲注。你没有任何时间准备。此玩法迫使你从开局就开始备战",
+ k_showdown = "决战",
+ k_showdown_description = "前两个底注之后,每个盲注都是宿敌盲注。次玩法会给你充足备战的时间",
+ k_survival = "生存",
+ k_survival_description = "击败最远的盲注的玩家获胜。 无宿敌盲注. 此玩法会考研你逐步构建出最高分经典牌型的能力",
+ k_weekly = "Weekly",
+ k_weekly_description = "一个每周或每两周就会更新一次的特殊规则集。我想你就得自己去发现它到底是什么~ \n目前:",
+ k_weekly_smallworld = "小世界",
+ k_oops_ex = "没有!",
+ k_asteroids = "小行星",
+ k_amount_short = "数量",
+ k_filed_ex = "已申报!",
+ k_timer = "计时器",
+ k_mods_list = "模组列表",
+ k_enemy_jokers = "敌方小丑牌",
+ k_your_jokers = "你的小丑牌",
+ k_nemesis_deck = "敌方牌组",
+ k_your_deck = "你的牌组",
+ k_the_order_credit = "*感谢 @MathIsFun_",
+ k_the_order_integration_desc = "此修复会令牌制造过程不受底注影响,以及导致每一个稀有度或类型都使用同样聚合",
+ k_requires_restart = "*重启生效",
+ k_new_weekly_ruleset = "新的每周规则集已被推出!",
+ k_currently_colon = "目前: ",
+ k_sync_locally = "本地同步(会重启游戏)",
+ k_bans = "禁用",
+ k_reworks = "修改",
+ k_ruleset_disabled_the_order_required = "需要 The Order",
+ k_ruleset_disabled_the_order_banned = "禁用 The Order",
+ k_ruleset_not_found = "未知规则集",
+ k_tutorial_not_complete = "你必须完成教程之后才能进行多人游戏",
+ k_created_by = "创作者:",
+ k_major_contributors = "主要贡献由:",
+ ml_enemy_loc = {
+ "敌方",
+ "地点",
+ },
+ ml_mp_kofi_message = {
+ "次模组以及游戏服务器",
+ "由一个人开发并维护",
+ "如果您喜欢,请考虑",
+ },
+ ml_lobby_info = {
+ "房间",
+ "信息",
+ },
+ loc_ready = "准备对战",
+ loc_selecting = "选择盲注中",
+ loc_shop = "购物中",
+ loc_playing = "战斗", -- translates to "fighting" because the direct translation for playing sounds a bit funny in context
+ },
+ v_dictionary = {
+ a_mp_art = {
+ "Art: #1#",
+ },
+ a_mp_code = {
+ "Code: #1#",
+ },
+ a_mp_idea = {
+ "Idea: #1#",
+ },
+ a_mp_skips_ahead = {
+ "领先#1#次跳过盲住",
+ },
+ a_mp_skips_behind = {
+ "落后#1#次跳过盲注",
+ },
+ a_mp_skips_tied = {
+ "跳过盲注次数相等",
+ },
+ k_banned_objs = "禁用 #1#",
+ k_no_banned_objs = "非禁用 #1#",
+ k_reworked_objs = "修改 #1#",
+ k_no_reworked_objs = "非修改 #1#",
+ k_ruleset_disabled_smods_version = "需求 SMODS 版本 #1#",
+ k_failed_to_join_lobby = "加入房间失败: #1#",
+ k_ante_number = "底注 #1#",
+ k_ante_range = "底注 #1#-#2#", -- For example, "Ante 1-2"
+ k_ante_min = "底注 #1#+", -- For example, "Ante 2+"
+ k_credits_list = "#1# 以及更多的人!", -- #1# gets replaced with a list of names
+ },
+ v_text = {
+ ch_c_hanging_chad_rework = {
+ "{C:attention}未断选票{}有{C:dark_edition}修改",
+ },
+ ch_c_glass_cards_rework = {
+ "{C:attention}玻璃牌{}有{C:dark_edition}修改",
+ },
+ },
+ challenge_names = {
+ c_mp_standard = "标准",
+ c_mp_sandbox = "沙盒",
+ c_mp_badlatro = "Badlatro",
+ c_mp_tournament = "竞赛",
+ c_mp_weekly = "每周挑战",
+ c_mp_vanilla = "香草",
+ c_mp_misprint_deck = "印错牌组",
+ c_mp_legendaries = "传奇小丑",
+ c_mp_psychosis = "精神分裂", -- technically translates to "schizophrenia", but the accurate term for psychosis is also used as an insult
+ c_mp_scratch = "从零开始", -- translates "starting from zero"
+ c_mp_twin_towers = "双子塔",
+ c_mp_in_the_red = "陷入亏损", -- kind of translates to "stuck in debt"
+ c_mp_paper_money = "纸币", -- don't know the context for this one, for some reason it doesnt show up in the challenges list, so this one's just a direct translation
+ c_mp_high_hand = "大牌手", -- roughly translates to "big card hands"
+ c_mp_chore_list = "家务表",
+ c_mp_oops_all_jokers = "全是小丑牌", -- i don't think there's an equivalent for the "oops, all XXX" phrase, so here's just a direct translation of "all jokers"
+ c_mp_divination = "占卜",
+ c_mp_skip_off = "跳房子", -- same as translation for skip-off joker (just translates to "hopscotch")
+ c_mp_lets_go_gambling = "咱们去赌博吧",
+ c_mp_speed = "速度",
+ },
+ },
+}
diff --git a/localization/zh_TW.lua b/localization/zh_TW.lua
new file mode 100644
index 00000000..fbe69984
--- /dev/null
+++ b/localization/zh_TW.lua
@@ -0,0 +1,84 @@
+-- Localization by CUexter on GitHub
+return {
+ descriptions = {
+ Blind = {
+ bl_pvp = {
+ name = "你的對手",
+ text = {
+ "面對其他玩家,",
+ "得分最多的人贏",
+ },
+ },
+ },
+ },
+ misc = {
+ challenge_names = {
+ c_multiplayer_1 = "多人遊戲",
+ },
+ dictionary = {
+ singleplayer = "單人遊戲",
+ join_lobby = "加入大廳",
+ return_lobby = "返回大廳",
+ reconnect = "重新連線",
+ desc = "令你可以與其他人對戰",
+ create_lobby = "建立大廳",
+ start_lobby = "開放大廳",
+ enemy_score = "現在對手的分數",
+ enemy_hands = "對手剩餘的手牌數",
+ coming_soon = "即將推出",
+ ready = "準備下一回合",
+ unready = "取消準備",
+ wait_enemy = "等待對手完成...",
+ lives = "剩餘命數",
+ leave_lobby = "離開大廳",
+ lost_life = "失去了一條命",
+ failed = "失敗了",
+ defeat_enemy = "擊敗對手",
+ total_lives_lost = "總共失去的命數(每條命 $4)",
+ attrition_name = "消耗戰",
+ attrition_desc = "每個Boss回合都是玩家之間的比賽, 分數較低的玩家會失去一條命",
+ draft_name = "選拔戰",
+ draft_desc = "雙方玩家會玩三次正常的底注, 然後他們會玩一次底注, 每回合分數較低的人會失去一條命",
+ royale_name = "大亂鬥",
+ royale_desc = "類似消耗戰,但有八個玩家且每人只有一條命",
+ vanilla_plus_name = "經典+",
+ vp_desc = "第一個輸的人就輸,沒有PVP的盲注",
+ enter_lobby_code = "輸入大廳代碼",
+ join_clip = "從剪貼簿貼上",
+ username = "用戶名稱:",
+ enter_username = "輸入用戶名稱",
+ join_discord = "加入",
+ discord_name = "Balatro多人Discord",
+ discord_msg = "你可以在那裡報告找到的bugs並找到人陪你玩",
+ enter_to_save = "按Enter保存",
+ in_lobby = "在大廳",
+ connected = "服務已連接",
+ warn_service = "警告:無法找到多人伺服器",
+ set_name = "可以在主選單設置自己的名字(Mods > Multiplayer)",
+ start = "開始",
+ wait_for = "等待",
+ host_start = "大廳主開始",
+ players = "其他玩家",
+ lobby_options = "大廳設置",
+ lobby_options_cap = "大廳設置",
+ copy_clipboard = "複製到剪貼簿",
+ connect_player = "已連接玩家:",
+ view_code = "查看代碼",
+ copy_code = "複製程式碼",
+ leave = "離開",
+ opts_only_host = "只有大廳主能改這些設置",
+ opts_cb_money = "死後會給錢重新開始",
+ opts_no_gold_on_loss = "輸了回合會失去那個盲注的錢",
+ opts_death_on_loss = "如果非PvP回合輸了會失去一條命",
+ opts_start_antes = "一開始正常底注數目",
+ opts_diff_seeds = "玩家有不同的SEED",
+ opts_lives = "命數",
+ opts_gm = "模式修改效果",
+ bl_or = "或",
+ bl_life = "生",
+ bl_death = "死",
+ lobby = "大廳",
+ return_to = "返回",
+ },
+ },
+}
diff --git a/lovely/TheOrder.toml b/lovely/TheOrder.toml
new file mode 100644
index 00000000..1c8e64ac
--- /dev/null
+++ b/lovely/TheOrder.toml
@@ -0,0 +1,195 @@
+# Credit to @MathIsFun_ for creating TheOrder, which this integration is a copy of
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 0
+
+# Patches boss generation to be ante-based
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = "local _, boss = pseudorandom_element(eligible_bosses, pseudoseed('boss'))"
+position = "at"
+payload = '''local boss = nil
+if MP.should_use_the_order() then
+ _, boss = pseudorandom_element(eligible_bosses, pseudoseed('boss'..G.GAME.round_resets.ante))
+else
+ _, boss = pseudorandom_element(eligible_bosses, pseudoseed('boss'))
+end'''
+match_indent = true
+
+# Adds an asterisk to the front of the seed
+# This isn't required for the mod to function, but it makes it easier to identify that seeds vary from vanilla
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = "for k, v in pairs(self.GAME.pseudorandom) do if v == 0 then self.GAME.pseudorandom[k] = pseudohash(k..self.GAME.pseudorandom.seed) end end"
+position = "before"
+payload = '''if self.GAME.pseudorandom.seed:sub(1, 1) ~= "*" and MP.should_use_the_order() then self.GAME.pseudorandom.seed = "*" .. self.GAME.pseudorandom.seed end'''
+match_indent = true
+
+# Ankh compat w/ previous patch
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = "self.GAME.pseudorandom.seed = hash_seed(self.GAME.pseudorandom.seed)"
+position = "before"
+payload = '''if self.GAME.pseudorandom.seed:sub(1, 1) ~= "*" and MP.should_use_the_order() then self.GAME.pseudorandom.seed = "*" .. self.GAME.pseudorandom.seed end'''
+match_indent = true
+
+
+
+
+# Make hallucination coinflip queue global
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''if SMODS.pseudorandom_probability(self, 'halu'..G.GAME.round_resets.ante, 1, self.ability.extra) then'''
+position = "at"
+payload = '''if SMODS.pseudorandom_probability(self, 'halu'..MP.ante_based(), 1, self.ability.extra) then'''
+match_indent = true
+
+# Make booster pack queue global
+[[patches]]
+[patches.pattern]
+target = '''=[SMODS _ "src/overrides.lua"]'''
+pattern = '''local poll = pseudorandom(pseudoseed((_key or 'pack_generic')..G.GAME.round_resets.ante))*cume'''
+position = "at"
+payload = '''local poll = pseudorandom(pseudoseed((_key or 'pack_generic')..MP.ante_based()))*cume'''
+match_indent = true
+
+# Make deck shuffles round based
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''G.deck:shuffle('nr'..G.GAME.round_resets.ante)'''
+position = "at"
+payload = '''G.deck:shuffle('nr'..MP.order_round_based(true))'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "functions/button_callbacks.lua"
+pattern = '''G.deck:shuffle('cashout'..G.GAME.round_resets.ante)'''
+position = "at"
+payload = '''G.deck:shuffle('cashout'..MP.order_round_based(true))'''
+match_indent = true
+
+# Patch polled rate
+# This determines whether shop card is tarot/joker/planet/etc
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''local polled_rate = pseudorandom(pseudoseed('cdt'..G.GAME.round_resets.ante))*total_rate'''
+position = "at"
+payload = '''local polled_rate = pseudorandom(pseudoseed('cdt'..MP.ante_based()))*total_rate'''
+match_indent = true
+
+# Resample advances rarity queue
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''center = pseudorandom_element(_pool, pseudoseed(_pool_key..'_resample'..it))'''
+position = "at"
+payload = '''center = pseudorandom_element(_pool, pseudoseed(_pool_key..(MP.should_use_the_order() and '' or ('_resample'..it)) ))'''
+match_indent = true
+
+# Patch joker editions/stickers to be dependent on individual jokers rather than queue
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if (area == G.shop_jokers) or (area == G.pack_cards) then'''
+position = "before"
+payload = '''
+local _etpeareakey = MP.should_use_the_order() and 'etperpoll' or (area == G.pack_cards and 'packetper' or 'etperpoll')
+local _rentareakey = MP.should_use_the_order() and 'ssjr' or (area == G.pack_cards and 'packssjr' or 'ssjr')
+local _order = MP.should_use_the_order() and center.key or ""
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''local eternal_perishable_poll = pseudorandom((area == G.pack_cards and 'packetper' or 'etperpoll')..G.GAME.round_resets.ante)'''
+position = "at"
+payload = '''local eternal_perishable_poll = pseudorandom(_order.._etpeareakey..G.GAME.round_resets.ante)'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if G.GAME.modifiers.enable_rentals_in_shop and pseudorandom((area == G.pack_cards and 'packssjr' or 'ssjr')..G.GAME.round_resets.ante) > 0.7 and not SMODS.Stickers["rental"].should_apply then'''
+position = "at"
+payload = '''if G.GAME.modifiers.enable_rentals_in_shop and pseudorandom(_order.._rentareakey..G.GAME.round_resets.ante) > 0.7 and not SMODS.Stickers["rental"].should_apply then'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''local edition = poll_edition('edi'..(key_append or '')..G.GAME.round_resets.ante)'''
+position = "at"
+payload = '''
+if MP.should_use_the_order() then key_append = nil end -- why does this even use key_append again?
+local edition = poll_edition(_order..'edi'..(key_append or '')..G.GAME.round_resets.ante)
+'''
+match_indent = true
+
+# Make soul/black hole queue not dependent on type (omen globe)
+# Avoid black hole overwriting soul
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if pseudorandom('soul_'.._type..G.GAME.round_resets.ante) > 0.997 then
+ forced_key = 'c_soul''''
+position = "at"
+payload = '''if pseudorandom('soul_'..(MP.should_use_the_order() and 'c_soul' or _type)..G.GAME.round_resets.ante) > 0.997 then
+ forced_key = 'c_soul''''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if pseudorandom('soul_'.._type..G.GAME.round_resets.ante) > 0.997 then
+ forced_key = 'c_black_hole''''
+position = "at"
+payload = '''if pseudorandom('soul_'..(MP.should_use_the_order() and 'c_black_hole' or _type)..G.GAME.round_resets.ante) > 0.997 then
+ if not (MP.should_use_the_order() and forced_key) then
+ forced_key = 'c_black_hole'
+ end'''
+match_indent = true
+
+# Patch Wraith rarity to be the same as Rare Tag (because order uses it for some reason)
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''local card = create_card('Joker', G.jokers, nil, 0.99, nil, nil, nil, 'wra')'''
+position = "at"
+payload = '''local card = create_card('Joker', G.jokers, nil, MP.should_use_the_order() and 1 or 0.99, nil, nil, nil, 'wra')'''
+match_indent = true
+
+# Patch To-Do List rng to be the same on different operating systems
+# This is a vanilla bug!
+# Orbital adjacent fix is in ui/game.lua
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''while not self.ability.to_do_poker_hand do'''
+position = "before"
+payload = '''
+if MP.should_use_the_order() then
+ _poker_hands = MP.sorted_hand_list(self.ability.to_do_poker_hand)
+end
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''self.ability.to_do_poker_hand = pseudorandom_element(_poker_hands, pseudoseed('to_do'))'''
+position = "before"
+payload = '''
+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
diff --git a/lovely/cards.toml b/lovely/cards.toml
new file mode 100644
index 00000000..8ac1c62e
--- /dev/null
+++ b/lovely/cards.toml
@@ -0,0 +1,151 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.regex]
+target = "card.lua"
+pattern = '''\) then self.cost = 0 end'''
+position = 'after'
+payload = '''if self.edition and self.edition.type == 'mp_phantom' then self.sell_cost = 0 end'''
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''if G.jokers.cards[i] ~= self then'''
+position = 'at'
+payload = '''if G.jokers.cards[i] ~= self and (not G.jokers.cards[i].edition or G.jokers.cards[i].edition.type ~= "mp_phantom") then'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''local chosen_joker = pseudorandom_element(G.jokers.cards, pseudoseed('ankh_choice'))'''
+position = 'at'
+payload = '''local copyable_jokers = {}
+ for i, v in ipairs(G.jokers.cards) do
+ if not G.jokers.cards[i].edition or G.jokers.cards[i].edition.type ~= "mp_phantom" then copyable_jokers[#copyable_jokers + 1] = v end
+ end
+ local chosen_joker = pseudorandom_element(copyable_jokers, pseudoseed('ankh_choice'))'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.regex]
+target = "card.lua"
+pattern = '''--if there is at least one joker(?[\s\S]+?)for k, v in pairs\(G.jokers.cards\) do'''
+position = 'at'
+payload = '''--if there is at least one joker$pre local copyable_jokers = {}
+ for i, v in ipairs(G.jokers.cards) do
+ if not G.jokers.cards[i].edition or G.jokers.cards[i].edition.type ~= "mp_phantom" then copyable_jokers[#copyable_jokers + 1] = v end
+ end
+ for k, v in pairs(copyable_jokers) do'''
+times = 1
+
+# Sets the balanced sticker on reworked cards
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''if center.consumeable then'''
+position = 'before'
+payload = '''
+if old_center ~= center or initial then
+ if MP.LOBBY.config.ruleset then
+ local ruleset = string.sub(MP.LOBBY.config.ruleset, 12, #MP.LOBBY.config.ruleset)
+
+ if center.mp_reworks and center.mp_reworks[ruleset] and ruleset ~= 'vanilla' then
+ if not center.mp_silent[ruleset] then
+ self.ability.mp_sticker_balanced = true
+ end
+ end
+ end
+end'''
+match_indent = true
+times = 1
+
+# Fixes the issue with baseball card working on phantom jokers
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''if self.ability.name == 'Baseball Card' and self ~= context.other_joker and context.other_joker:is_rarity("Uncommon") then'''
+position = 'after'
+payload = '''if context.other_joker.edition and context.other_joker.edition.type == 'mp_phantom' then return end'''
+match_indent = true
+times = 1
+
+# STEAMODDED BUG - move this thing again???
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = ''' for k, v in pairs(other.ability) do
+ if type(v) == 'table' then
+ new_card.ability[k] = copy_table(v)
+ else
+ new_card.ability[k] = v
+ end
+ end'''
+position = 'at'
+payload = ''' '''
+match_indent = true
+times = 1
+
+# STEAMODDED BUG - move this thing again???
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if other.params then'''
+position = 'before'
+payload = '''
+ for k, v in pairs(other.ability) do
+ if type(v) == 'table' then
+ new_card.ability[k] = copy_table(v)
+ else
+ new_card.ability[k] = v
+ end
+ end'''
+match_indent = true
+times = 1
+
+# STEAMODDED BUG - add strip_edition check
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if other.params then'''
+position = 'before'
+payload = '''
+ for k, v in pairs(other.ability) do
+ if type(v) == 'table' then
+ new_card.ability[k] = copy_table(v)
+ else
+ new_card.ability[k] = v
+ end
+ end'''
+match_indent = true
+times = 1
+
+# STEAMODDED BUG - add check for this because perkeo
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if other.edition then'''
+position = 'at'
+payload = '''if other.edition and strip_edition then'''
+match_indent = true
+times = 1
+
+# STEAMODDED BUG - disable this
+# feels weird but recent steamodded commit to dev version removes this
+# so it's correct i guess
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''if other.seal then
+ new_card.ability.card_limit = new_card.ability.card_limit - (other.ability.seal.card_limit or 0)'''
+position = 'at'
+payload = '''if false then
+ new_card.ability.card_limit = new_card.ability.card_limit - (other.ability.seal.card_limit or 0)'''
+match_indent = true
+times = 1
diff --git a/lovely/ce.toml b/lovely/ce.toml
new file mode 100644
index 00000000..2311502c
--- /dev/null
+++ b/lovely/ce.toml
@@ -0,0 +1,39 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''G.GAME.dollars = G.GAME.dollars + mod'''
+position = 'after'
+payload = '''
+ if MP and MP.LOBBY and MP.LOBBY.code then
+ if MP.GAME.ce_cache == false then
+ MP.GAME.real_money = tonumber(MP.GAME.real_money) + mod
+ if MP.GAME.real_money ~= G.GAME.dollars then
+ MP.GAME.ce_cache = true
+ Client.send("ce_cache")
+ end
+ MP.GAME.real_money = tostring(MP.GAME.real_money)
+ end
+ end
+'''
+match_indent = true
+times = 1
+
+
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = '''self.GAME.dollars = self.GAME.starting_params.dollars'''
+position = 'after'
+payload = '''
+ if MP and MP.LOBBY and MP.LOBBY.code then
+ MP.GAME.real_money = tostring(self.GAME.starting_params.dollars)
+ end
+'''
+match_indent = true
+times = 1
+
diff --git a/lovely/challenges.toml b/lovely/challenges.toml
new file mode 100644
index 00000000..c05b495d
--- /dev/null
+++ b/lovely/challenges.toml
@@ -0,0 +1,31 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.pattern]
+target = '''functions/UI_definitions.lua'''
+pattern = "if v.pinned then card.pinned = true end"
+position = 'after'
+payload = "if v.rental then card:set_rental(true) end"
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = '''game.lua'''
+pattern = "if v.pinned then _joker.pinned = true end"
+position = 'after'
+payload = "if v.rental then _joker:set_rental(true) end"
+match_indent = true
+
+# there's no other way to do ante scaling in challenges other than patching lol
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = '''elseif v.value then'''
+position = "before"
+payload = '''
+elseif v.id == 'mp_ante_scaling' then
+ self.GAME.starting_params.ante_scaling = v.value'''
+match_indent = true
\ No newline at end of file
diff --git a/lovely/compatibility.toml b/lovely/compatibility.toml
new file mode 100644
index 00000000..d529c838
--- /dev/null
+++ b/lovely/compatibility.toml
@@ -0,0 +1,31 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 0
+
+# Make Next Ante Preview display Nemesis Blind's size as "????"
+[[patches]]
+[patches.pattern]
+target = '=[SMODS AntePreview "main.lua"]'
+pattern = "local tag = prediction[choice].tag"
+position = 'before'
+payload = '''
+if prediction[choice].blind == "bl_mp_nemesis" then
+ blind_amt = "????"
+end
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = '=[SMODS Cryptid "items/code.lua"]'
+pattern = '''if G.GAME.USING_CODE then
+ return
+ end'''
+position = 'before'
+payload = '''
+if MP.LOBBY.code then
+ return
+end
+'''
+match_indent = true
diff --git a/lovely/deck_select.toml b/lovely/deck_select.toml
new file mode 100644
index 00000000..1585158e
--- /dev/null
+++ b/lovely/deck_select.toml
@@ -0,0 +1,81 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+# Deck Select
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''local t = create_UIBox_generic_options({no_back = from_game_over, no_esc = from_game_over, contents ={'''
+position = 'at'
+payload = '''local t = MP.LOBBY.code and create_UIBox_generic_options({contents ={
+ {n=G.UIT.R, config={align = "cm", padding = 0, draw_layer = 1}, nodes={
+ create_tabs(
+ {tabs = {
+ {
+ label = localize('b_new_run'),
+ chosen = true,
+ tab_definition_function = (Galdur and Galdur.config.use) and G.UIDEF.run_setup_option_new_model or G.UIDEF.run_setup_option,
+ tab_definition_function_args = 'New Run'
+ },
+ {
+ label = localize('b_challenges'),
+ tab_definition_function = G.UIDEF.challenges,
+ tab_definition_function_args = from_game_over,
+ chosen = false
+ },
+ },
+ snap_to_nav = true}),
+ }},
+ }}) or create_UIBox_generic_options({no_back = from_game_over, no_esc = from_game_over, contents ={'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''if type == 'New Run' then'''
+position = 'at'
+payload = '''if type == 'New Run' and not MP.LOBBY.code then'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''G.FUNCS.change_stake({to_key = G.viewed_stake})
+ else'''
+position = 'after'
+payload = '''if MP.LOBBY.code then
+ G.viewed_stake = MP.LOBBY.deck.stake
+ G.FUNCS.change_stake({to_key = G.viewed_stake})
+ end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/button_callbacks.lua"
+pattern = '''if G.SETTINGS.current_setup == 'New Run' then'''
+position = 'at'
+payload = '''if G.SETTINGS.current_setup == 'New Run' or G.SETTINGS.current_setup == 'Multiplayer' then'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''G.GAME.viewed_back = Back(get_deck_from_name(G.PROFILES[G.SETTINGS.profile].MEMORY.deck))'''
+position = 'at'
+payload = '''G.GAME.viewed_back = MP.LOBBY.code and Back(get_deck_from_name(MP.LOBBY.deck.back)) or Back(get_deck_from_name(G.PROFILES[G.SETTINGS.profile].MEMORY.deck))'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.regex]
+target = "functions/UI_definitions.lua"
+pattern = '''function G.UIDEF.run_setup_option(?[\s\S]+?)localize\('b_play_cap'\)'''
+position = 'at'
+payload = '''function G.UIDEF.run_setup_option$pre MP.LOBBY.code and localize('b_select') or localize('b_play_cap')'''
+times = 1
diff --git a/lovely/decks.toml b/lovely/decks.toml
new file mode 100644
index 00000000..376412bd
--- /dev/null
+++ b/lovely/decks.toml
@@ -0,0 +1,43 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+
+# ---- Oracle Deck
+
+
+# max
+[[patches]]
+[patches.pattern]
+target = 'functions/common_events.lua'
+pattern = '''G.GAME.dollars = G.GAME.dollars + mod'''
+position = 'before'
+payload = '''
+if G.GAME.modifiers.oracle_max then
+ mod = oracle_apply_dollar_cap(mod, G.GAME.dollars, G.GAME.modifiers.oracle_max + G.GAME.interest_cap)
+ if oracle_should_show_max(mod, G.GAME.dollars, G.GAME.modifiers.oracle_max + G.GAME.interest_cap) then
+ text = "MAX"
+ col = G.C.RED
+ end
+end
+'''
+match_indent = true
+times = 1
+
+# show max alert
+# janky since we check the text instead of a condition but hey easily fixed
+[[patches]]
+[patches.pattern]
+target = 'functions/common_events.lua'
+pattern = ''' attention_text({
+ text = text..tostring(math.abs(mod)),'''
+position = 'before'
+payload = '''
+if text == "MAX" then
+ oracle_show_max_alert(dollar_UI)
+ return
+end
+'''
+match_indent = true
+times = 1
\ No newline at end of file
diff --git a/lovely/double_emplace.toml b/lovely/double_emplace.toml
new file mode 100644
index 00000000..a0976a96
--- /dev/null
+++ b/lovely/double_emplace.toml
@@ -0,0 +1,58 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2
+
+[[patches]]
+[patches.pattern]
+target = '''cardarea.lua'''
+pattern = '''function CardArea:emplace(card, location, stay_flipped)'''
+position = "after"
+payload = '''
+for k, v in pairs(self.cards) do
+ if v == card then return end
+end
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = '''cardarea.lua'''
+pattern = ''' if area:is(CardArea) then'''
+position = "after"
+payload = '''
+local prevent = false
+do
+ local _cards = discarded_only and {} or area.cards
+ local card = nil
+ if discarded_only then
+ for k, v in ipairs(area.cards) do
+ if v.ability and v.ability.discarded then
+ _cards[#_cards+1] = v
+ end
+ end
+ end
+ if area.config.type == 'discard' or area.config.type == 'deck' then
+ card = _cards[#_cards]
+ else
+ card = _cards[1]
+ end
+ for k, v in pairs(self.cards) do
+ if v == card then prevent = true; break end
+ end
+end
+if prevent then return end
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = '''functions/common_events.lua'''
+pattern = '''function draw_card(from, to, percent, dir, sort, card, delay, mute, stay_flipped, vol, discarded_only)'''
+position = "after"
+payload = '''
+for k, v in pairs(to.cards) do
+ if v == card then return end
+end
+'''
+match_indent = true
\ No newline at end of file
diff --git a/lovely/end_round.toml b/lovely/end_round.toml
new file mode 100644
index 00000000..ae13e938
--- /dev/null
+++ b/lovely/end_round.toml
@@ -0,0 +1,240 @@
+# Handles all the assorted end_round behaviour in many patches, in an attempt to preserve compatibility where possible
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+# handle duplicate end
+[[patches]]
+[patches.regex]
+target = "functions/state_events.lua"
+pattern = '''function end_round\(\)(?[\s\S]+?)func = function\(\)'''
+position = 'after'
+payload = '''
+if MP.handle_duplicate_end() then
+ return true
+end
+if MP.LOBBY.code then
+ MP.GAME.round_ended = true
+end
+'''
+times = 1
+
+# water is wet
+# the game over code was ripped out from the original function. here? it just shouldn't run, i think
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''-- context.end_of_round calculations'''
+position = 'before'
+payload = '''
+if MP.LOBBY.code then
+ game_over = false
+end
+'''
+match_indent = true
+times = 1
+
+# prevent winning (i don't know if this is needed)
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''if game_over then'''
+position = 'before'
+payload = '''
+if MP.LOBBY.code then
+ game_won = nil
+ G.GAME.won = nil
+end
+'''
+match_indent = true
+times = 1
+
+# handle deck out
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''G.FUNCS.draw_from_discard_to_deck()'''
+position = 'after'
+payload = '''
+MP.handle_deck_out()
+'''
+match_indent = true
+times = 1
+
+## survival mode things
+
+# define local var
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.STATE = G.STATES.ROUND_EVAL
+G.STATE_COMPLETE = false
+'''
+position = 'after'
+payload = '''
+local temp_furthest_blind = 0
+'''
+match_indent = true
+times = 1
+
+# small
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.GAME.round_resets.blind_states.Small = 'Defeated'
+'''
+position = 'after'
+payload = '''
+temp_furthest_blind = G.GAME.round_resets.ante * 10 + 1
+'''
+match_indent = true
+times = 1
+
+# big
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.GAME.round_resets.blind_states.Big = 'Defeated'
+'''
+position = 'after'
+payload = '''
+temp_furthest_blind = G.GAME.round_resets.ante * 10 + 2
+'''
+match_indent = true
+times = 1
+
+# boss
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.GAME.round_resets.blind_states.Boss = 'Defeated'
+'''
+position = 'after'
+payload = '''
+temp_furthest_blind = (G.GAME.round_resets.ante - 1) * 10 + 3
+'''
+match_indent = true
+times = 1
+
+# set furthest blind and pincher index
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+if G.GAME.round_resets.temp_handsize then G.hand:change_size(-G.GAME.round_resets.temp_handsize); G.GAME.round_resets.temp_handsize = nil end
+'''
+position = 'before'
+payload = '''
+if MP.LOBBY.code then
+ MP.GAME.furthest_blind = (temp_furthest_blind > MP.GAME.furthest_blind) and temp_furthest_blind or MP.GAME.furthest_blind
+ MP.ACTIONS.set_furthest_blind(MP.GAME.furthest_blind)
+
+ MP.GAME.pincher_index = MP.GAME.pincher_index + 1
+end
+'''
+match_indent = true
+times = 1
+
+# ease_ante event suppression for compatibility
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''
+function ease_ante(mod)
+'''
+position = 'after'
+payload = '''
+if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then
+ MP.suppress_next_event = true
+end
+'''
+match_indent = true
+times = 1
+
+## showdown mode thing
+
+# spoof nemesis blind so it works fine with the condition later
+# this is bad but uhhhhh it's fine
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.STATE = G.STATES.ROUND_EVAL
+G.STATE_COMPLETE = false
+'''
+position = 'after'
+payload = '''
+local mp_nemesis_spoof = false
+if G.GAME.round_resets.blind == G.P_BLINDS.bl_mp_nemesis then
+ mp_nemesis_spoof = true
+ if G.GAME.blind_on_deck == "Small" then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_small
+ elseif G.GAME.blind_on_deck == "Big" then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_big
+ else
+ mp_nemesis_spoof = false
+ end
+end
+'''
+match_indent = true
+times = 1
+
+# back to normal
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+if G.GAME.round_resets.temp_handsize then G.hand:change_size(-G.GAME.round_resets.temp_handsize); G.GAME.round_resets.temp_handsize = nil end
+'''
+position = 'before'
+payload = '''
+if mp_nemesis_spoof then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_mp_nemesis
+end
+'''
+match_indent = true
+times = 1
+
+# same patches but for a different part of the code (new_round)
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.GAME.round_bonus.discards = 0
+'''
+position = 'after'
+payload = '''
+local mp_nemesis_spoof = false
+if G.GAME.round_resets.blind == G.P_BLINDS.bl_mp_nemesis then
+ mp_nemesis_spoof = true
+ if G.GAME.blind_on_deck == "Small" then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_small
+ elseif G.GAME.blind_on_deck == "Big" then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_big
+ else
+ mp_nemesis_spoof = false
+ end
+end
+'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''
+G.GAME.blind:set_blind(G.GAME.round_resets.blind)
+'''
+position = 'before'
+payload = '''
+if mp_nemesis_spoof then
+ G.GAME.round_resets.blind = G.P_BLINDS.bl_mp_nemesis
+end
+'''
+match_indent = true
+times = 1
\ No newline at end of file
diff --git a/lovely/game.toml b/lovely/game.toml
new file mode 100644
index 00000000..adddae41
--- /dev/null
+++ b/lovely/game.toml
@@ -0,0 +1,202 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.regex]
+target = "game.lua"
+pattern = '''function Game:update_round_eval\(dt\)(?[\s\S]+?)if not G.STATE_COMPLETE then'''
+position = 'at'
+payload = '''function Game:update_round_eval(dt)$pre if not G.STATE_COMPLETE and not MP.GAME.prevent_eval then
+ MP.GAME.prevent_eval = true'''
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = 'functions/state_events.lua'
+pattern = '''dollars = dollars + G.GAME.interest_amount*math.min(math.floor(G.GAME.dollars/5), G.GAME.interest_cap/5)
+ end'''
+position = 'after'
+payload = '''
+ if not MP.GAME.comeback_bonus_given then
+ MP.GAME.comeback_bonus_given = true
+ local comeback_bonus
+
+ if MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" then
+ comeback_bonus = 3 * (G.GAME.round_resets.ante - 1)
+ else
+ if MP.is_major_league_ruleset() then
+ comeback_bonus = 4 * MP.GAME.comeback_bonus
+ else
+ if G.GAME.stake >= 6 then
+ comeback_bonus = 2
+ else
+ comeback_bonus = 4
+ end
+ end
+ end
+
+ add_round_eval_row({
+ bonus = true,
+ name = "comeback",
+ pitch = pitch,
+ dollars = comeback_bonus,
+ })
+ dollars = dollars + comeback_bonus
+ end'''
+match_indent = false
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''elseif config.name == 'hands' then'''
+position = 'before'
+payload = '''elseif config.name == "comeback" then
+ local bonus
+ if MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" then
+ bonus = G.GAME.round_resets.ante - 1
+ else
+ bonus = MP.GAME.comeback_bonus
+ end
+ table.insert(left_text, {
+ n = G.UIT.T,
+ config = {
+ text = bonus,
+ scale = 0.8 * scale,
+ colour = G.C.PURPLE,
+ shadow = true,
+ juice = true,
+ },
+ })
+
+ local comeback_money_key = MP.LOBBY.config.ruleset == "ruleset_mp_sandbox" and "k_comeback_money_sandbox" or "k_total_lives_lost"
+ table.insert(left_text, {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = {
+ localize(comeback_money_key),
+ },
+ colours = { G.C.UI.TEXT_LIGHT },
+ shadow = true,
+ pop_in = 0,
+ scale = 0.4 * scale,
+ silent = true,
+ }),
+ },
+ })'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/state_events.lua"
+pattern = '''if G.GAME.current_round.hands_left > 0 and not G.GAME.modifiers.no_extra_hand_money then'''
+position = 'at'
+payload = '''if G.GAME.current_round.hands_left > 0 and (not G.GAME.modifiers.no_extra_hand_money) and (not MP.is_pvp_boss()) then'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''G.GAME.current_round.hands_left = G.GAME.current_round.hands_left + mod'''
+position = 'after'
+payload = '''if MP.LOBBY.code and MP.is_pvp_boss() and mod > 0 then
+ MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left)
+ end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''function Card:sell_card()'''
+position = 'after'
+payload = '''if MP.LOBBY.code then
+ MP.ACTIONS.sold_joker()
+end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/button_callbacks.lua"
+pattern = '''G.FUNCS.toggle_shop = function(e)'''
+position = 'after'
+payload = '''if MP.LOBBY.code then
+ MP.ACTIONS.spent_last_shop(to_big(MP.GAME.spent_total) - to_big(MP.GAME.spent_before_shop))
+end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''G.GAME.dollars = G.GAME.dollars + mod'''
+position = 'after'
+payload = '''if MP.LOBBY.code and to_big(mod) < to_big(0) then
+ MP.GAME.spent_total = to_big(MP.GAME.spent_total) + (to_big(mod) * to_big(-1))
+end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = 'functions/state_events.lua'
+pattern = '''if G.GAME.chips - G.GAME.blind.chips >= 0 then
+add_round_eval_row({dollars = G.GAME.blind.dollars, name='blind1', pitch = pitch})'''
+position = 'at'
+payload = '''if G.GAME.chips - G.GAME.blind.chips >= 0 or MP.is_pvp_boss() then
+add_round_eval_row({dollars = G.GAME.blind.dollars, name='blind1', pitch = pitch})'''
+match_indent = true
+times = 1
+
+# apply bans here
+# hmm...
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = '''G:save_settings()'''
+position = "before"
+payload = '''
+if not saveTable then -- i am 99% sure this is unnecessary but i'm checking it anyway
+ MP.ApplyBans()
+end
+'''
+match_indent = true
+times = 1
+
+# effectively ban enhancements from grim/familiar/incantation
+# this MIGHT break stuff but my bet is that it doesn't
+# because that would be convenient
+[[patches]]
+[patches.pattern]
+target = '''=[SMODS _ "src/game_object.lua"]'''
+pattern = '''for k, v in pairs(G.P_CENTER_POOLS["Enhanced"]) do'''
+position = "at"
+payload = '''
+for i, v in ipairs(MP.UTILS.get_culled_pool("Enhanced")) do
+ local v = G.P_CENTERS[v]
+'''
+match_indent = true
+times = 3
+
+# Stop game from replacing the multiplayer "ready up" button with "select blind" button
+[[patches]]
+[patches.pattern]
+target = '''functions/button_callbacks.lua'''
+pattern = """_top_button.config.button = 'select_blind'"""
+position = "at"
+payload = '''
+if _top_button.config.button ~= "mp_toggle_ready" then
+ _top_button.config.button = "select_blind"
+end
+'''
+match_indent = true
+times = 1
+
+
+
diff --git a/lovely/hud.toml b/lovely/hud.toml
new file mode 100644
index 00000000..22fbe3f5
--- /dev/null
+++ b/lovely/hud.toml
@@ -0,0 +1,39 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.regex]
+target = "functions/UI_definitions.lua"
+pattern = '''contents\.buttons = \{(?[\s\S]*?)minh = 1\.75(?[\s\S]*?)minh = 1\.75'''
+position = 'at'
+payload = '''contents.buttons = {$pre minh = MP.LOBBY.code and 1.2 or 1.75$between minh = MP.LOBBY.code and 1.2 or 1.75'''
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''{n=G.UIT.T, config={text = localize('b_options'), scale = scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
+ }},
+ }}'''
+position = 'after'
+payload = ''',
+MP.LOBBY.code and {n=G.UIT.R, config={id = 'lobby_info_button', align = "cm", minh = 1.2, minw = 1.5,padding = 0.05, r = 0.1, hover = true, colour = G.C.BLUE, button = "lobby_info", shadow = true}, nodes={
+ {n=G.UIT.R, config={align = "cm", padding = 0, maxw = 1.4}, nodes={
+ {n=G.UIT.T, config={text = localize("ml_lobby_info")[1], scale = 1.2*scale, colour = G.C.UI.TEXT_LIGHT, shadow = true}}
+ }},
+ {n=G.UIT.R, config={align = "cm", padding = 0, maxw = 1.4}, nodes={
+ {n=G.UIT.T, config={text = localize("ml_lobby_info")[2], scale = 1*scale, colour = G.C.UI.TEXT_LIGHT, shadow = true, focus_args = {button = G.F_GUIDE and 'guide' or 'back', orientation = 'bm'}, func = 'set_button_pip'}}
+ }}
+}} or nil'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.regex]
+target = "functions/UI_definitions.lua"
+pattern = '''\{n=G\.UIT\.C, config=\{align = "cm", padding = 0\.05, minw = 1\.45, minh = 1,'''
+position = 'at'
+payload = '''MP.LOBBY.code and (not MP.LOBBY.config.disable_live_and_timer_hud) and MP.UI.timer_hud() or {n=G.UIT.C, config={align = "cm", padding = 0.05, minw = 1.45, minh = 1,'''
+times = 1
diff --git a/lovely/judgement.toml.disabled b/lovely/judgement.toml.disabled
new file mode 100644
index 00000000..3f77e15b
--- /dev/null
+++ b/lovely/judgement.toml.disabled
@@ -0,0 +1,68 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 1
+
+# This file is for various patches required by the Judgement changes in Standard
+
+# define list as game variable
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = "joker_usage = {},"
+position = "before"
+payload = '''
+MP_joker_overrides = {},
+'''
+match_indent = true
+# keeeey
+# thank goodness we can just do this
+# not easy for the stickers though
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = "function create_card(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)"
+position = "after"
+payload = '''
+local sticker_override = false
+G.GAME.MP_joker_overrides = G.GAME.MP_joker_overrides or {}
+if _type == 'Joker'
+and G.GAME.MP_joker_overrides[1]
+and not (forced_key or key_append or _rarity)
+and (area == G.shop_jokers or area == G.pack_cards or G.MP_JUDGEMENT_OVERRIDE)
+then
+ local done = false
+ while not done do
+ if G.GAME.MP_joker_overrides[1] then
+ if not (G.GAME.used_jokers[G.GAME.MP_joker_overrides[1].key] and not next(find_joker("Showman"))) then
+ forced_key = G.GAME.MP_joker_overrides[1].key
+ sticker_override = true
+ done = true
+ else
+ table.remove(G.GAME.MP_joker_overrides, 1)
+ end
+ else
+ done = true
+ end
+ end
+end
+'''
+match_indent = true
+# well it's kinda easy
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = "if (area == G.shop_jokers) or (area == G.pack_cards) then"
+position = "at"
+payload = '''
+if sticker_override then
+ for k, v in pairs(G.GAME.MP_joker_overrides[1].stickers) do -- dumb loop
+ if k == 'eternal' then card:set_eternal(v) end
+ if k == 'perishable' and v == true then card:set_perishable(true) end -- bruh
+ if k == 'rental' then card:set_rental(v) end
+ end
+ table.remove(G.GAME.MP_joker_overrides, 1)
+end
+if ( (area == G.shop_jokers) or (area == G.pack_cards) ) and not sticker_override then
+'''
+match_indent = true
\ No newline at end of file
diff --git a/lovely/misc.toml b/lovely/misc.toml
new file mode 100644
index 00000000..b284c675
--- /dev/null
+++ b/lovely/misc.toml
@@ -0,0 +1,143 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.pattern]
+target = '''=[SMODS _ "src/utils.lua"]'''
+pattern = '''function SMODS.in_scoring(card, scoring_hand)'''
+position = 'after'
+payload = ''' if not scoring_hand then return false end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = '''=[SMODS _ "src/utils.lua"]'''
+pattern = "for _, area in ipairs(SMODS.get_card_areas('playing_cards')) do"
+position = 'after'
+payload = "if not area.cards then goto continue end"
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = '''functions/misc_functions.lua'''
+pattern = "function localize(args, misc_cat)"
+position = 'after'
+payload = '''
+ if not args then return "ERROR" end'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = '''engine/moveable.lua'''
+pattern = "function Moveable:align_to_major()"
+position = 'after'
+payload = '''
+if not self or not self.alignment or not self.role then return end
+if not self.alignment.type or not self.alignment.prev_type then return end
+if not self.alignment.offset or not self.alignment.prev_offset then return end
+if not self.alignment.offset.x or not self.alignment.offset.y then return end
+if not self.alignment.prev_offset.x or not self.alignment.prev_offset.y then return end
+if not self.T then return end
+if not self.Mid or not self.Mid.T or not self.Mid.T.w or not self.Mid.T.h or not self.Mid.T.x or not self.Mid.T.y then return end
+if not self.role.major or not self.role.major.T then return end
+if not self.role.major.T.w or not self.role.major.T.h or not self.role.major.T.x or not self.role.major.T.y then return end
+if not self.T.w or not self.T.h or not self.T.x or not self.T.y then return end
+if not self.role.offset then self.role.offset = {} end'''
+match_indent = true
+times = 1
+
+# remove collection misprint when in multiplayer
+# code restructured from "no peeking" by spad_overolls
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = 'for i = self.ability.extra.min, self.ability.extra.max do'
+position = "before"
+payload = '''
+local mp_collection = self.area.config.type == "title" and MP.LOBBY.code
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = '''{string = 'rand()', colour = G.C.JOKER_GREY},{string = "#@"..(G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.id or 11)..(G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.suit:sub(1,1) or 'D'), colour = G.C.RED},'''
+position = "at"
+payload = '''{string = 'rand()', colour = G.C.JOKER_GREY},{string = "#@"..(mp_collection and 'NOPE' or G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.id or 11)..(mp_collection and '' or G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.suit:sub(1,1) or 'D'), colour = G.C.RED},'''
+match_indent = true
+
+# this seed is passed through a billion functions, and never global scoped
+# just give it to us
+[[patches]]
+[patches.pattern]
+target = "game.lua"
+pattern = 'self.GAME.selected_back_key = selected_back'
+position = "after"
+payload = '''
+G._MP_SET_SEED = args.seed
+'''
+match_indent = true
+
+# relevant only for small world
+[[patches]]
+[patches.pattern]
+target = "back.lua"
+pattern = '''local card = create_card('Tarot', G.consumeables, nil, nil, nil, nil, v, 'deck')'''
+position = "at"
+payload = '''local card = create_card(MP.legacy_smallworld() and 'Tarot' or G.P_CENTERS[v].set, G.consumeables, nil, nil, nil, nil, v, 'deck')'''
+match_indent = true
+
+# i was the one who pred this line, i guess it became a problem
+[[patches]]
+[patches.pattern]
+target = "functions/misc_functions.lua"
+pattern = '''if G.SETTINGS.paused and key ~= 'to_do' then return math.random() end'''
+position = "at"
+payload = '''
+-- disabled by Multiplayer because of voucher rng calls while paused
+-- if G.SETTINGS.paused and key ~= 'to_do' then return math.random() end
+'''
+match_indent = true
+
+# mp_exclude function
+# avoids UNAVAILABLE substitution for rng preservation
+# unfortunate timing but lua doesn't have continue, so this is an uninvasive patch
+[[patches]]
+[patches.pattern]
+target = "functions/common_events.lua"
+pattern = '''
+ _pool[#_pool + 1] = 'UNAVAILABLE'
+end
+'''
+position = "after"
+payload = '''
+if v.mp_include and type(v.mp_include) == 'function' then
+ if not v:mp_include() then
+ table.remove(_pool) -- remove whatever was just done
+ if add and not G.GAME.banned_keys[v.key] then
+ _pool_size = _pool_size - 1
+ end
+ end
+end
+'''
+match_indent = true
+
+# STEAMODDED BUG - hackfix for deck flashing in incorrect position for one frame when switching (caused due to set_sprites drawing jank)
+[[patches]]
+[patches.pattern]
+target = "functions/button_callbacks.lua"
+pattern = "val:set_ability(val.config.center, true)"
+position = 'at'
+payload = '''
+local temp_x, temp_y = val.T.x, val.T.y
+val:set_ability(val.config.center, true)
+val.children.back.VT.x, val.children.back.VT.y = temp_x, temp_y
+val.children.center.VT.x, val.children.center.VT.y = temp_x, temp_y
+'''
+match_indent = true
+times = 1
diff --git a/lovely/pause.toml b/lovely/pause.toml
new file mode 100644
index 00000000..84cb4960
--- /dev/null
+++ b/lovely/pause.toml
@@ -0,0 +1,31 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.pattern]
+target = 'functions/UI_definitions.lua'
+pattern = '''main_menu = UIBox_button{ label = {localize('b_main_menu')}, button = "go_to_menu", minw = 5}'''
+position = 'after'
+payload = '''unstuck_button = UIBox_button{ label = {localize('b_unstuck')}, button = "mp_unstuck", minw = 5}
+return_to_lobby = UIBox_button{ label = {localize('b_return_lobby')}, button = "mp_return_to_lobby", minw = 5}
+leave_lobby = UIBox_button{ label = {localize('b_leave_lobby')}, button = "lobby_leave", minw = 5}'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = 'functions/UI_definitions.lua'
+pattern = '''G.GAME.seeded and current_seed or nil,
+ restart,
+ main_menu,'''
+position = 'at'
+payload = '''(not MP.LOBBY.code and G.GAME.seeded) and current_seed or nil,
+not MP.LOBBY.code and restart or nil,
+not MP.LOBBY.code and main_menu or nil,
+MP.LOBBY.code and unstuck_button or nil,
+MP.LOBBY.code and return_to_lobby or nil,
+MP.LOBBY.code and leave_lobby or nil,'''
+match_indent = true
+times = 1
\ No newline at end of file
diff --git a/lovely/preview.toml b/lovely/preview.toml
new file mode 100644
index 00000000..3e4a5500
--- /dev/null
+++ b/lovely/preview.toml
@@ -0,0 +1,46 @@
+[manifest]
+version = "1.0"
+dump_lua = true
+priority = 0
+
+[[patches]]
+[patches.copy]
+target = "globals.lua"
+position = "append"
+sources = [
+ "compatibility/Preview/InitSimulate.lua",
+ "compatibility/Preview/InitPreview.lua",
+]
+
+[[patches]]
+[patches.copy]
+target = "main.lua"
+position = "append"
+sources = [
+ "compatibility/Preview/CorePreview.lua",
+ "compatibility/Preview/UtilsPreview.lua",
+]
+
+[[patches]]
+[patches.copy]
+target = "functions/common_events.lua"
+position = "append"
+sources = [
+ "compatibility/Preview/EngineSimulate.lua",
+ "compatibility/Preview/UtilsSimulate.lua",
+]
+
+[[patches]]
+[patches.copy]
+target = "card.lua"
+position = "append"
+sources = [
+ "compatibility/Preview/Jokers/_Vanilla.lua",
+ "compatibility/Preview/Jokers/Multiplayer.lua",
+]
+
+[[patches]]
+[patches.copy]
+target = "functions/UI_definitions.lua"
+position = "append"
+sources = ["compatibility/Preview/InterfacePreview.lua"]
diff --git a/lovely/rework-center.toml b/lovely/rework-center.toml
new file mode 100644
index 00000000..b1330b56
--- /dev/null
+++ b/lovely/rework-center.toml
@@ -0,0 +1,43 @@
+# so here's the deal: when we rework vanilla jokers with MP.ReworkCenter,
+# some vanilla behavior still exists on the center. for `calculate`, we can just
+# do an early return to prevent the vanilla logic from running (see bloodstone.lua
+# for an example - it returns `nil, true` to signal "don't run vanilla logic").
+#
+# BUT for `add_to_deck` and `remove_from_deck`, there's no such early return
+# mechanism in smods. the vanilla function just runs after ours no matter what.
+#
+# so we do this slightly cursed thing: we target smods's card.lua patch to wrap the
+# add_to_deck/remove_from_deck calls in an `if` check. if our reworked function
+# returns true, it bails out early and skips the vanilla behavior. if it returns
+# nil/false (or if there's no rework), vanilla runs as normal.
+#
+# priority is set high so we patch after smods does its thing.
+
+[manifest]
+version = "1.0"
+dump_lua = true
+priority = 1333337
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = "obj:add_to_deck(self, from_debuff)"
+position = "at"
+payload = '''
+if obj:add_to_deck(self, from_debuff) then
+ return
+end
+'''
+match_indent = true
+
+[[patches]]
+[patches.pattern]
+target = "card.lua"
+pattern = "obj:remove_from_deck(self, from_debuff)"
+position = "at"
+payload = '''
+if obj:remove_from_deck(self, from_debuff) then
+ return
+end
+'''
+match_indent = true
diff --git a/lovely/shared_area.toml b/lovely/shared_area.toml
new file mode 100644
index 00000000..f142500f
--- /dev/null
+++ b/lovely/shared_area.toml
@@ -0,0 +1,35 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.regex]
+target = 'game.lua'
+pattern = 'self\.jokers = CardArea'
+position = 'before'
+payload = '''if MP.LOBBY.code then
+ MP.shared = CardArea(
+ 0, CAI.consumeable_H + 0.3,
+ CAI.consumeable_W / 2,
+ CAI.consumeable_H,
+ {card_limit = 0, type = 'joker', highlight_limit = 1})
+ elseif MP.shared then
+ MP.shared:remove()
+ MP.shared = nil
+ end
+'''
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = 'functions/common_events.lua'
+pattern = 'G.consumeables.T.y = 0'
+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
+end
+'''
+match_indent = true
+times = 1
\ No newline at end of file
diff --git a/lovely/stakes.toml b/lovely/stakes.toml
new file mode 100644
index 00000000..540e2381
--- /dev/null
+++ b/lovely/stakes.toml
@@ -0,0 +1,49 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+## Plastic Stake - handle reduced interest rate
+
+# if anyone breaks this pattern i'm gonna scream
+[[patches]]
+[patches.pattern]
+target = '''functions/state_events.lua'''
+pattern = '''pitch = pitch + 0.06
+
+if total_cashout_rows > 7 then'''
+position = 'before'
+payload = '''
+if G.GAME.modifiers.mp_modified_interest_rate and G.GAME.dollars >= G.GAME.modifiers.mp_modified_interest_rate and not G.GAME.modifiers.TRUE_no_interest then
+ local interest = G.GAME.modifiers.mp_modified_interest_rate
+ local cap = G.GAME.interest_cap / (5 / interest)
+ add_round_eval_row({bonus = true, name='mp_modified_interest', pitch = pitch, dollars = G.GAME.interest_amount*math.min(math.floor(G.GAME.dollars/interest), cap/interest)})
+ pitch = pitch + 0.06
+ if (not G.GAME.seeded and not G.GAME.challenge) or SMODS.config.seeded_unlocks then
+ if G.GAME.interest_amount*math.min(math.floor(G.GAME.dollars/interest), cap/interest) == G.GAME.interest_amount*G.GAME.interest_cap/interest then
+ G.PROFILES[G.SETTINGS.profile].career_stats.c_round_interest_cap_streak = G.PROFILES[G.SETTINGS.profile].career_stats.c_round_interest_cap_streak + 1
+ else
+ G.PROFILES[G.SETTINGS.profile].career_stats.c_round_interest_cap_streak = 0
+ end
+ end
+ check_for_unlock({type = 'interest_streak'})
+ dollars = dollars + G.GAME.interest_amount*math.min(math.floor(G.GAME.dollars/interest), cap/interest)
+end
+'''
+match_indent = true
+times = 1
+
+# doing all this because there's a random 5 we can't change (as usual)
+# could maybe override but who knows what could happen
+[[patches]]
+[patches.pattern]
+target = '''functions/common_events.lua'''
+pattern = '''elseif config.name == 'hands' then'''
+position = 'before'
+payload = '''
+elseif config.name == 'mp_modified_interest' then
+ table.insert(left_text, {n=G.UIT.T, config={text = num_dollars, scale = 0.8*scale, colour = G.C.MONEY, shadow = true, juice = true}})
+ table.insert(left_text,{n=G.UIT.O, config={object = DynaText({string = {" "..localize{type = 'variable', key = 'interest', vars = {G.GAME.interest_amount, G.GAME.modifiers.mp_modified_interest_rate, G.GAME.interest_amount*G.GAME.interest_cap/5}}}, colours = {G.C.UI.TEXT_LIGHT}, shadow = true, pop_in = 0, scale = 0.4*scale, silent = true})}})
+'''
+match_indent = true
+times = 1
\ No newline at end of file
diff --git a/lovely/view_deck.toml b/lovely/view_deck.toml
new file mode 100644
index 00000000..a58c3e69
--- /dev/null
+++ b/lovely/view_deck.toml
@@ -0,0 +1,29 @@
+[manifest]
+version = "1.0.0"
+dump_lua = true
+priority = 2147483600
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''{
+ label = localize('b_full_deck'),
+ chosen = true,
+ tab_definition_function = G.UIDEF.view_deck
+ },'''
+position = 'after'
+payload = '''MP.LOBBY.code and {
+ label = G.localization.misc.challenge_names[MP.Rulesets[MP.LOBBY.config.ruleset].challenge_deck],
+ tab_definition_function = G.UIDEF.multiplayer_deck,
+ },'''
+match_indent = true
+times = 1
+
+[[patches]]
+[patches.pattern]
+target = "functions/UI_definitions.lua"
+pattern = '''not is_row and {n=G.UIT.R, config={align = "cm", minh = 0.9}, nodes={'''
+position = 'at'
+payload = '''(not (MP.LOBBY.code and G.STAGE == G.STAGES.RUN)) and (not is_row) and {n=G.UIT.R, config={align = "cm", minh = 0.9}, nodes={'''
+match_indent = true
+times = 1
diff --git a/networking-old/action_handlers.lua b/networking-old/action_handlers.lua
new file mode 100644
index 00000000..8961b54a
--- /dev/null
+++ b/networking-old/action_handlers.lua
@@ -0,0 +1,971 @@
+Client = {}
+
+function Client.send(msg)
+ if not (msg == "action:keepAliveAck") then
+ sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER")
+ end
+ love.thread.getChannel("uiToNetwork"):push(msg)
+end
+
+-- Server to Client
+function MP.ACTIONS.set_username(username)
+ MP.LOBBY.username = username or "Guest"
+ if MP.LOBBY.connected then
+ Client.send(
+ string.format(
+ "action:username,username:%s,modHash:%s",
+ MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col,
+ MP.MOD_STRING
+ )
+ )
+ end
+end
+
+function MP.ACTIONS.set_blind_col(num)
+ MP.LOBBY.blind_col = num or 1
+end
+
+local function action_connected()
+ MP.LOBBY.connected = true
+ MP.UI.update_connection_status()
+ Client.send(
+ string.format(
+ "action:username,username:%s,modHash:%s",
+ MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col,
+ MP.MOD_STRING
+ )
+ )
+end
+
+local function action_joinedLobby(code, type)
+ MP.LOBBY.code = code
+ MP.LOBBY.type = type
+ MP.LOBBY.ready_to_start = false
+ MP.ACTIONS.sync_client()
+ MP.ACTIONS.lobby_info()
+ MP.UI.update_connection_status()
+end
+
+local function action_lobbyInfo(host, hostHash, hostCached, guest, guestHash, guestCached, guestReady, is_host)
+ MP.LOBBY.players = {}
+ MP.LOBBY.is_host = is_host == "true"
+ local function parseName(name)
+ local username, col_str = string.match(name, "([^~]+)~(%d+)")
+ username = username or "Guest"
+ local col = tonumber(col_str) or 1
+ col = math.max(1, math.min(col, 25))
+ return username, col
+ end
+ local hostName, hostCol = parseName(host)
+ local hostConfig, hostMods = MP.UTILS.parse_Hash(hostHash)
+ MP.LOBBY.host = {
+ username = hostName,
+ blind_col = hostCol,
+ hash_str = hostMods,
+ hash = hash(hostMods),
+ cached = hostCached == "true",
+ config = hostConfig,
+ }
+
+ if guest ~= nil then
+ local guestName, guestCol = parseName(guest)
+ local guestConfig, guestMods = MP.UTILS.parse_Hash(guestHash)
+ MP.LOBBY.guest = {
+ username = guestName,
+ blind_col = guestCol,
+ hash_str = guestMods,
+ hash = hash(guestMods),
+ cached = guestCached == "true",
+ config = guestConfig,
+ }
+ else
+ MP.LOBBY.guest = {}
+ end
+
+ -- Backwards compatibility for old server, assume guest is ready
+ -- TODO: Remove this once new server gets released
+ guestReady = guestReady or "true"
+
+ -- TODO: This should check for player count instead
+ -- once we enable more than 2 players
+ MP.LOBBY.ready_to_start = guest ~= nil and guestReady == "true"
+
+ if MP.LOBBY.is_host then MP.ACTIONS.lobby_options() end
+
+ if G.STAGE == G.STAGES.MAIN_MENU then MP.ACTIONS.update_player_usernames() end
+end
+
+local function action_error(message)
+ sendWarnMessage(message, "MULTIPLAYER")
+
+ MP.UI.UTILS.overlay_message(message)
+end
+
+local function action_keep_alive()
+ Client.send("action:keepAliveAck")
+end
+
+local function action_disconnected()
+ MP.LOBBY.connected = false
+ if MP.LOBBY.code then MP.LOBBY.code = nil end
+ MP.UI.update_connection_status()
+end
+
+---@param seed string
+---@param stake_str string
+local function action_start_game(seed, stake_str)
+ MP.reset_game_states()
+ local stake = tonumber(stake_str)
+ MP.ACTIONS.set_ante(0)
+ if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then
+ seed = MP.LOBBY.config.custom_seed
+ end
+ G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake })
+ MP.LOBBY.ready_to_start = false
+end
+
+local function begin_pvp_blind()
+ if MP.GAME.next_blind_context then
+ G.FUNCS.select_blind(MP.GAME.next_blind_context)
+ else
+ sendErrorMessage("No next blind context", "MULTIPLAYER")
+ end
+end
+
+local function action_start_blind()
+ MP.GAME.ready_blind = false
+ MP.GAME.timer_started = false
+ MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
+ MP.UI.start_pvp_countdown(begin_pvp_blind)
+end
+
+---@param score_str string
+---@param hands_left_str string
+---@param skips_str string
+local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str)
+ local score = MP.INSANE_INT.from_string(score_str)
+
+ local hands_left = tonumber(hands_left_str)
+ local skips = tonumber(skips_str)
+ local lives = tonumber(lives_str)
+
+ if MP.GAME.enemy.skips ~= skips then
+ for i = 1, skips - MP.GAME.enemy.skips do
+ MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = 0
+ end
+ end
+
+ if score == nil or hands_left == nil then
+ sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER")
+ return
+ end
+
+ if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end
+
+ 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",
+ ease_to = score.coeffiocient,
+ func = function(t)
+ return math.floor(t)
+ end,
+ }))
+
+ G.E_MANAGER:add_event(Event({
+ blockable = false,
+ blocking = false,
+ trigger = "ease",
+ delay = 3,
+ ref_table = MP.GAME.enemy.score,
+ ref_value = "exponent",
+ ease_to = score.exponent,
+ func = function(t)
+ return math.floor(t)
+ end,
+ }))
+
+ MP.GAME.enemy.hands = hands_left
+ MP.GAME.enemy.skips = skips
+ MP.GAME.enemy.lives = lives
+ if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
+end
+
+local function action_stop_game()
+ if G.STAGE ~= G.STAGES.MAIN_MENU then
+ G.FUNCS.go_to_menu()
+ MP.UI.update_connection_status()
+ MP.reset_game_states()
+ end
+end
+
+local function action_end_pvp()
+ MP.GAME.end_pvp = true
+ MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
+ MP.GAME.timer_started = false
+ MP.GAME.ready_blind = false
+end
+
+---@param lives number
+local function action_player_info(lives)
+ if MP.GAME.lives ~= lives then
+ if MP.GAME.lives ~= 0 and MP.LOBBY.config.gold_on_life_loss then
+ MP.GAME.comeback_bonus_given = false
+ MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1
+ end
+ MP.UI.ease_lives(lives - MP.GAME.lives)
+ if MP.LOBBY.config.no_gold_on_round_loss and (G.GAME.blind and G.GAME.blind.dollars) then
+ G.GAME.blind.dollars = 0
+ end
+ end
+ MP.GAME.lives = lives
+end
+
+local function action_win_game()
+ MP.end_game_jokers_payload = ""
+ MP.nemesis_deck_string = ""
+ MP.end_game_jokers_received = false
+ MP.nemesis_deck_received = false
+ MP.GAME.won = true
+ win_game()
+end
+
+local function action_lose_game()
+ MP.end_game_jokers_payload = ""
+ MP.nemesis_deck_string = ""
+ MP.end_game_jokers_received = false
+ MP.nemesis_deck_received = false
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.GAME_OVER
+end
+
+local function action_lobby_options(options)
+ local different_decks_before = MP.LOBBY.config.different_decks
+ for k, v in pairs(options) do
+ if k == "ruleset" then
+ if not MP.Rulesets[v] then
+ G.FUNCS.lobby_leave(nil)
+ MP.UI.UTILS.overlay_message(localize({
+ type = "variable",
+ key = "k_failed_to_join_lobby",
+ vars = { localize("k_ruleset_not_found") },
+ }))
+ return
+ end
+ local disabled = MP.Rulesets[v].is_disabled()
+ if disabled then
+ G.FUNCS.lobby_leave(nil)
+ MP.UI.UTILS.overlay_message(
+ localize({ type = "variable", key = "k_failed_to_join_lobby", vars = { disabled } })
+ )
+ return
+ end
+ MP.LOBBY.config.ruleset = v
+ goto continue
+ end
+ if k == "gamemode" then
+ MP.LOBBY.config.gamemode = v
+ goto continue
+ end
+
+ local parsed_v = v
+ if v == "true" then
+ parsed_v = true
+ elseif v == "false" then
+ parsed_v = false
+ end
+
+ if
+ k == "starting_lives"
+ or k == "pvp_start_round"
+ or k == "timer_base_seconds"
+ or k == "timer_increment_seconds"
+ or k == "showdown_starting_antes"
+ or k == "pvp_countdown_seconds"
+ or k == "timer_forgiveness"
+ then
+ parsed_v = tonumber(v)
+ end
+
+ MP.LOBBY.config[k] = parsed_v
+ if MP.UI.update_lobby_option_toggle then MP.UI.update_lobby_option_toggle(k) end
+ ::continue::
+ end
+ if different_decks_before ~= MP.LOBBY.config.different_decks then
+ G.FUNCS.exit_overlay_menu() -- throw out guest from any menu.
+ end
+ MP.ACTIONS.update_player_usernames() -- render new DECK button state
+end
+
+local function action_send_phantom(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)
+ new_card:set_edition("e_mp_phantom")
+ new_card:add_to_deck()
+ MP.shared:emplace(new_card)
+ G.OVERLAY_MENU = menu
+end
+
+local function action_remove_phantom(key)
+ local card = MP.UTILS.get_phantom_joker(key)
+ if card then
+ card:remove_from_deck()
+ card:start_dissolve({ G.C.RED }, nil, 1.6)
+ MP.shared:remove_card(card)
+ end
+end
+
+-- card:remove is called in an event so we have to hook the function instead of doing normal things
+local cardremove = Card.remove
+function Card:remove()
+ local menu = G.OVERLAY_MENU
+ if self.edition and self.edition.type == "mp_phantom" then G.OVERLAY_MENU = G.OVERLAY_MENU or true end
+ cardremove(self)
+ G.OVERLAY_MENU = menu
+end
+
+-- and smods find card STILL needs to be patched here
+local smodsfindcard = SMODS.find_card
+function SMODS.find_card(key, count_debuffed)
+ local ret = smodsfindcard(key, count_debuffed)
+ local new_ret = {}
+ for i, v in ipairs(ret) do
+ if not v.edition or v.edition.type ~= "mp_phantom" then new_ret[#new_ret + 1] = v end
+ end
+ return new_ret
+end
+
+-- don't poll edition
+local origedpoll = poll_edition
+function poll_edition(_key, _mod, _no_neg, _guaranteed, _options)
+ if G.OVERLAY_MENU then return nil end
+ return origedpoll(_key, _mod, _no_neg, _guaranteed, _options)
+end
+
+local function action_speedrun()
+ SMODS.calculate_context({ mp_speedrun = true })
+end
+
+local function enemyLocation(options)
+ local location = options.location
+ local value = ""
+
+ if string.find(location, "-") then
+ local split = {}
+ for str in string.gmatch(location, "([^-]+)") do
+ table.insert(split, str)
+ end
+ location = split[1]
+ value = split[2]
+ end
+
+ loc_name = localize({ type = "name_text", key = value, set = "Blind" })
+ if loc_name ~= "ERROR" then
+ value = loc_name
+ else
+ value = (G.P_BLINDS[value] and G.P_BLINDS[value].name) or value
+ end
+
+ loc_location = G.localization.misc.dictionary[location]
+
+ if loc_location == nil then
+ if location ~= nil then
+ loc_location = location
+ else
+ loc_location = "Unknown"
+ end
+ end
+
+ MP.GAME.enemy.location = loc_location .. value
+end
+
+local function action_version()
+ MP.ACTIONS.version()
+end
+
+local action_asteroid = 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_sold_joker()
+ -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked
+ MP.GAME.enemy.sells = MP.GAME.enemy.sells + 1
+ MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] = (
+ (MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1
+ )
+end
+
+local function action_lets_go_gambling_nemesis()
+ local card = MP.UTILS.get_phantom_joker("j_mp_lets_go_gambling")
+ if card then card:juice_up() end
+ ease_dollars(card and card.ability and card.ability.extra and card.ability.extra.nemesis_dollars or 5)
+end
+
+local function action_eat_pizza(discards)
+ MP.GAME.pizza_discards = MP.GAME.pizza_discards + discards
+ G.GAME.round_resets.discards = G.GAME.round_resets.discards + discards
+ ease_discard(discards)
+end
+
+local function action_spent_last_shop(amount)
+ MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = tonumber(amount)
+end
+
+local function action_magnet()
+ 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
+ end
+
+ if card then
+ local candidates = {}
+ for _, v in pairs(G.jokers.cards) do
+ if v.sell_cost == card.sell_cost then table.insert(candidates, v) end
+ end
+
+ -- Scale the pseudo from 0 - 1 to the number of candidates
+ local random_index = math.floor(pseudorandom("j_mp_magnet") * #candidates) + 1
+ local chosen_card = candidates[random_index]
+ sendTraceMessage(
+ string.format("Sending magnet joker: %s", MP.UTILS.joker_to_string(chosen_card)),
+ "MULTIPLAYER"
+ )
+
+ local card_save = chosen_card:save()
+ local card_encoded = MP.UTILS.str_pack_and_encode(card_save)
+ MP.ACTIONS.magnet_response(card_encoded)
+ end
+end
+
+local function action_magnet_response(key)
+ local card_save, success, err
+
+ card_save, err = MP.UTILS.str_decode_and_unpack(key)
+ if not card_save then
+ sendDebugMessage(string.format("Failed to unpack magnet joker: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ local card =
+ Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base)
+ -- Avoid crashing if the load function ends up indexing a nil value
+ success, err = pcall(card.load, card, card_save)
+ if not success then
+ sendDebugMessage(string.format("Failed to load magnet joker: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ -- BALATRO BUG (version 1.0.1o): `card.VT.h` is mistakenly set to nil after calling `card:load()`
+ -- Without this call to `card:hard_set_VT()`, the game will crash later when the card is drawn
+ card:hard_set_VT()
+
+ -- Enforce "add to deck" effects (e.g. increase hand size effects)
+ card.added_to_deck = nil
+
+ card:add_to_deck()
+ G.jokers:emplace(card)
+ sendTraceMessage(string.format("Received magnet joker: %s", MP.UTILS.joker_to_string(card)), "MULTIPLAYER")
+end
+
+function G.FUNCS.load_end_game_jokers()
+ local card_area_save, success, err
+
+ if not MP.end_game_jokers or not MP.end_game_jokers_payload then return end
+
+ card_area_save, err = MP.UTILS.str_decode_and_unpack(MP.end_game_jokers_payload)
+ if not card_area_save then
+ sendDebugMessage(string.format("Failed to unpack enemy jokers: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ -- Avoid crashing if the load function ends up indexing a nil value
+ success, err = pcall(MP.end_game_jokers.load, MP.end_game_jokers, card_area_save)
+ if not success then
+ sendDebugMessage(string.format("Failed to load enemy jokers: %s", err), "MULTIPLAYER")
+ -- Reset the card area if loading fails to avoid inconsistent state
+ MP.end_game_jokers:remove()
+ MP.end_game_jokers:init(
+ ---@diagnostic disable-next-line: param-type-mismatch
+ 0,
+ 0,
+ 5 * G.CARD_W,
+ G.CARD_H,
+ { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1 }
+ )
+ return
+ end
+
+ -- Log the jokers
+ if MP.end_game_jokers.cards then
+ local jokers_str = ""
+ for _, card in pairs(MP.end_game_jokers.cards) do
+ jokers_str = jokers_str .. ";" .. MP.UTILS.joker_to_string(card)
+ end
+ sendTraceMessage(string.format("Received end game jokers: %s", jokers_str), "MULTIPLAYER")
+ end
+end
+
+local function action_receive_end_game_jokers(keys)
+ MP.end_game_jokers_payload = keys
+ MP.end_game_jokers_received = true
+ G.FUNCS.load_end_game_jokers()
+end
+
+local function action_get_end_game_jokers()
+ if not G.jokers or not G.jokers.cards then
+ Client.send("action:receiveEndGameJokers,keys:")
+ return
+ end
+
+ -- Log the jokers
+ local jokers_str = ""
+ for _, card in pairs(G.jokers.cards) do
+ jokers_str = jokers_str .. ";" .. MP.UTILS.joker_to_string(card)
+ end
+ sendTraceMessage(string.format("Sending end game jokers: %s", jokers_str), "MULTIPLAYER")
+
+ local jokers_save = G.jokers:save()
+ local jokers_encoded = MP.UTILS.str_pack_and_encode(jokers_save)
+
+ Client.send(string.format("action:receiveEndGameJokers,keys:%s", jokers_encoded))
+end
+
+local function action_get_nemesis_deck()
+ local deck_str = ""
+ for _, card in ipairs(G.playing_cards) do
+ deck_str = deck_str .. ";" .. MP.UTILS.card_to_string(card)
+ end
+ Client.send(string.format("action:receiveNemesisDeck,cards:%s", deck_str))
+end
+
+local function action_send_game_stats()
+ if not MP.GAME.stats then
+ Client.send("action:nemesisEndGameStats")
+ return
+ end
+
+ local stats_str = string.format(
+ "reroll_count:%d,reroll_cost_total:%d",
+ MP.GAME.stats.reroll_count,
+ MP.GAME.stats.reroll_cost_total
+ )
+
+ -- Extract voucher keys where value is true and join them with a dash
+ local voucher_keys = ""
+ if G.GAME.used_vouchers then
+ local keys = {}
+ for k, v in pairs(G.GAME.used_vouchers) do
+ if v == true then table.insert(keys, k) end
+ end
+ voucher_keys = table.concat(keys, "-")
+ end
+
+ -- Add voucher keys to stats string
+ if voucher_keys ~= "" then stats_str = stats_str .. string.format(",vouchers:%s", voucher_keys) end
+
+ Client.send(string.format("action:nemesisEndGameStats,%s", stats_str))
+end
+
+function G.FUNCS.load_nemesis_deck()
+ if not MP.nemesis_deck_string or not MP.nemesis_deck or not MP.nemesis_cards or not MP.LOBBY.code then return end
+
+ local card_strings = MP.UTILS.string_split(MP.nemesis_deck_string, ";")
+
+ for k, _ in pairs(MP.nemesis_cards) do
+ MP.nemesis_cards[k] = nil
+ end
+
+ for _, card_str in pairs(card_strings) do
+ if card_str == "" then goto continue end
+
+ local card_params = MP.UTILS.string_split(card_str, "-")
+
+ local suit = card_params[1]
+ local rank = card_params[2]
+ local enhancement = card_params[3]
+ local edition = card_params[4]
+ local seal = card_params[5]
+
+ -- Validate the card parameters
+ -- If invalid suit or rank, skip the card
+ -- If invalid enhancement, edition, or seal, fallback to "none"
+ local front_key = tostring(suit) .. "_" .. tostring(rank)
+ if not G.P_CARDS[front_key] then
+ sendDebugMessage(string.format("Invalid playing card key: %s", front_key), "MULTIPLAYER")
+ goto continue
+ end
+ if not enhancement or (enhancement ~= "none" and not G.P_CENTERS[enhancement]) then
+ sendDebugMessage(string.format("Invalid enhancement: %s", enhancement), "MULTIPLAYER")
+ enhancement = "none"
+ end
+ if not edition or (edition ~= "none" and not G.P_CENTERS["e_" .. edition]) then
+ sendDebugMessage(string.format("Invalid edition: %s", edition), "MULTIPLAYER")
+ edition = "none"
+ end
+ if not seal or (seal ~= "none" and not G.P_SEALS[seal]) then
+ sendDebugMessage(string.format("Invalid seal: %s", seal), "MULTIPLAYER")
+ seal = "none"
+ end
+
+ -- Create the card
+ local card = create_playing_card({
+ front = G.P_CARDS[front_key],
+ center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil,
+ }, MP.nemesis_deck, true, true, nil, false)
+ if edition ~= "none" then card:set_edition({ [edition] = true }, true, true) end
+ if seal ~= "none" then card:set_seal(seal, true, true) end
+
+ -- Remove the card from G.playing_cards and insert into MP.nemesis_cards
+ table.remove(G.playing_cards, #G.playing_cards)
+ table.insert(MP.nemesis_cards, card)
+
+ ::continue::
+ end
+end
+
+local function action_receive_nemesis_deck(deck_str)
+ MP.nemesis_deck_string = deck_str
+ MP.nemesis_deck_received = true
+ G.FUNCS.load_nemesis_deck()
+end
+
+local function action_start_ante_timer(time)
+ if type(time) == "string" then time = tonumber(time) end
+ MP.GAME.timer = time
+ MP.GAME.timer_started = true
+ G.E_MANAGER:add_event(MP.timer_event)
+end
+
+local function action_pause_ante_timer(time)
+ if type(time) == "string" then time = tonumber(time) end
+ MP.GAME.timer = time
+ MP.GAME.timer_started = false
+end
+
+-- #region Client to Server
+function MP.ACTIONS.create_lobby(gamemode)
+ Client.send(string.format("action:createLobby,gameMode:%s", gamemode))
+end
+
+function MP.ACTIONS.join_lobby(code)
+ Client.send(string.format("action:joinLobby,code:%s", code))
+end
+
+function MP.ACTIONS.ready_lobby()
+ Client.send("action:readyLobby")
+end
+
+function MP.ACTIONS.unready_lobby()
+ Client.send("action:unreadyLobby")
+end
+
+function MP.ACTIONS.lobby_info()
+ Client.send("action:lobbyInfo")
+end
+
+function MP.ACTIONS.leave_lobby()
+ Client.send("action:leaveLobby")
+end
+
+function MP.ACTIONS.start_game()
+ Client.send("action:startGame")
+end
+
+function MP.ACTIONS.ready_blind(e)
+ MP.GAME.next_blind_context = e
+ Client.send("action:readyBlind")
+end
+
+function MP.ACTIONS.unready_blind()
+ Client.send("action:unreadyBlind")
+end
+
+function MP.ACTIONS.stop_game()
+ Client.send("action:stopGame")
+end
+
+function MP.ACTIONS.fail_round(hands_used)
+ if MP.LOBBY.config.no_gold_on_round_loss then G.GAME.blind.dollars = 0 end
+ if hands_used == 0 then return end
+ Client.send("action:failRound")
+end
+
+function MP.ACTIONS.version()
+ Client.send(string.format("action:version,version:%s", MULTIPLAYER_VERSION))
+end
+
+function MP.ACTIONS.set_location(location)
+ if MP.GAME.location == location then return end
+ MP.GAME.location = location
+ Client.send(string.format("action:setLocation,location:%s", location))
+end
+
+---@param score number
+---@param hands_left number
+function MP.ACTIONS.play_hand(score, hands_left)
+ local fixed_score = tostring(to_big(score))
+ -- Credit to sidmeierscivilizationv on discord for this fix for Talisman
+ if string.match(fixed_score, "[eE]") == nil and string.match(fixed_score, "[.]") then
+ -- Remove decimal from non-exponential numbers
+ fixed_score = string.sub(string.gsub(fixed_score, "%.", ","), 1, -3)
+ end
+ fixed_score = string.gsub(fixed_score, ",", "") -- Remove commas
+
+ local insane_int_score = MP.INSANE_INT.from_string(fixed_score)
+ if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.highest_score) then
+ MP.GAME.highest_score = insane_int_score
+ end
+ Client.send(string.format("action:playHand,score:" .. fixed_score .. ",handsLeft:%d", hands_left))
+end
+
+function MP.ACTIONS.lobby_options()
+ local msg = "action:lobbyOptions"
+ for k, v in pairs(MP.LOBBY.config) do
+ msg = msg .. string.format(",%s:%s", k, tostring(v))
+ end
+ Client.send(msg)
+end
+
+function MP.ACTIONS.set_ante(ante)
+ Client.send(string.format("action:setAnte,ante:%d", ante))
+end
+
+function MP.ACTIONS.new_round()
+ MP.GAME.duplicate_end = false
+ MP.GAME.round_ended = false
+ Client.send("action:newRound")
+end
+
+function MP.ACTIONS.set_furthest_blind(furthest_blind)
+ Client.send(string.format("action:setFurthestBlind,furthestBlind:%d", furthest_blind))
+end
+
+function MP.ACTIONS.skip(skips)
+ Client.send("action:skip,skips:" .. tostring(skips))
+end
+
+function MP.ACTIONS.send_phantom(key)
+ Client.send("action:sendPhantom,key:" .. key)
+end
+
+function MP.ACTIONS.remove_phantom(key)
+ Client.send("action:removePhantom,key:" .. key)
+end
+
+function MP.ACTIONS.asteroid()
+ Client.send("action:asteroid")
+end
+
+function MP.ACTIONS.sold_joker()
+ Client.send("action:soldJoker")
+end
+
+function MP.ACTIONS.lets_go_gambling_nemesis()
+ Client.send("action:letsGoGamblingNemesis")
+end
+
+function MP.ACTIONS.eat_pizza(discards)
+ Client.send("action:eatPizza,whole:" .. tostring(discards))
+end
+
+function MP.ACTIONS.spent_last_shop(amount)
+ Client.send("action:spentLastShop,amount:" .. tostring(amount))
+end
+
+function MP.ACTIONS.magnet()
+ Client.send("action:magnet")
+end
+
+function MP.ACTIONS.magnet_response(key)
+ Client.send("action:magnetResponse,key:" .. key)
+end
+
+function MP.ACTIONS.get_end_game_jokers()
+ Client.send("action:getEndGameJokers")
+end
+
+function MP.ACTIONS.get_nemesis_deck()
+ Client.send("action:getNemesisDeck")
+end
+
+function MP.ACTIONS.send_game_stats()
+ Client.send("action:sendGameStats")
+ action_send_game_stats()
+end
+
+function MP.ACTIONS.request_nemesis_stats()
+ Client.send("action:endGameStatsRequested")
+end
+
+function MP.ACTIONS.start_ante_timer()
+ Client.send("action:startAnteTimer,time:" .. tostring(MP.GAME.timer))
+ action_start_ante_timer(MP.GAME.timer)
+end
+
+function MP.ACTIONS.pause_ante_timer()
+ Client.send("action:pauseAnteTimer,time:" .. tostring(MP.GAME.timer))
+ action_pause_ante_timer(MP.GAME.timer) -- TODO
+end
+
+function MP.ACTIONS.fail_timer()
+ Client.send("action:failTimer")
+end
+
+function MP.ACTIONS.sync_client()
+ Client.send("action:syncClient,isCached:" .. tostring(_RELEASE_MODE))
+end
+
+-- #endregion Client to Server
+
+-- Utils
+function MP.ACTIONS.connect()
+ Client.send("connect")
+end
+
+function MP.ACTIONS.update_player_usernames()
+ if MP.LOBBY.code then
+ if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end
+ set_main_menu_UI()
+ end
+end
+
+local function string_to_table(str)
+ local tbl = {}
+ for part in string.gmatch(str, "([^,]+)") do
+ local key, value = string.match(part, "([^:]+):(.+)")
+ if key and value then tbl[key] = value end
+ end
+ return tbl
+end
+
+local last_game_seed = nil
+
+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()
+ if msg then
+ local parsedAction = string_to_table(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
+
+ if parsedAction.action == "connected" then
+ action_connected()
+ elseif parsedAction.action == "version" then
+ action_version()
+ elseif parsedAction.action == "disconnected" then
+ action_disconnected()
+ elseif parsedAction.action == "joinedLobby" then
+ action_joinedLobby(parsedAction.code, parsedAction.type)
+ elseif parsedAction.action == "lobbyInfo" then
+ action_lobbyInfo(
+ parsedAction.host,
+ parsedAction.hostHash,
+ parsedAction.hostCached,
+ parsedAction.guest,
+ parsedAction.guestHash,
+ parsedAction.guestCached,
+ parsedAction.guestReady,
+ parsedAction.isHost
+ )
+ elseif parsedAction.action == "startGame" then
+ action_start_game(parsedAction.seed, parsedAction.stake)
+ elseif parsedAction.action == "startBlind" then
+ action_start_blind()
+ elseif parsedAction.action == "enemyInfo" then
+ action_enemy_info(parsedAction.score, parsedAction.handsLeft, parsedAction.skips, parsedAction.lives)
+ elseif parsedAction.action == "stopGame" then
+ action_stop_game()
+ elseif parsedAction.action == "endPvP" then
+ action_end_pvp()
+ elseif parsedAction.action == "playerInfo" then
+ action_player_info(parsedAction.lives)
+ elseif parsedAction.action == "winGame" then
+ action_win_game()
+ elseif parsedAction.action == "loseGame" then
+ action_lose_game()
+ elseif parsedAction.action == "lobbyOptions" then
+ action_lobby_options(parsedAction)
+ elseif parsedAction.action == "enemyLocation" then
+ enemyLocation(parsedAction)
+ elseif parsedAction.action == "sendPhantom" then
+ action_send_phantom(parsedAction.key)
+ elseif parsedAction.action == "removePhantom" then
+ action_remove_phantom(parsedAction.key)
+ elseif parsedAction.action == "speedrun" then
+ action_speedrun()
+ elseif parsedAction.action == "asteroid" then
+ action_asteroid()
+ elseif parsedAction.action == "soldJoker" then
+ action_sold_joker()
+ elseif parsedAction.action == "letsGoGamblingNemesis" then
+ action_lets_go_gambling_nemesis()
+ elseif parsedAction.action == "eatPizza" then
+ action_eat_pizza(parsedAction.whole) -- rename to "discards" when possible
+ elseif parsedAction.action == "spentLastShop" then
+ action_spent_last_shop(parsedAction.amount)
+ elseif parsedAction.action == "magnet" then
+ action_magnet()
+ elseif parsedAction.action == "magnetResponse" then
+ action_magnet_response(parsedAction.key)
+ elseif parsedAction.action == "getEndGameJokers" then
+ action_get_end_game_jokers()
+ elseif parsedAction.action == "receiveEndGameJokers" then
+ action_receive_end_game_jokers(parsedAction.keys)
+ elseif parsedAction.action == "getNemesisDeck" then
+ action_get_nemesis_deck()
+ elseif parsedAction.action == "receiveNemesisDeck" then
+ action_receive_nemesis_deck(parsedAction.cards)
+ elseif parsedAction.action == "endGameStatsRequested" then
+ action_send_game_stats()
+ elseif parsedAction.action == "nemesisEndGameStats" then
+ -- Handle receiving game stats (is only logged now, now shown in the ui)
+ elseif parsedAction.action == "startAnteTimer" then
+ action_start_ante_timer(parsedAction.time)
+ elseif parsedAction.action == "pauseAnteTimer" then
+ action_pause_ante_timer(parsedAction.time)
+ elseif parsedAction.action == "error" then
+ action_error(parsedAction.message)
+ elseif parsedAction.action == "keepAlive" then
+ action_keep_alive()
+ end
+ end
+ until not msg
+end
diff --git a/networking-old/socket.lua b/networking-old/socket.lua
new file mode 100644
index 00000000..62d7cc6c
--- /dev/null
+++ b/networking-old/socket.lua
@@ -0,0 +1,186 @@
+-- Code for networking stuff that runs in a separate thread
+
+-- Since threads run on a separate lua environment, we need to require
+-- the necessary modules again
+return [[
+local CONFIG_URL, CONFIG_PORT = ...
+
+require("love.filesystem")
+local socket = require("socket")
+
+local DEBUGGING = false
+
+-- Defining this again, for debugging this thread
+local function initializeThreadDebugSocketConnection()
+ CLIENT = socket.connect("localhost", 12346)
+ if not CLIENT then
+ sendWarnMessage("Failed to connect to the debug server", "MULTIPLAYER")
+ end
+end
+
+function SEND_THREAD_DEBUG_MESSAGE(message)
+ if DEBUGGING and CLIENT and message then
+ CLIENT:send(message .. "\n")
+ end
+end
+
+if DEBUGGING then
+ initializeThreadDebugSocketConnection()
+end
+
+Networking = {}
+local isSocketClosed = true
+local networkToUiChannel = love.thread.getChannel("networkToUi")
+local uiToNetworkChannel = love.thread.getChannel("uiToNetwork")
+
+function Networking.connect()
+ -- TODO: Check first if Networking.Client is not null
+ -- and if it is, skip this function
+
+ SEND_THREAD_DEBUG_MESSAGE(
+ string.format("Attempting to connect to multiplayer server... URL: %s, PORT: %d", CONFIG_URL, CONFIG_PORT)
+ )
+
+ Networking.Client = socket.tcp()
+ -- Allow for 10 seconds to reconnect
+ Networking.Client:settimeout(10)
+
+ Networking.Client:setoption("tcp-nodelay", true)
+ local connectionResult, errorMessage = Networking.Client:connect(CONFIG_URL, CONFIG_PORT) -- Not sure if I want to make these values public yet
+
+ if connectionResult ~= 1 then
+ SEND_THREAD_DEBUG_MESSAGE(string.format("%s", errorMessage))
+ networkToUiChannel:push("action:error,message:Failed to connect to multiplayer server")
+ else
+ isSocketClosed = false
+ end
+
+ Networking.Client:settimeout(0)
+end
+
+-- Check for messages from the main thread
+local mainThreadMessageQueue = function()
+ -- Executes a max of requestsPerCycle action requests
+ -- from the main thread and then yields
+ local requestsPerCycle = 25
+ while true do
+ for _ = 1, requestsPerCycle do
+ local msg = uiToNetworkChannel:pop()
+ if msg then
+ if msg:find("^action") ~= nil then
+ Networking.Client:send(msg .. "\n")
+ elseif msg == "connect" then
+ Networking.connect()
+ end
+ else
+ -- If there are no more messages, yield
+ coroutine.yield()
+ end
+ end
+
+ coroutine.yield()
+ end
+end
+local mainThreadCoroutine = coroutine.create(mainThreadMessageQueue)
+
+local timer = function(time)
+ local init = os.time()
+ local diff = os.difftime(os.time(), init)
+ while diff < time do
+ coroutine.yield(diff)
+ diff = os.difftime(os.time(), init)
+ end
+end
+local timerCoroutine = coroutine.create(timer)
+
+-- All values are in seconds
+local keepAliveInitialTimeout = 7
+local keepAliveRetryTimeout = 3
+local keepAliveRetryCount = 3
+
+local isRetry = false
+local retryCount = 0
+
+-- Check for network packets
+local networkPacketQueue = function()
+ local packetsPerCycle = 25
+ while true do
+ if Networking.Client then
+ -- Tries to fetch a packet a max of packetsPerCycle times
+ -- and then yields
+ for _ = 1, packetsPerCycle do
+ local data, error, partial = Networking.Client:receive()
+ if data then
+ -- Packet arrived, reset retries
+ isRetry = false
+ retryCount = 0
+ -- Also reset timer
+ timerCoroutine = coroutine.create(timer)
+
+ -- For now, we just send the string as is to the main thread
+ networkToUiChannel:push(data)
+ elseif error == "close" then
+ -- Handle connection closed gracefully
+ isSocketClosed = true
+ retryCount = 0
+ isRetry = false
+
+ timerCoroutine = coroutine.create(timer)
+ networkToUiChannel:push("action:disconnected")
+ else
+ -- If there are no more packets, yield
+ coroutine.yield()
+ end
+ end
+
+ coroutine.yield()
+ end
+
+ coroutine.yield()
+ end
+end
+local networkCoroutine = coroutine.create(networkPacketQueue)
+
+-- Checks for network packets,
+-- then sends them to the main thread
+-- then advances timers
+-- and then sleeps
+while true do
+ coroutine.resume(mainThreadCoroutine)
+ coroutine.resume(networkCoroutine)
+
+ -- Run Timer
+ if not isSocketClosed and coroutine.status(timerCoroutine) ~= "dead" then
+ coroutine.resume(timerCoroutine, keepAliveInitialTimeout)
+ elseif not isSocketClosed then
+ -- Timer triggered
+ isRetry = true
+
+ if retryCount > keepAliveRetryCount then
+ Networking.Client:close()
+
+ -- Connection closed, restart everything
+ isSocketClosed = true
+ retryCount = 0
+ isRetry = false
+
+ timerCoroutine = coroutine.create(timer)
+
+ networkToUiChannel:push("action:disconnected")
+ end
+
+ if isRetry then
+ retryCount = retryCount + 1
+ -- Send keepAlive without cutting the line
+ uiToNetworkChannel:push("action:keepAlive")
+
+ -- Restart the timer
+ timerCoroutine = coroutine.create(timer)
+ coroutine.resume(timerCoroutine, keepAliveRetryTimeout)
+ end
+ end
+
+ -- Sleeps for 200 milliseconds
+ socket.sleep(0.2)
+end
+]]
diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua
new file mode 100644
index 00000000..3f52ad00
--- /dev/null
+++ b/networking/action_handlers.lua
@@ -0,0 +1,1303 @@
+local json = require("json")
+
+Client = {}
+
+function Client.send(msg)
+ msg = json.encode(msg)
+ if msg ~= '{"action":"keepAliveAck"}' then
+ sendTraceMessage(string.format("Client sent message: %s", msg), "MULTIPLAYER")
+ end
+ love.thread.getChannel("uiToNetwork"):push(msg)
+end
+
+-- Server to Client
+function MP.ACTIONS.set_username(username)
+ MP.LOBBY.username = username or "Guest"
+ if MP.LOBBY.connected then
+ Client.send({
+ action = "username",
+ username = MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col,
+ modHash = MP.MOD_STRING,
+ })
+ end
+end
+
+function MP.ACTIONS.set_blind_col(num)
+ MP.LOBBY.blind_col = num or 1
+end
+
+-- Reconnection state (persists across connections)
+local reconnectToken = nil
+local lastLobbyCode = nil
+
+local function action_connected()
+ MP.LOBBY.connected = true
+ MP.UI.update_connection_status()
+ Client.send({
+ action = "username",
+ username = MP.LOBBY.username .. "~" .. MP.LOBBY.blind_col,
+ modHash = MP.MOD_STRING,
+ })
+
+ -- If we have reconnect info, attempt to rejoin the lobby
+ if reconnectToken and lastLobbyCode then
+ Client.send({
+ action = "rejoinLobby",
+ code = lastLobbyCode,
+ reconnectToken = reconnectToken,
+ })
+ end
+end
+
+local function action_joinedLobby(code, type, token)
+ MP.LOBBY.code = code
+ MP.LOBBY.type = type
+ MP.LOBBY.ready_to_start = false
+ -- Store reconnect info for potential future reconnection
+ if token then reconnectToken = token end
+ lastLobbyCode = code
+ MP.ACTIONS.sync_client()
+ MP.ACTIONS.lobby_info()
+ MP.UI.update_connection_status()
+end
+
+local function action_rejoinedLobby(code, type, token)
+ MP.LOBBY.code = code
+ MP.LOBBY.type = type
+ -- Update reconnect token
+ reconnectToken = token
+ lastLobbyCode = code
+ MP.self_reconnect_countdown = nil
+ MP.ACTIONS.sync_client()
+ MP.ACTIONS.lobby_info()
+ MP.UI.update_connection_status()
+ sendWarnMessage("Reconnected to lobby!", "MULTIPLAYER")
+ G.FUNCS.exit_overlay_menu()
+ MP.UI.UTILS.overlay_message("Reconnected to lobby!")
+end
+
+-- Countdown state for disconnect overlays
+MP.enemy_disconnect_countdown = nil
+MP.self_reconnect_countdown = nil
+
+-- Shared timeout handler for both countdowns
+local function handle_reconnect_timeout(message)
+ G.FUNCS.exit_overlay_menu()
+ MP.LOBBY.connected = false
+ if MP.LOBBY.code then MP.LOBBY.code = nil end
+ reconnectToken = nil
+ lastLobbyCode = nil
+ MP.UI.update_connection_status()
+ if G.STAGE ~= G.STAGES.MAIN_MENU then
+ MP.reset_game_states()
+ G.FUNCS.go_to_menu()
+ end
+ MP.UI.UTILS.overlay_message(message)
+end
+
+-- Hook into Game.update to tick countdown displays
+local _disconnect_gupdate = Game.update
+function Game:update(dt)
+ if MP.enemy_disconnect_countdown then
+ local remaining = math.max(0, math.ceil(MP.enemy_disconnect_countdown.end_time - love.timer.getTime()))
+ MP.enemy_disconnect_countdown.display = remaining .. "s remaining"
+ -- No client-side timeout needed: the server sends stopGame
+ -- when the grace period expires, which handles the cleanup
+ end
+ if MP.self_reconnect_countdown then
+ local remaining = math.max(0, math.ceil(MP.self_reconnect_countdown.end_time - love.timer.getTime()))
+ MP.self_reconnect_countdown.display = remaining .. "s remaining"
+ if remaining <= 0 then
+ MP.self_reconnect_countdown = nil
+ handle_reconnect_timeout("Reconnection failed.\nReturning to main menu.")
+ end
+ end
+ return _disconnect_gupdate(self, dt)
+end
+
+local function action_enemyDisconnected(timeout)
+ timeout = timeout or 60
+ sendWarnMessage("Opponent disconnected, waiting for reconnection...", "MULTIPLAYER")
+
+ MP.enemy_disconnect_countdown = {
+ end_time = love.timer.getTime() + timeout,
+ display = timeout .. "s remaining",
+ }
+
+ MP.UI.UTILS.overlay_message_countdown(
+ "Opponent disconnected,\nwaiting for reconnection...",
+ MP.enemy_disconnect_countdown,
+ true
+ )
+end
+
+local function action_enemyReconnected()
+ MP.enemy_disconnect_countdown = nil
+ sendWarnMessage("Opponent reconnected!", "MULTIPLAYER")
+ G.FUNCS.exit_overlay_menu()
+ MP.UI.UTILS.overlay_message("Opponent reconnected!")
+end
+
+local function action_lobbyInfo(host, hostHash, hostCached, guest, guestHash, guestCached, guestReady, is_host)
+ MP.LOBBY.players = {}
+ MP.LOBBY.is_host = is_host
+ local function parseName(name)
+ local username, col_str = string.match(name, "([^~]+)~(%d+)")
+ username = username or "Guest"
+ local col = tonumber(col_str) or 1
+ col = math.max(1, math.min(col, 25))
+ return username, col
+ end
+ local hostName, hostCol = parseName(host)
+ local hostConfig, hostMods = MP.UTILS.parse_Hash(hostHash)
+ MP.LOBBY.host = {
+ username = hostName,
+ blind_col = hostCol,
+ hash_str = hostMods,
+ hash = hash(hostMods),
+ cached = hostCached,
+ config = hostConfig,
+ }
+
+ if guest ~= nil then
+ local guestName, guestCol = parseName(guest)
+ local guestConfig, guestMods = MP.UTILS.parse_Hash(guestHash)
+ MP.LOBBY.guest = {
+ username = guestName,
+ blind_col = guestCol,
+ hash_str = guestMods,
+ hash = hash(guestMods),
+ cached = guestCached,
+ config = guestConfig,
+ }
+ else
+ MP.LOBBY.guest = {}
+ end
+
+ -- TODO: This should check for player count instead
+ -- once we enable more than 2 players
+ MP.LOBBY.ready_to_start = guest ~= nil and guestReady
+
+ if MP.LOBBY.is_host then MP.ACTIONS.lobby_options() end
+
+ if G.STAGE == G.STAGES.MAIN_MENU then MP.ACTIONS.update_player_usernames() end
+end
+
+local function action_error(message)
+ sendWarnMessage(message, "MULTIPLAYER")
+
+ MP.UI.UTILS.overlay_message(message)
+end
+
+local function action_keep_alive()
+ Client.send({
+ action = "keepAliveAck",
+ })
+end
+
+local function action_disconnected()
+ MP.LOBBY.connected = false
+ MP.self_reconnect_countdown = nil
+ if MP.LOBBY.code then MP.LOBBY.code = nil end
+ -- Clear reconnect state since all reconnection attempts failed
+ reconnectToken = nil
+ lastLobbyCode = nil
+ MP.UI.update_connection_status()
+end
+
+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.UI.update_connection_status()
+ sendWarnMessage("Connection lost, attempting to reconnect...", "MULTIPLAYER")
+
+ MP.self_reconnect_countdown = {
+ end_time = love.timer.getTime() + 60,
+ display = "60s remaining",
+ }
+
+ MP.UI.UTILS.overlay_message_countdown(
+ "Connection lost,\nattempting to reconnect...",
+ MP.self_reconnect_countdown,
+ true
+ )
+ end
+end
+
+---@param seed string
+---@param stake_str string
+local function action_start_game(seed, stake_str)
+ MP.reset_game_states()
+ local stake = tonumber(stake_str)
+ MP.ACTIONS.set_ante(0)
+ if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then
+ seed = MP.LOBBY.config.custom_seed
+ end
+ G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake })
+ MP.LOBBY.ready_to_start = false
+end
+
+local function begin_pvp_blind()
+ if MP.GAME.next_blind_context then
+ G.FUNCS.select_blind(MP.GAME.next_blind_context)
+ else
+ sendErrorMessage("No next blind context", "MULTIPLAYER")
+ end
+end
+
+local function action_start_blind()
+ MP.GAME.ready_blind = false
+ MP.GAME.timer_started = false
+ MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
+ MP.UI.start_pvp_countdown(begin_pvp_blind)
+end
+
+---@param score_str string
+---@param hands_left_str string
+---@param skips_str string
+local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str)
+ local score = MP.INSANE_INT.from_string(score_str)
+
+ local hands_left = tonumber(hands_left_str)
+ local skips = tonumber(skips_str)
+ local lives = tonumber(lives_str)
+
+ if MP.GAME.enemy.skips ~= skips then
+ for i = 1, skips - MP.GAME.enemy.skips do
+ MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = 0
+ end
+ end
+
+ if score == nil or hands_left == nil then
+ sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER")
+ return
+ end
+
+ if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end
+
+ 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",
+ ease_to = score.coeffiocient,
+ func = function(t)
+ return math.floor(t)
+ end,
+ }))
+
+ G.E_MANAGER:add_event(Event({
+ blockable = false,
+ blocking = false,
+ trigger = "ease",
+ delay = 3,
+ ref_table = MP.GAME.enemy.score,
+ ref_value = "exponent",
+ ease_to = score.exponent,
+ func = function(t)
+ return math.floor(t)
+ end,
+ }))
+
+ if MP.GAME.enemy.lives > lives then
+ play_sound("holo1", 0.865, 0.9)
+ play_sound("gong", 0.765, 0.4)
+ end
+
+ MP.GAME.enemy.hands = hands_left
+ MP.GAME.enemy.skips = skips
+ MP.GAME.enemy.lives = lives
+ if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
+end
+
+local function action_stop_game()
+ MP.enemy_disconnect_countdown = nil
+ if G.STAGE ~= G.STAGES.MAIN_MENU then
+ G.FUNCS.go_to_menu()
+ MP.UI.update_connection_status()
+ MP.reset_game_states()
+ end
+end
+
+local function action_end_pvp()
+ MP.GAME.end_pvp = true
+ MP.GAME.timer = MP.LOBBY.config.timer_base_seconds
+ MP.GAME.timer_started = false
+ MP.GAME.ready_blind = false
+end
+
+---@param lives number
+local function action_player_info(lives)
+ if MP.GAME.lives ~= lives then
+ if MP.GAME.lives ~= 0 and MP.LOBBY.config.gold_on_life_loss then
+ if MP.is_pvp_boss() or MP.is_major_league_ruleset() then
+ MP.GAME.comeback_bonus_given = false
+ MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1
+ end
+ end
+ MP.UI.ease_lives(lives - MP.GAME.lives)
+ if MP.LOBBY.config.no_gold_on_round_loss and (G.GAME.blind and G.GAME.blind.dollars) then
+ G.GAME.blind.dollars = 0
+ end
+ end
+ MP.GAME.lives = lives
+end
+
+local function action_win_game()
+ MP.end_game_jokers_payload = ""
+ MP.nemesis_deck_string = ""
+ MP.end_game_jokers_received = false
+ MP.nemesis_deck_received = false
+ MP.GAME.won = true
+ MP.STATS.record_match(true)
+ win_game()
+end
+
+local function action_lose_game()
+ MP.end_game_jokers_payload = ""
+ MP.nemesis_deck_string = ""
+ MP.end_game_jokers_received = false
+ MP.nemesis_deck_received = false
+ MP.STATS.record_match(false)
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.GAME_OVER
+end
+
+local function action_lobby_options(options)
+ local different_decks_before = MP.LOBBY.config.different_decks
+ for k, v in pairs(options) do
+ if k == "ruleset" then
+ if not MP.Rulesets[v] then
+ G.FUNCS.lobby_leave(nil)
+ MP.UI.UTILS.overlay_message(localize({
+ type = "variable",
+ key = "k_failed_to_join_lobby",
+ vars = { localize("k_ruleset_not_found") },
+ }))
+ return
+ end
+ local disabled = MP.Rulesets[v].is_disabled()
+ if disabled then
+ G.FUNCS.lobby_leave(nil)
+ MP.UI.UTILS.overlay_message(
+ localize({ type = "variable", key = "k_failed_to_join_lobby", vars = { disabled } })
+ )
+ return
+ end
+ MP.LOBBY.config.ruleset = v
+ goto continue
+ end
+ if k == "gamemode" then
+ MP.LOBBY.config.gamemode = v
+ goto continue
+ end
+
+ local parsed_v = v
+ if v == "true" then
+ parsed_v = true
+ elseif v == "false" then
+ parsed_v = false
+ end
+
+ if
+ k == "starting_lives"
+ or k == "pvp_start_round"
+ or k == "timer_base_seconds"
+ or k == "timer_increment_seconds"
+ or k == "showdown_starting_antes"
+ or k == "pvp_countdown_seconds"
+ or k == "timer_forgiveness"
+ then
+ parsed_v = tonumber(v)
+ end
+
+ MP.LOBBY.config[k] = parsed_v
+ if MP.UI.update_lobby_option_toggle then MP.UI.update_lobby_option_toggle(k) end
+ ::continue::
+ end
+ if different_decks_before ~= MP.LOBBY.config.different_decks then
+ G.FUNCS.exit_overlay_menu() -- throw out guest from any menu.
+ end
+ MP.ACTIONS.update_player_usernames() -- render new DECK button state
+end
+
+local function action_send_phantom(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)
+ new_card:set_edition("e_mp_phantom")
+ new_card:add_to_deck()
+ MP.shared:emplace(new_card)
+ G.OVERLAY_MENU = menu
+end
+
+local function action_remove_phantom(key)
+ local card = MP.UTILS.get_phantom_joker(key)
+ if card then
+ card:remove_from_deck()
+ card:start_dissolve({ G.C.RED }, nil, 1.6)
+ MP.shared:remove_card(card)
+ end
+end
+
+-- card:remove is called in an event so we have to hook the function instead of doing normal things
+local cardremove = Card.remove
+function Card:remove()
+ local menu = G.OVERLAY_MENU
+ if self.edition and self.edition.type == "mp_phantom" then G.OVERLAY_MENU = G.OVERLAY_MENU or true end
+ cardremove(self)
+ G.OVERLAY_MENU = menu
+end
+
+-- and smods find card STILL needs to be patched here
+local smodsfindcard = SMODS.find_card
+function SMODS.find_card(key, count_debuffed)
+ local ret = smodsfindcard(key, count_debuffed)
+ local new_ret = {}
+ for i, v in ipairs(ret) do
+ if not v.edition or v.edition.type ~= "mp_phantom" then new_ret[#new_ret + 1] = v end
+ end
+ return new_ret
+end
+
+-- don't poll edition
+local origedpoll = poll_edition
+function poll_edition(_key, _mod, _no_neg, _guaranteed, _options)
+ if G.OVERLAY_MENU then return nil end
+ return origedpoll(_key, _mod, _no_neg, _guaranteed, _options)
+end
+
+local function action_speedrun()
+ SMODS.calculate_context({ mp_speedrun = true })
+end
+
+local function enemyLocation(options)
+ local location = options.location
+ local value = ""
+
+ if string.find(location, "-") then
+ local split = {}
+ for str in string.gmatch(location, "([^-]+)") do
+ table.insert(split, str)
+ end
+ location = split[1]
+ value = split[2]
+ end
+
+ loc_name = localize({ type = "name_text", key = value, set = "Blind" })
+ if loc_name ~= "ERROR" then
+ value = loc_name
+ else
+ value = (G.P_BLINDS[value] and G.P_BLINDS[value].name) or value
+ end
+
+ loc_location = G.localization.misc.dictionary[location]
+
+ if loc_location == nil then
+ if location ~= nil then
+ loc_location = location
+ else
+ loc_location = "Unknown"
+ end
+ end
+
+ MP.GAME.enemy.location = loc_location .. value
+end
+
+local function action_version()
+ MP.ACTIONS.version()
+end
+
+local action_asteroid = 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_sold_joker()
+ -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked
+ MP.GAME.enemy.sells = MP.GAME.enemy.sells + 1
+ MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] = (
+ (MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] or 0) + 1
+ )
+end
+
+local function action_lets_go_gambling_nemesis()
+ local card = MP.UTILS.get_phantom_joker("j_mp_lets_go_gambling")
+ if card then card:juice_up() end
+ ease_dollars(card and card.ability and card.ability.extra and card.ability.extra.nemesis_dollars or 5)
+end
+
+local function action_eat_pizza(discards)
+ MP.GAME.pizza_discards = MP.GAME.pizza_discards + discards
+ G.GAME.round_resets.discards = G.GAME.round_resets.discards + discards
+ ease_discard(discards)
+end
+
+local function action_spent_last_shop(amount)
+ MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = tonumber(amount)
+end
+
+local function action_magnet()
+ 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
+ end
+
+ if card then
+ local candidates = {}
+ for _, v in pairs(G.jokers.cards) do
+ if v.sell_cost == card.sell_cost then table.insert(candidates, v) end
+ end
+
+ -- Scale the pseudo from 0 - 1 to the number of candidates
+ local random_index = math.floor(pseudorandom("j_mp_magnet") * #candidates) + 1
+ local chosen_card = candidates[random_index]
+ sendTraceMessage(
+ string.format("Sending magnet joker: %s", MP.UTILS.joker_to_string(chosen_card)),
+ "MULTIPLAYER"
+ )
+
+ local card_save = chosen_card:save()
+ local card_encoded = MP.UTILS.str_pack_and_encode(card_save)
+ MP.ACTIONS.magnet_response(card_encoded)
+ end
+end
+
+local function action_jimbo_appear(pos, text)
+ pos = tonumber(pos)
+ if not pos or pos < 1 or pos > 4 then
+ sendDebugMessage("jimboAppear: invalid pos: " .. tostring(pos), "MULTIPLAYER")
+ return
+ end
+ if text and type(text) ~= "string" then
+ sendDebugMessage("jimboAppear: invalid text type: " .. type(text), "MULTIPLAYER")
+ return
+ end
+ MP.UI.create_jimbo(pos)
+ if text and text ~= "" then MP.UI.jimbo_say(text) end
+end
+
+local function action_jimbo_talk(text)
+ if not text or type(text) ~= "string" or text == "" then
+ sendDebugMessage("jimboTalk: invalid or empty text", "MULTIPLAYER")
+ return
+ end
+ MP.UI.jimbo_say(text)
+end
+
+local function action_jimbo_move(pos)
+ pos = tonumber(pos)
+ if not pos or pos < 1 or pos > 4 then
+ sendDebugMessage("jimboMove: invalid pos: " .. tostring(pos), "MULTIPLAYER")
+ return
+ end
+ MP.UI.move_jimbo(pos)
+end
+
+local function action_jimbo_remove()
+ MP.UI.remove_jimbo()
+end
+
+local function action_magnet_response(key)
+ local card_save, success, err
+
+ card_save, err = MP.UTILS.str_decode_and_unpack(key)
+ if not card_save then
+ sendDebugMessage(string.format("Failed to unpack magnet joker: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ local card =
+ Card(G.jokers.T.x + G.jokers.T.w / 2, G.jokers.T.y, G.CARD_W, G.CARD_H, G.P_CENTERS.j_joker, G.P_CENTERS.c_base)
+ -- Avoid crashing if the load function ends up indexing a nil value
+ success, err = pcall(card.load, card, card_save)
+ if not success then
+ sendDebugMessage(string.format("Failed to load magnet joker: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ -- BALATRO BUG (version 1.0.1o): `card.VT.h` is mistakenly set to nil after calling `card:load()`
+ -- Without this call to `card:hard_set_VT()`, the game will crash later when the card is drawn
+ card:hard_set_VT()
+
+ -- Enforce "add to deck" effects (e.g. increase hand size effects)
+ card.added_to_deck = nil
+
+ card:add_to_deck()
+ G.jokers:emplace(card)
+ sendTraceMessage(string.format("Received magnet joker: %s", MP.UTILS.joker_to_string(card)), "MULTIPLAYER")
+end
+
+function G.FUNCS.load_end_game_jokers()
+ local card_area_save, success, err
+
+ if not MP.end_game_jokers or not MP.end_game_jokers_payload then return end
+
+ card_area_save, err = MP.UTILS.str_decode_and_unpack(MP.end_game_jokers_payload)
+ if not card_area_save then
+ sendDebugMessage(string.format("Failed to unpack enemy jokers: %s", err), "MULTIPLAYER")
+ return
+ end
+
+ -- Avoid crashing if the load function ends up indexing a nil value
+ success, err = pcall(MP.end_game_jokers.load, MP.end_game_jokers, card_area_save)
+ if not success then
+ sendDebugMessage(string.format("Failed to load enemy jokers: %s", err), "MULTIPLAYER")
+ -- Reset the card area if loading fails to avoid inconsistent state
+ MP.end_game_jokers:remove()
+ MP.end_game_jokers:init(
+ ---@diagnostic disable-next-line: param-type-mismatch
+ 0,
+ 0,
+ 5 * G.CARD_W,
+ G.CARD_H,
+ { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1 }
+ )
+ return
+ end
+
+ -- Log the jokers
+ if MP.end_game_jokers.cards then
+ local jokers_str = ""
+ for _, card in pairs(MP.end_game_jokers.cards) do
+ jokers_str = jokers_str .. ";" .. MP.UTILS.joker_to_string(card)
+ end
+ sendTraceMessage(string.format("Received end game jokers: %s", jokers_str), "MULTIPLAYER")
+ end
+end
+
+local function action_receive_end_game_jokers(keys)
+ MP.end_game_jokers_payload = keys
+ MP.end_game_jokers_received = true
+ G.FUNCS.load_end_game_jokers()
+end
+
+local function action_get_end_game_jokers()
+ if not G.jokers or not G.jokers.cards then
+ Client.send({
+ action = "receiveEndGameJokers",
+ keys = {},
+ })
+ return
+ end
+
+ -- Log the jokers
+ local jokers_str = ""
+ for _, card in pairs(G.jokers.cards) do
+ jokers_str = jokers_str .. ";" .. MP.UTILS.joker_to_string(card)
+ end
+ sendTraceMessage(string.format("Sending end game jokers: %s", jokers_str), "MULTIPLAYER")
+
+ local jokers_save = G.jokers:save()
+ local jokers_encoded = MP.UTILS.str_pack_and_encode(jokers_save)
+
+ Client.send({
+ action = "receiveEndGameJokers",
+ keys = jokers_encoded,
+ })
+end
+
+local function action_get_nemesis_deck()
+ local deck_str = ""
+ for _, card in ipairs(G.playing_cards) do
+ deck_str = deck_str .. ";" .. MP.UTILS.card_to_string(card)
+ end
+ Client.send({
+ action = "receiveNemesisDeck",
+ cards = deck_str,
+ })
+end
+
+local function action_send_game_stats()
+ if not MP.GAME.stats then
+ Client.send({
+ action = "nemesisEndGameStats",
+ })
+ return
+ end
+
+ local stats = {
+ action = "nemesisEndGameStats",
+ reroll_count = MP.GAME.stats.reroll_count,
+ reroll_cost_total = MP.GAME.stats.reroll_cost_total,
+ }
+
+ -- Extract voucher keys where value is true and join them with a dash
+ local voucher_keys = ""
+ if G.GAME.used_vouchers then
+ local keys = {}
+ for k, v in pairs(G.GAME.used_vouchers) do
+ if v == true then table.insert(keys, k) end
+ end
+ voucher_keys = table.concat(keys, "-")
+ end
+
+ -- Add voucher keys to stats string
+ if voucher_keys ~= "" then stats.vouchers = voucher_keys end
+
+ Client.send(stats)
+end
+
+function G.FUNCS.load_nemesis_deck()
+ if not MP.nemesis_deck_string or not MP.nemesis_deck or not MP.nemesis_cards or not MP.LOBBY.code then return end
+
+ local card_strings = MP.UTILS.string_split(MP.nemesis_deck_string, ";")
+
+ for k, _ in pairs(MP.nemesis_cards) do
+ MP.nemesis_cards[k] = nil
+ end
+
+ for _, card_str in pairs(card_strings) do
+ if card_str == "" then goto continue end
+
+ local card_params = MP.UTILS.string_split(card_str, "-")
+
+ local suit = card_params[1]
+ local rank = card_params[2]
+ local enhancement = card_params[3]
+ local edition = card_params[4]
+ local seal = card_params[5]
+
+ -- Validate the card parameters
+ -- If invalid suit or rank, skip the card
+ -- If invalid enhancement, edition, or seal, fallback to "none"
+ local front_key = tostring(suit) .. "_" .. tostring(rank)
+ if not G.P_CARDS[front_key] then
+ sendDebugMessage(string.format("Invalid playing card key: %s", front_key), "MULTIPLAYER")
+ goto continue
+ end
+ if not enhancement or (enhancement ~= "none" and not G.P_CENTERS[enhancement]) then
+ sendDebugMessage(string.format("Invalid enhancement: %s", enhancement), "MULTIPLAYER")
+ enhancement = "none"
+ end
+ if not edition or (edition ~= "none" and not G.P_CENTERS["e_" .. edition]) then
+ sendDebugMessage(string.format("Invalid edition: %s", edition), "MULTIPLAYER")
+ edition = "none"
+ end
+ if not seal or (seal ~= "none" and not G.P_SEALS[seal]) then
+ sendDebugMessage(string.format("Invalid seal: %s", seal), "MULTIPLAYER")
+ seal = "none"
+ end
+
+ -- Create the card
+ local card = create_playing_card({
+ front = G.P_CARDS[front_key],
+ center = enhancement ~= "none" and G.P_CENTERS[enhancement] or nil,
+ }, MP.nemesis_deck, true, true, nil, false)
+ if edition ~= "none" then card:set_edition({ [edition] = true }, true, true) end
+ if seal ~= "none" then card:set_seal(seal, true, true) end
+
+ -- Remove the card from G.playing_cards and insert into MP.nemesis_cards
+ table.remove(G.playing_cards, #G.playing_cards)
+ table.insert(MP.nemesis_cards, card)
+
+ ::continue::
+ end
+end
+
+local function action_receive_nemesis_deck(deck_str)
+ MP.nemesis_deck_string = deck_str
+ MP.nemesis_deck_received = true
+ G.FUNCS.load_nemesis_deck()
+end
+
+local function action_start_ante_timer(time)
+ local option = SMODS.Mods["Multiplayer"].config.timersfx or 1
+ local timersfx = (option == 1) or (option == 2 and G.timer_ante ~= G.GAME.round_resets.ante)
+ G.timer_ante = G.GAME.round_resets.ante
+
+ if timersfx then
+ for i = 1, 3 do
+ local wait_time = (0.15 * (i - 1))
+ G.E_MANAGER:add_event(Event({
+ blocking = false,
+ blockable = false,
+ trigger = "after",
+ delay = G.SETTINGS.GAMESPEED * wait_time,
+ func = function()
+ play_sound("timpani", 0.55 + 0.25 * i, 0.7)
+ play_sound("generic1", 0.75 + 0.25 * i, 0.7)
+ return true
+ end,
+ }))
+ end
+ end
+ if type(time) == "string" then time = tonumber(time) end
+ MP.GAME.timer = time
+ MP.GAME.timer_started = true
+ if not MP.is_ruleset_active("speedlatro") then G.E_MANAGER:add_event(MP.timer_event) end
+end
+
+local function action_pause_ante_timer(time)
+ if type(time) == "string" then time = tonumber(time) end
+ MP.GAME.timer = time
+ MP.GAME.timer_started = false
+end
+
+-- #region Client to Server
+function MP.ACTIONS.create_lobby(gamemode)
+ Client.send({
+ action = "createLobby",
+ gameMode = gamemode,
+ })
+end
+
+function MP.ACTIONS.join_lobby(code)
+ Client.send({
+ action = "joinLobby",
+ code = code,
+ })
+end
+
+function MP.ACTIONS.ready_lobby()
+ Client.send({
+ action = "readyLobby",
+ })
+end
+
+function MP.ACTIONS.unready_lobby()
+ Client.send({
+ action = "unreadyLobby",
+ })
+end
+
+function MP.ACTIONS.lobby_info()
+ Client.send({
+ action = "lobbyInfo",
+ })
+end
+
+function MP.ACTIONS.leave_lobby()
+ -- Clear reconnect state on voluntary leave
+ reconnectToken = nil
+ lastLobbyCode = nil
+ Client.send({
+ action = "leaveLobby",
+ })
+end
+
+function MP.ACTIONS.start_game()
+ Client.send({
+ action = "startGame",
+ })
+end
+
+function MP.ACTIONS.ready_blind(e)
+ MP.GAME.next_blind_context = e
+ Client.send({
+ action = "readyBlind",
+ })
+end
+
+function MP.ACTIONS.unready_blind()
+ Client.send({
+ action = "unreadyBlind",
+ })
+end
+
+function MP.ACTIONS.stop_game()
+ Client.send({
+ action = "stopGame",
+ })
+end
+
+function MP.ACTIONS.fail_round(hands_used)
+ if MP.LOBBY.config.no_gold_on_round_loss then G.GAME.blind.dollars = 0 end
+ if hands_used == 0 then return end
+ Client.send({
+ action = "failRound",
+ })
+end
+
+function MP.ACTIONS.version()
+ Client.send({
+ action = "version",
+ version = MULTIPLAYER_VERSION,
+ })
+end
+
+function MP.ACTIONS.set_location(location)
+ if MP.GAME.location == location then return end
+ MP.GAME.location = location
+ Client.send({
+ action = "setLocation",
+ location = location,
+ })
+end
+
+---@param score number
+---@param hands_left number
+function MP.ACTIONS.play_hand(score, hands_left)
+ local fixed_score = tostring(to_big(score))
+ -- Credit to sidmeierscivilizationv on discord for this fix for Talisman
+ if string.match(fixed_score, "[eE]") == nil and string.match(fixed_score, "[.]") then
+ -- Remove decimal from non-exponential numbers
+ fixed_score = string.sub(string.gsub(fixed_score, "%.", ","), 1, -3)
+ end
+ fixed_score = string.gsub(fixed_score, ",", "") -- Remove commas
+
+ local insane_int_score = MP.INSANE_INT.from_string(fixed_score)
+ if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.highest_score) then
+ MP.GAME.highest_score = insane_int_score
+ end
+ Client.send({
+ action = "playHand",
+ score = fixed_score,
+ handsLeft = hands_left,
+ })
+end
+
+function MP.ACTIONS.lobby_options()
+ ---@type table
+ local msg = {
+ action = "lobbyOptions",
+ }
+ for k, v in pairs(MP.LOBBY.config) do
+ msg[tostring(k)] = v
+ end
+ Client.send(msg)
+end
+
+function MP.ACTIONS.set_ante(ante)
+ Client.send({
+ action = "setAnte",
+ ante = ante,
+ })
+end
+
+function MP.ACTIONS.new_round()
+ MP.GAME.duplicate_end = false
+ MP.GAME.round_ended = false
+ Client.send({
+ action = "newRound",
+ })
+end
+
+function MP.ACTIONS.set_furthest_blind(furthest_blind)
+ Client.send({
+ action = "setFurthestBlind",
+ furthestBlind = furthest_blind,
+ })
+end
+
+function MP.ACTIONS.skip(skips)
+ Client.send({
+ action = "skip",
+ skips = skips,
+ })
+end
+
+function MP.ACTIONS.send_phantom(key)
+ Client.send({
+ action = "sendPhantom",
+ key = key,
+ })
+end
+
+function MP.ACTIONS.remove_phantom(key)
+ Client.send({
+ action = "removePhantom",
+ key = key,
+ })
+end
+
+function MP.ACTIONS.asteroid()
+ Client.send({
+ action = "asteroid",
+ })
+end
+
+function MP.ACTIONS.sold_joker()
+ Client.send({
+ action = "soldJoker",
+ })
+end
+
+function MP.ACTIONS.lets_go_gambling_nemesis()
+ Client.send({
+ action = "letsGoGamblingNemesis",
+ })
+end
+
+function MP.ACTIONS.eat_pizza(discards)
+ Client.send({
+ action = "eatPizza",
+ whole = discards,
+ })
+end
+
+function MP.ACTIONS.spent_last_shop(amount)
+ Client.send({
+ action = "spentLastShop",
+ amount = amount,
+ })
+end
+
+function MP.ACTIONS.magnet()
+ Client.send({
+ action = "magnet",
+ })
+end
+
+function MP.ACTIONS.magnet_response(key)
+ Client.send({
+ action = "magnetResponse",
+ key = key,
+ })
+end
+
+function MP.ACTIONS.get_end_game_jokers()
+ Client.send({
+ action = "getEndGameJokers",
+ })
+end
+
+function MP.ACTIONS.get_nemesis_deck()
+ Client.send({
+ action = "getNemesisDeck",
+ })
+end
+
+function MP.ACTIONS.send_game_stats()
+ Client.send({
+ action = "sendGameStats",
+ })
+ action_send_game_stats()
+end
+
+function MP.ACTIONS.request_nemesis_stats()
+ Client.send({
+ action = "endGameStatsRequested",
+ })
+end
+
+function MP.ACTIONS.start_ante_timer()
+ Client.send({
+ action = "startAnteTimer",
+ time = MP.GAME.timer,
+ })
+ action_start_ante_timer(MP.GAME.timer)
+end
+
+function MP.ACTIONS.pause_ante_timer()
+ Client.send({
+ action = "pauseAnteTimer",
+ time = MP.GAME.timer,
+ })
+ action_pause_ante_timer(MP.GAME.timer) -- TODO
+end
+
+function MP.ACTIONS.fail_timer()
+ Client.send({
+ action = "failTimer",
+ })
+end
+
+function MP.ACTIONS.sync_client()
+ Client.send({
+ action = "syncClient",
+ isCached = _RELEASE_MODE,
+ })
+end
+
+function MP.ACTIONS.modded(modId, modAction, params, target)
+ local msg = {
+ action = "moddedAction",
+ modId = modId,
+ modAction = modAction,
+ }
+ if params then
+ for k, v in pairs(params) do
+ msg[k] = v
+ end
+ end
+ if target then msg.target = target end
+ Client.send(msg)
+end
+
+-- #endregion Client to Server
+
+-- Utils
+function MP.ACTIONS.connect()
+ Client.send({
+ action = "connect",
+ })
+end
+
+function MP.ACTIONS.update_player_usernames()
+ if MP.LOBBY.code then
+ if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end
+ set_main_menu_UI()
+ end
+end
+
+local function string_to_table(str)
+ local tbl = {}
+ for part in string.gmatch(str, "([^,]+)") do
+ local key, value = string.match(part, "([^:]+):(.+)")
+ if key and value then tbl[key] = value end
+ end
+ return tbl
+end
+
+local last_game_seed = nil
+
+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()
+ if msg then
+ -- horribly messy catch
+ if string.sub(msg, 1, 1) == "a" then
+ if msg ~= "action:keepAlive" then
+ local networkToUiChannel = love.thread.getChannel("networkToUi")
+ networkToUiChannel:push(json.encode({
+ action = "error",
+ message = "Attempting to connect to outdated server",
+ }))
+ networkToUiChannel:push('{"action":"disconnected"}')
+ end
+ 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
+
+ if parsedAction.action == "connected" then
+ action_connected()
+ elseif parsedAction.action == "version" then
+ action_version()
+ elseif parsedAction.action == "disconnected" then
+ action_disconnected()
+ elseif parsedAction.action == "reconnecting" then
+ action_reconnecting()
+ elseif parsedAction.action == "joinedLobby" then
+ action_joinedLobby(parsedAction.code, parsedAction.type, parsedAction.reconnectToken)
+ elseif parsedAction.action == "rejoinedLobby" then
+ action_rejoinedLobby(parsedAction.code, parsedAction.type, parsedAction.reconnectToken)
+ elseif parsedAction.action == "enemyDisconnected" then
+ action_enemyDisconnected(parsedAction.timeout)
+ elseif parsedAction.action == "enemyReconnected" then
+ action_enemyReconnected()
+ elseif parsedAction.action == "lobbyInfo" then
+ action_lobbyInfo(
+ parsedAction.host,
+ parsedAction.hostHash,
+ parsedAction.hostCached,
+ parsedAction.guest,
+ parsedAction.guestHash,
+ parsedAction.guestCached,
+ parsedAction.guestReady,
+ parsedAction.isHost
+ )
+ elseif parsedAction.action == "startGame" then
+ action_start_game(parsedAction.seed, parsedAction.stake)
+ elseif parsedAction.action == "startBlind" then
+ action_start_blind()
+ elseif parsedAction.action == "enemyInfo" then
+ action_enemy_info(parsedAction.score, parsedAction.handsLeft, parsedAction.skips, parsedAction.lives)
+ elseif parsedAction.action == "stopGame" then
+ action_stop_game()
+ elseif parsedAction.action == "endPvP" then
+ action_end_pvp()
+ elseif parsedAction.action == "playerInfo" then
+ action_player_info(parsedAction.lives)
+ elseif parsedAction.action == "winGame" then
+ action_win_game()
+ elseif parsedAction.action == "loseGame" then
+ action_lose_game()
+ elseif parsedAction.action == "lobbyOptions" then
+ action_lobby_options(parsedAction)
+ elseif parsedAction.action == "enemyLocation" then
+ enemyLocation(parsedAction)
+ elseif parsedAction.action == "sendPhantom" then
+ action_send_phantom(parsedAction.key)
+ elseif parsedAction.action == "removePhantom" then
+ action_remove_phantom(parsedAction.key)
+ elseif parsedAction.action == "speedrun" then
+ action_speedrun()
+ elseif parsedAction.action == "asteroid" then
+ action_asteroid()
+ elseif parsedAction.action == "soldJoker" then
+ action_sold_joker()
+ elseif parsedAction.action == "letsGoGamblingNemesis" then
+ action_lets_go_gambling_nemesis()
+ elseif parsedAction.action == "eatPizza" then
+ action_eat_pizza(parsedAction.whole) -- rename to "discards" when possible
+ elseif parsedAction.action == "spentLastShop" then
+ action_spent_last_shop(parsedAction.amount)
+ elseif parsedAction.action == "magnet" then
+ action_magnet()
+ elseif parsedAction.action == "magnetResponse" then
+ action_magnet_response(parsedAction.key)
+ elseif parsedAction.action == "getEndGameJokers" then
+ action_get_end_game_jokers()
+ elseif parsedAction.action == "receiveEndGameJokers" then
+ action_receive_end_game_jokers(parsedAction.keys)
+ elseif parsedAction.action == "getNemesisDeck" then
+ action_get_nemesis_deck()
+ elseif parsedAction.action == "receiveNemesisDeck" then
+ action_receive_nemesis_deck(parsedAction.cards)
+ elseif parsedAction.action == "endGameStatsRequested" then
+ action_send_game_stats()
+ elseif parsedAction.action == "nemesisEndGameStats" then
+ -- Handle receiving game stats (is only logged now, now shown in the ui)
+ elseif parsedAction.action == "startAnteTimer" then
+ action_start_ante_timer(parsedAction.time)
+ elseif parsedAction.action == "pauseAnteTimer" then
+ action_pause_ante_timer(parsedAction.time)
+ elseif parsedAction.action == "jimboAppear" then
+ action_jimbo_appear(parsedAction.pos, parsedAction.text)
+ elseif parsedAction.action == "jimboTalk" then
+ action_jimbo_talk(parsedAction.text)
+ elseif parsedAction.action == "jimboMove" then
+ action_jimbo_move(parsedAction.pos)
+ elseif parsedAction.action == "jimboRemove" then
+ action_jimbo_remove()
+ elseif parsedAction.action == "moddedAction" then
+ local registry = MP.MOD_ACTIONS[parsedAction.modId]
+ if registry and registry[parsedAction.modAction] then registry[parsedAction.modAction](parsedAction) end
+ elseif parsedAction.action == "error" then
+ action_error(parsedAction.message)
+ elseif parsedAction.action == "keepAlive" then
+ action_keep_alive()
+ end
+ end
+ until not msg
+end
diff --git a/networking/socket.lua b/networking/socket.lua
new file mode 100644
index 00000000..93620cc5
--- /dev/null
+++ b/networking/socket.lua
@@ -0,0 +1,233 @@
+-- Code for networking stuff that runs in a separate thread
+
+-- Since threads run on a separate lua environment, we need to require
+-- the necessary modules again
+return [[
+local CONFIG_URL, CONFIG_PORT = ...
+
+require("love.filesystem")
+local json = require("json")
+local socket = require("socket")
+
+local DEBUGGING = false
+
+-- Defining this again, for debugging this thread
+local function initializeThreadDebugSocketConnection()
+ CLIENT = socket.connect("localhost", 12346)
+ if not CLIENT then
+ sendWarnMessage("Failed to connect to the debug server", "MULTIPLAYER")
+ end
+end
+
+function SEND_THREAD_DEBUG_MESSAGE(message)
+ if DEBUGGING and CLIENT and message then
+ CLIENT:send(message .. "\n")
+ end
+end
+
+if DEBUGGING then
+ initializeThreadDebugSocketConnection()
+end
+
+Networking = {}
+local isSocketClosed = true
+local hasGivenUp = false -- true after all reconnect attempts failed
+local networkToUiChannel = love.thread.getChannel("networkToUi")
+local uiToNetworkChannel = love.thread.getChannel("uiToNetwork")
+
+-- Reconnection settings
+local maxReconnectAttempts = 3
+local reconnectDelays = { 2, 4, 8 } -- seconds, exponential backoff
+
+function Networking.connect()
+ -- TODO: Check first if Networking.Client is not null
+ -- and if it is, skip this function
+
+ SEND_THREAD_DEBUG_MESSAGE(
+ string.format("Attempting to connect to multiplayer server... URL: %s, PORT: %d", CONFIG_URL, CONFIG_PORT)
+ )
+
+ Networking.Client = socket.tcp()
+ -- Allow for 10 seconds to reconnect
+ Networking.Client:settimeout(10)
+
+ Networking.Client:setoption("tcp-nodelay", true)
+ local connectionResult, errorMessage = Networking.Client:connect(CONFIG_URL, CONFIG_PORT) -- Not sure if I want to make these values public yet
+
+ if connectionResult ~= 1 then
+ SEND_THREAD_DEBUG_MESSAGE(string.format("%s", errorMessage))
+ networkToUiChannel:push(json.encode({
+ action = "error",
+ message = "Failed to connect to multiplayer server",
+ }))
+ return false
+ else
+ isSocketClosed = false
+ hasGivenUp = false
+ end
+
+ Networking.Client:settimeout(0)
+ return true
+end
+
+-- Attempt automatic reconnection with exponential backoff.
+-- Returns true if reconnected, false if all attempts failed.
+function Networking.tryReconnect()
+ SEND_THREAD_DEBUG_MESSAGE("Connection lost, attempting automatic reconnection...")
+
+ for attempt = 1, maxReconnectAttempts do
+ local delay = reconnectDelays[attempt] or reconnectDelays[#reconnectDelays]
+ SEND_THREAD_DEBUG_MESSAGE(string.format("Reconnect attempt %d/%d in %ds...", attempt, maxReconnectAttempts, delay))
+ socket.sleep(delay)
+
+ if Networking.connect() then
+ SEND_THREAD_DEBUG_MESSAGE("Reconnected successfully!")
+ return true
+ end
+ end
+
+ SEND_THREAD_DEBUG_MESSAGE("All reconnection attempts failed.")
+ return false
+end
+
+-- Check for messages from the main thread
+local mainThreadMessageQueue = function()
+ -- Executes a max of requestsPerCycle action requests
+ -- from the main thread and then yields
+ local requestsPerCycle = 25
+ while true do
+ for _ = 1, requestsPerCycle do
+ local msg = uiToNetworkChannel:pop()
+ if msg then
+ if msg == "{\"action\":\"connect\"}" then
+ hasGivenUp = false
+ Networking.connect()
+ else
+ Networking.Client:send(msg .. "\n")
+ end
+ else
+ -- If there are no more messages, yield
+ coroutine.yield()
+ end
+ end
+
+ coroutine.yield()
+ end
+end
+local mainThreadCoroutine = coroutine.create(mainThreadMessageQueue)
+
+local timer = function(time)
+ local init = os.time()
+ local diff = os.difftime(os.time(), init)
+ while diff < time do
+ coroutine.yield(diff)
+ diff = os.difftime(os.time(), init)
+ end
+end
+local timerCoroutine = coroutine.create(timer)
+
+-- All values are in seconds
+local keepAliveInitialTimeout = 20
+local keepAliveRetryTimeout = 5
+local keepAliveRetryCount = 4
+
+local isRetry = false
+local retryCount = 0
+
+-- Check for network packets
+local networkPacketQueue = function()
+ local packetsPerCycle = 25
+ while true do
+ if Networking.Client and not hasGivenUp then
+ -- Tries to fetch a packet a max of packetsPerCycle times
+ -- and then yields
+ for _ = 1, packetsPerCycle do
+ local data, error, partial = Networking.Client:receive()
+ if data then
+ -- Packet arrived, reset retries
+ isRetry = false
+ retryCount = 0
+ -- Also reset timer
+ timerCoroutine = coroutine.create(timer)
+
+ -- Respond to server keepAlive directly on the socket
+ -- to avoid latency from routing through the UI thread
+ if string.find(data, '"keepAlive"') and not string.find(data, 'Ack') then
+ Networking.Client:send('{"action":"keepAliveAck"}\n')
+ end
+
+ -- Send the string as is to the main thread
+ networkToUiChannel:push(data)
+ elseif error == "close" then
+ -- Connection closed, attempt automatic reconnection
+ isSocketClosed = true
+ retryCount = 0
+ isRetry = false
+ timerCoroutine = coroutine.create(timer)
+
+ networkToUiChannel:push("{\"action\":\"reconnecting\"}")
+ if not Networking.tryReconnect() then
+ hasGivenUp = true
+ networkToUiChannel:push("{\"action\":\"disconnected\"}")
+ end
+ break
+ else
+ -- If there are no more packets, yield
+ coroutine.yield()
+ end
+ end
+
+ coroutine.yield()
+ end
+
+ coroutine.yield()
+ end
+end
+local networkCoroutine = coroutine.create(networkPacketQueue)
+
+-- Checks for network packets,
+-- then sends them to the main thread
+-- then advances timers
+-- and then sleeps
+while true do
+ coroutine.resume(mainThreadCoroutine)
+ coroutine.resume(networkCoroutine)
+
+ -- Run Timer
+ if not isSocketClosed and coroutine.status(timerCoroutine) ~= "dead" then
+ coroutine.resume(timerCoroutine, keepAliveInitialTimeout)
+ elseif not isSocketClosed then
+ -- Timer triggered
+ isRetry = true
+
+ if retryCount > keepAliveRetryCount then
+ Networking.Client:close()
+
+ -- Keepalive failed, attempt automatic reconnection
+ isSocketClosed = true
+ retryCount = 0
+ isRetry = false
+ timerCoroutine = coroutine.create(timer)
+
+ networkToUiChannel:push("{\"action\":\"reconnecting\"}")
+ if not Networking.tryReconnect() then
+ hasGivenUp = true
+ networkToUiChannel:push("{\"action\":\"disconnected\"}")
+ end
+ end
+
+ if isRetry then
+ retryCount = retryCount + 1
+ -- Send keepAlive without cutting the line
+ uiToNetworkChannel:push("{\"action\":\"keepAlive\"}")
+
+ -- Restart the timer
+ timerCoroutine = coroutine.create(timer)
+ coroutine.resume(timerCoroutine, keepAliveRetryTimeout)
+ end
+ end
+
+ -- Sleeps for 50 milliseconds
+ socket.sleep(0.05)
+end
+]]
diff --git a/objects/blinds/nemesis.lua b/objects/blinds/nemesis.lua
new file mode 100644
index 00000000..0a8558c1
--- /dev/null
+++ b/objects/blinds/nemesis.lua
@@ -0,0 +1,35 @@
+SMODS.Atlas({
+ key = "player_blind_chip",
+ path = "player_blind_row.png",
+ atlas_table = "ANIMATION_ATLAS",
+ frames = 21,
+ px = 34,
+ py = 34,
+})
+
+SMODS.Atlas({
+ key = "player_blind_col",
+ path = "blind_col.png",
+ atlas_table = "ANIMATION_ATLAS",
+ frames = 21,
+ px = 34,
+ py = 34,
+})
+
+SMODS.Blind({
+ key = "nemesis",
+ dollars = 5,
+ mult = 1, -- Jen's Almanac crashes the game if the mult is 0
+ boss_colour = G.C.MULTIPLAYER,
+ boss = { min = 1, max = 10 },
+ atlas = "player_blind_chip",
+ discovered = true,
+ in_pool = function(self)
+ return false
+ end,
+})
+
+function MP.is_pvp_boss()
+ if not G.GAME or not G.GAME.blind then return false end
+ return G.GAME.blind.config.blind.key == "bl_mp_nemesis" or G.GAME.blind.pvp
+end
diff --git a/objects/boosters/standard_giga.lua b/objects/boosters/standard_giga.lua
new file mode 100644
index 00000000..d9daf047
--- /dev/null
+++ b/objects/boosters/standard_giga.lua
@@ -0,0 +1,46 @@
+SMODS.Atlas({
+ key = "standard_giga",
+ path = "standard_giga.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Booster({
+ key = "standard_giga",
+ kind = "Standard",
+ group_key = "k_standard_pack",
+ atlas = "standard_giga",
+ pos = { x = 0, y = 0 },
+ config = { extra = 10, choose = 4 },
+ cost = 16,
+ weight = 0,
+ unskippable = true,
+ create_card = function(self, card, i)
+ local s_append = "" -- MP.get_booster_append(card)
+ local b_append = MP.ante_based() .. s_append
+
+ local _edition = poll_edition("standard_edition" .. b_append, 2, true)
+ local _seal = SMODS.poll_seal({ mod = 10, key = "stdseal" .. b_append })
+
+ return {
+ set = (pseudorandom(pseudoseed("stdset" .. b_append)) > 0.6) and "Enhanced" or "Base",
+ edition = _edition,
+ seal = _seal,
+ area = G.pack_cards,
+ skip_materialize = true,
+ soulable = true,
+ key_append = "sta" .. s_append,
+ }
+ end,
+})
+
+-- unskippable pack hook here, why not
+local can_skip_ref = G.FUNCS.can_skip_booster
+G.FUNCS.can_skip_booster = function(e)
+ if SMODS.OPENED_BOOSTER and SMODS.OPENED_BOOSTER.config.center.unskippable then
+ e.config.colour = G.C.UI.BACKGROUND_INACTIVE
+ e.config.button = nil
+ else
+ return can_skip_ref(e)
+ end
+end
diff --git a/objects/challenges/all_must_go.lua b/objects/challenges/all_must_go.lua
new file mode 100644
index 00000000..7d717841
--- /dev/null
+++ b/objects/challenges/all_must_go.lua
@@ -0,0 +1,10 @@
+SMODS.Challenge({
+ key = "all_must_go",
+ jokers = {
+ { id = "j_mp_taxes", eternal = true },
+ { id = "j_campfire", eternal = true },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/balancing_act.lua b/objects/challenges/balancing_act.lua
new file mode 100644
index 00000000..10ccada7
--- /dev/null
+++ b/objects/challenges/balancing_act.lua
@@ -0,0 +1,80 @@
+SMODS.Challenge({
+ key = "balancing_act",
+ rules = {
+ custom = {
+ { id = "mp_score_instability" },
+ { id = "mp_score_instability_EXAMPLE" }, -- ?????????????????????
+ { id = "mp_score_instability_LOC1" },
+ { id = "mp_score_instability_LOC2" },
+ { id = "mp_ante_scaling", value = 0.25 }, -- this would be in modifiers table if it actually worked
+ },
+ },
+ unlocked = function(self)
+ 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/chore_list.lua b/objects/challenges/chore_list.lua
new file mode 100644
index 00000000..28df3a78
--- /dev/null
+++ b/objects/challenges/chore_list.lua
@@ -0,0 +1,32 @@
+SMODS.Challenge({
+ key = "chore_list",
+ jokers = {
+ { id = "j_todo_list", eternal = true, rental = true, edition = "negative" },
+ { id = "j_todo_list", eternal = true, rental = true, edition = "negative" },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "j_trading" },
+ { id = "j_midas_mask" },
+ { id = "j_golden" },
+ { id = "j_todo_list" },
+ { id = "j_rough_gem" },
+ { id = "j_reserved_parking" },
+ { id = "j_to_the_moon" },
+ { id = "j_business" },
+ { id = "j_delayed_grat" },
+ { id = "j_satellite" },
+ { id = "j_egg" },
+ { id = "j_faceless" },
+ { id = "j_mail" },
+ { id = "j_golden" },
+ { id = "j_gift" },
+ { id = "j_riff_raff" },
+ { id = "j_chaos" },
+ { id = "j_mp_penny_pincher" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/divination.lua b/objects/challenges/divination.lua
new file mode 100644
index 00000000..5b35ff90
--- /dev/null
+++ b/objects/challenges/divination.lua
@@ -0,0 +1,9 @@
+SMODS.Challenge({
+ key = "divination",
+ jokers = {
+ { id = "j_vagabond", eternal = true },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/high_hand.lua b/objects/challenges/high_hand.lua
new file mode 100644
index 00000000..31f79521
--- /dev/null
+++ b/objects/challenges/high_hand.lua
@@ -0,0 +1,18 @@
+SMODS.Challenge({
+ key = "high_hand",
+ rules = {
+ modifiers = {
+ { id = "hands", value = 1 },
+ { id = "hand_size", value = 26 },
+ { id = "discards", value = 0 },
+ },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "j_burglar" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/in_the_red.lua b/objects/challenges/in_the_red.lua
new file mode 100644
index 00000000..c35521a2
--- /dev/null
+++ b/objects/challenges/in_the_red.lua
@@ -0,0 +1,20 @@
+SMODS.Challenge({
+ key = "in_the_red",
+ rules = {
+ custom = {
+ { id = "no_reward_specific", value = "Small" },
+ { id = "no_reward_specific", value = "Big" },
+ },
+ },
+ jokers = {
+ { id = "j_credit_card", eternal = true, edition = "negative", rental = true },
+ },
+ restrictions = {
+ banned_tags = {
+ { id = "tag_investment" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/legendaries.lua b/objects/challenges/legendaries.lua
new file mode 100644
index 00000000..e38d39de
--- /dev/null
+++ b/objects/challenges/legendaries.lua
@@ -0,0 +1,32 @@
+SMODS.Challenge({
+ key = "legendaries",
+ rules = {
+ modifiers = {
+ {
+ id = "joker_slots",
+ value = 6,
+ },
+ },
+ },
+ jokers = {
+ { id = "j_caino", eternal = true },
+ { id = "j_perkeo", eternal = true },
+ { id = "j_triboulet", eternal = true },
+ { id = "j_yorick", eternal = true },
+ { id = "j_joker" },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "j_selzer" },
+ { id = "j_dusk" },
+ { id = "j_sock_and_buskin" },
+ { id = "j_hanging_chad" },
+ { id = "j_mp_hanging_chad" },
+ { id = "j_blueprint" },
+ { id = "j_brainstorm" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/lets_go_gambling.lua b/objects/challenges/lets_go_gambling.lua
new file mode 100644
index 00000000..c5001200
--- /dev/null
+++ b/objects/challenges/lets_go_gambling.lua
@@ -0,0 +1,44 @@
+SMODS.Challenge({
+ key = "lets_go_gambling",
+ rules = {
+ custom = {
+ { id = "no_reward_specific", value = "Small" },
+ { id = "no_reward_specific", value = "Big" },
+ },
+ },
+ jokers = {
+ { id = "j_oops", eternal = true, rental = true },
+ { id = "j_mp_lets_go_gambling", eternal = true, edition = "negative", rental = true },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "j_selzer" },
+ { id = "j_dusk" },
+ { id = "j_hanging_chad" },
+ { id = "j_bloodstone" },
+ { id = "c_high_priestess" },
+ { id = "c_empress" },
+ { id = "c_heirophant" },
+ { id = "c_chariot" },
+ { id = "c_justice" },
+ { id = "c_hermit" },
+ { id = "c_strength" },
+ { id = "c_hanged_man" },
+ { id = "c_death" },
+ { id = "c_temperance" },
+ { id = "c_devil" },
+ { id = "c_tower" },
+ { id = "c_star" },
+ { id = "c_moon" },
+ { id = "c_sun" },
+ { id = "c_world" },
+ },
+ },
+ deck = {
+ type = "Challenge Deck",
+ enhancement = "m_lucky",
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/misprint_deck.lua b/objects/challenges/misprint_deck.lua
new file mode 100644
index 00000000..91a4a8b9
--- /dev/null
+++ b/objects/challenges/misprint_deck.lua
@@ -0,0 +1,15 @@
+local deck_cards = {}
+for i = 1, 52 do
+ deck_cards[i] = { s = "S", r = "T" }
+end
+
+SMODS.Challenge({
+ key = "misprint_deck",
+ deck = {
+ type = "Challenge Deck",
+ cards = deck_cards,
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/oops_all_jokers.lua b/objects/challenges/oops_all_jokers.lua
new file mode 100644
index 00000000..36d9d12b
--- /dev/null
+++ b/objects/challenges/oops_all_jokers.lua
@@ -0,0 +1,53 @@
+SMODS.Challenge({
+ key = "oops_all_jokers",
+ jokers = {
+ { id = "j_ring_master", eternal = true, edition = "negative" },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "c_fool" },
+ { id = "c_magician" },
+ { id = "c_high_priestess" },
+ { id = "c_empress" },
+ { id = "c_emperor" },
+ { id = "c_heirophant" },
+ { id = "c_lovers" },
+ { id = "c_chariot" },
+ { id = "c_justice" },
+ { id = "c_hermit" },
+ { id = "c_wheel_of_fortune" },
+ { id = "c_strength" },
+ { id = "c_hanged_man" },
+ { id = "c_death" },
+ { id = "c_temperance" },
+ { id = "c_devil" },
+ { id = "c_tower" },
+ { id = "c_star" },
+ { id = "c_moon" },
+ { id = "c_sun" },
+ { id = "c_world" },
+ { id = "c_familiar" },
+ { id = "c_grim" },
+ { id = "c_incantation" },
+ { id = "c_talisman" },
+ { id = "c_aura" },
+ { id = "c_sigil" },
+ { id = "c_ouija" },
+ { id = "c_ectoplasm" },
+ { id = "c_immolate" },
+ { id = "c_deja_vu" },
+ { id = "c_hex" },
+ { id = "c_trance" },
+ { id = "c_medium" },
+ { id = "c_cryptid" },
+ { id = "c_black_hole" },
+ { id = "v_tarot_merchant" },
+ },
+ banned_tags = {
+ { id = "tag_charm" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/polymorph_spam.lua b/objects/challenges/polymorph_spam.lua
new file mode 100644
index 00000000..31a90bb3
--- /dev/null
+++ b/objects/challenges/polymorph_spam.lua
@@ -0,0 +1,167 @@
+SMODS.Challenge({
+ key = "polymorph_spam",
+ rules = {
+ custom = {
+ { id = "mp_polymorph_spam" },
+ { id = "mp_polymorph_spam_EXTENDED1" },
+ { id = "mp_polymorph_spam_EXTENDED2" },
+ },
+ },
+ restrictions = {
+ banned_cards = function()
+ local ret = {}
+ local add = {
+ j_campfire = true,
+ j_invisible = true,
+ j_caino = true,
+ j_yorick = true,
+ }
+ for i, v in ipairs(G.P_CENTER_POOLS.Joker) do
+ if (not v.perishable_compat) or add[v.key] then ret[#ret + 1] = { id = v.key } end
+ end
+ return ret
+ end,
+ },
+ unlocked = function(self)
+ 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/psychosis.lua b/objects/challenges/psychosis.lua
new file mode 100644
index 00000000..0fd07b83
--- /dev/null
+++ b/objects/challenges/psychosis.lua
@@ -0,0 +1,9 @@
+SMODS.Challenge({
+ key = "psychosis",
+ jokers = {
+ { id = "j_madness", eternal = true },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/salvaged_sibyl.lua b/objects/challenges/salvaged_sibyl.lua
new file mode 100644
index 00000000..e7b187e0
--- /dev/null
+++ b/objects/challenges/salvaged_sibyl.lua
@@ -0,0 +1,81 @@
+SMODS.Challenge({
+ key = "salvaged_sibyl",
+ rules = {
+ custom = {
+ { id = "mp_no_shop_planets" },
+ { id = "mp_only_medium" },
+ { id = "mp_only_purple_seals" },
+ { id = "mp_sibyl_CREDITS" },
+ },
+ },
+ consumeables = {
+ { id = "c_medium" },
+ },
+ restrictions = {
+ banned_cards = {
+ { id = "j_constellation" },
+ { id = "j_satellite" },
+ { id = "j_astronomer" },
+ { id = "c_high_priestess" },
+ { id = "v_planet_merchant", ids = { "v_planet_tycoon" } },
+ { id = "v_telescope", ids = { "v_observatory" } },
+ { id = "v_magic_trick", ids = { "v_illusion" } },
+ {
+ id = "p_celestial_normal_1",
+ ids = {
+ "p_celestial_normal_2",
+ "p_celestial_normal_3",
+ "p_celestial_normal_4",
+ "p_celestial_jumbo_1",
+ "p_celestial_jumbo_2",
+ "p_celestial_mega_1",
+ "p_celestial_mega_2",
+ },
+ },
+ {
+ id = "p_spectral_normal_1",
+ ids = {
+ "p_spectral_normal_2",
+ "p_spectral_jumbo_1",
+ "p_spectral_mega_1",
+ },
+ },
+ {
+ id = "p_standard_normal_1",
+ ids = {
+ "p_standard_normal_2",
+ "p_standard_normal_3",
+ "p_standard_normal_4",
+ "p_standard_jumbo_1",
+ "p_standard_jumbo_2",
+ "p_standard_mega_1",
+ "p_standard_mega_2",
+ },
+ },
+ },
+ },
+ apply = function(self)
+ G.GAME.selected_back.atlas = "mp_decks"
+ G.GAME.selected_back.pos = { x = 3, y = 0 }
+ G.GAME.planet_rate = 0
+ end,
+ unlocked = function(self)
+ return true
+ end,
+})
+
+-- billionth create card hook ever
+local create_card_ref = create_card
+function create_card(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)
+ if G.GAME.modifiers.mp_only_medium and _type == "Spectral" then
+ G.GAME.banned_keys["c_medium"] = nil
+ forced_key = "c_medium"
+ end
+ return create_card_ref(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key, key_append)
+end
+
+local set_seal_ref = Card.set_seal
+function Card:set_seal(_seal, silent, immediate)
+ if G.GAME.modifiers.mp_only_purple_seals and _seal then _seal = "Purple" end
+ return set_seal_ref(self, _seal, silent, immediate)
+end
diff --git a/objects/challenges/scratch.lua b/objects/challenges/scratch.lua
new file mode 100644
index 00000000..66166eb3
--- /dev/null
+++ b/objects/challenges/scratch.lua
@@ -0,0 +1,18 @@
+SMODS.Challenge({
+ key = "scratch",
+ jokers = {
+ { id = "j_half" },
+ },
+ vouchers = {
+ { id = "v_magic_trick" },
+ },
+ deck = {
+ type = "Challenge Deck",
+ cards = {
+ { s = "C", r = "7", e = "m_stone" },
+ },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/skip_off.lua b/objects/challenges/skip_off.lua
new file mode 100644
index 00000000..559a37f5
--- /dev/null
+++ b/objects/challenges/skip_off.lua
@@ -0,0 +1,10 @@
+SMODS.Challenge({
+ key = "skip_off",
+ jokers = {
+ { id = "j_mp_skip_off", eternal = true },
+ { id = "j_throwback", eternal = true },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/speed.lua b/objects/challenges/speed.lua
new file mode 100644
index 00000000..8094cd49
--- /dev/null
+++ b/objects/challenges/speed.lua
@@ -0,0 +1,10 @@
+SMODS.Challenge({
+ key = "speed",
+ jokers = {
+ { id = "j_mp_conjoined_joker", eternal = true, edition = "negative" },
+ { id = "j_mp_speedrun", eternal = true, edition = "negative" },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/twin_towers.lua b/objects/challenges/twin_towers.lua
new file mode 100644
index 00000000..b9c0d350
--- /dev/null
+++ b/objects/challenges/twin_towers.lua
@@ -0,0 +1,10 @@
+SMODS.Challenge({
+ key = "twin_towers",
+ jokers = {
+ { id = "j_obelisk", eternal = true },
+ { id = "j_obelisk", eternal = true },
+ },
+ unlocked = function(self)
+ return true
+ end,
+})
diff --git a/objects/challenges/vantablack.lua b/objects/challenges/vantablack.lua
new file mode 100644
index 00000000..25035005
--- /dev/null
+++ b/objects/challenges/vantablack.lua
@@ -0,0 +1,19 @@
+SMODS.Challenge({
+ key = "vantablack",
+ rules = {
+ custom = {
+ { id = "mp_vantablack_CREDITS" },
+ },
+ modifiers = {
+ { id = "joker_slots", value = 8 },
+ { id = "hands", value = 1 },
+ },
+ },
+ apply = function(self)
+ G.GAME.selected_back.atlas = "mp_decks"
+ G.GAME.selected_back.pos = { x = 3, y = 1 }
+ end,
+ unlocked = function(self)
+ return true
+ end,
+})
\ No newline at end of file
diff --git a/objects/consumables/asteroid.lua b/objects/consumables/asteroid.lua
new file mode 100644
index 00000000..5bbb0d86
--- /dev/null
+++ b/objects/consumables/asteroid.lua
@@ -0,0 +1,57 @@
+SMODS.Atlas({
+ key = "asteroid",
+ path = {
+ ["default"] = "c_asteroid.png",
+ ["ru"] = "c_asteroid_ru.png",
+ },
+ px = 71,
+ py = 95,
+})
+
+SMODS.Consumable({
+ key = "asteroid",
+ set = "Planet",
+ atlas = "asteroid",
+ cost = 3,
+ unlocked = true,
+ discovered = true,
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = { 1 } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ can_use = function(self, card)
+ return true
+ end,
+ use = function(self, card, area, copier)
+ local asteroids = MP.GAME.asteroids
+ update_hand_text(
+ { sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 },
+ { handname = localize("k_asteroids"), chips = localize("k_amount_short"), mult = MP.GAME.asteroids }
+ )
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.2,
+ func = function()
+ play_sound("tarot1", 0.9 + (MP.GAME.asteroids / 10), 1)
+ card:juice_up(0.8, 0.5)
+ return true
+ end,
+ }))
+ update_hand_text({ delay = 0 }, { mult = "+1", StatusText = true })
+ MP.GAME.asteroids = MP.GAME.asteroids + 1
+ update_hand_text({ delay = 0 }, { mult = MP.GAME.asteroids })
+ delay(2.5)
+ update_hand_text(
+ { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 },
+ { mult = 0, chips = 0, handname = "", level = "" }
+ )
+ end,
+ mp_credits = {
+ idea = { "Zilver" },
+ art = { "TheTrueRaven" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/consumables/judgement.lua b/objects/consumables/judgement.lua
new file mode 100644
index 00000000..7ecbe978
--- /dev/null
+++ b/objects/consumables/judgement.lua
@@ -0,0 +1,74 @@
+--[[ gotta redefine the logic
+MP.ReworkCenter({
+ key = "c_judgement",
+ ruleset = MP.UTILS.get_standard_rulesets({'minorleague'}),
+ silent = true,
+ use = function(self, card, area, copier)
+ local _card = copier or card
+ G.E_MANAGER:add_event(Event({trigger = 'after', delay = 0.4, func = function()
+ play_sound('timpani')
+ if MP.INTEGRATIONS.TheOrder then -- this only matters if order exists
+ local done = false
+ while not done do -- AHHH we have to do so much boilerplate
+ done = true
+
+ local card = { stickers = { eternal = false, perishable = false, rental = false } }
+ local rarity = SMODS.poll_rarity("Joker", 'rarity0')
+ local str_rarity = rarity
+ if type(rarity) == 'number' then
+ str_rarity = ({'Common', 'Uncommon', 'Rare', 'Legendary'})[rarity] -- kill me now
+ end
+ local _pool = get_current_pool('Joker', str_rarity, nil, '')
+ local _pool_key = 'Joker'..rarity..'0'
+ center = pseudorandom_element(_pool, pseudoseed(_pool_key))
+ local it = 1
+ while center == 'UNAVAILABLE' do
+ it = it + 1
+ center = pseudorandom_element(_pool, pseudoseed(_pool_key))
+ end
+
+ card.key = center
+
+ local eternal_perishable_poll = pseudorandom(center..'etperpoll0')
+ local rental_poll = pseudorandom(center..'ssjr0')
+
+ if G.GAME.modifiers.enable_eternals_in_shop
+ and eternal_perishable_poll > 0.7
+ and G.P_CENTERS[center].eternal_compat then
+ card.stickers.eternal = true
+ done = false
+ end
+
+ if G.GAME.modifiers.enable_perishables_in_shop
+ and ((eternal_perishable_poll > 0.4) and (eternal_perishable_poll <= 0.7))
+ and G.P_CENTERS[center].perishable_compat then
+ card.stickers.perishable = true
+ done = false
+ end
+
+ if G.GAME.modifiers.enable_rentals_in_shop
+ and rental_poll > 0.7 then
+ card.stickers.rental = true
+ done = false
+ end
+
+ if done then
+ table.insert(G.GAME.MP_joker_overrides, 1, card) -- start, so it gets created immediately
+ else
+ table.insert(G.GAME.MP_joker_overrides, card) -- end
+ end
+ end
+ end
+ G.MP_JUDGEMENT_OVERRIDE = true
+ local joker = create_card('Joker', G.jokers, nil, nil, nil, nil, nil, 'jud') -- just call create card since override will kick in
+ G.MP_JUDGEMENT_OVERRIDE = nil
+ joker:add_to_deck()
+ G.jokers:emplace(joker)
+ _card:juice_up(0.3, 0.5)
+
+ return true
+ end}))
+ delay(0.6)
+ end,
+})
+]]
diff --git a/objects/consumables/ouija.lua b/objects/consumables/ouija.lua
new file mode 100644
index 00000000..decd4372
--- /dev/null
+++ b/objects/consumables/ouija.lua
@@ -0,0 +1,148 @@
+SMODS.Atlas({
+ key = "ouija_2",
+ path = "c_ouija_2.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Consumable({
+ key = "ouija_standard",
+ set = "Spectral",
+ atlas = "ouija_2",
+ pos = { x = 0, y = 0 },
+ unlocked = true,
+ discovered = true,
+ config = { extra = { destroy = 3 }, mp_sticker_balanced = true },
+ in_pool = function(self)
+ return MP.is_ruleset_active("sandbox") or MP.UTILS.is_standard_ruleset()
+ end,
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.destroy } }
+ end,
+ use = function(self, card, area, copier)
+ local used_tarot = copier or card
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.2,
+ func = function()
+ play_sound("tarot1")
+ used_tarot:juice_up(0.3, 0.5)
+ return true
+ end,
+ }))
+
+ local cards_to_destroy = {}
+ for i = 1, math.min(card.ability.extra.destroy, #G.hand.cards) do
+ local remaining_cards = {}
+ for j, hand_card in ipairs(G.hand.cards) do
+ local already_marked = false
+ for k, marked_card in ipairs(cards_to_destroy) do
+ if marked_card == hand_card then
+ already_marked = true
+ break
+ end
+ end
+ if not already_marked then table.insert(remaining_cards, hand_card) end
+ end
+ if #remaining_cards > 0 then
+ local card_to_destroy = pseudorandom_element(remaining_cards, "ouija_destroy")
+ table.insert(cards_to_destroy, card_to_destroy)
+ end
+ end
+
+ -- Destroy the selected cards
+ for i, destroy_card in ipairs(cards_to_destroy) do
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.1,
+ func = function()
+ destroy_card.T.r = -0.2
+ destroy_card:juice_up(0.3, 0.4)
+ return true
+ end,
+ }))
+ SMODS.destroy_cards(destroy_card)
+ end
+
+ -- Wait for destruction, then flip remaining cards
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 1,
+ func = function()
+ for i = 1, #G.hand.cards do
+ local percent = 1.15 - (i - 0.999) / (#G.hand.cards - 0.998) * 0.3
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.15,
+ func = function()
+ if G.hand.cards[i] and not G.hand.cards[i].destroyed then
+ G.hand.cards[i]:flip()
+ play_sound("card1", percent)
+ G.hand.cards[i]:juice_up(0.3, 0.3)
+ end
+ return true
+ end,
+ }))
+ end
+ return true
+ end,
+ }))
+
+ -- Convert remaining cards to same rank
+ local _rank = pseudorandom_element(SMODS.Ranks, "ouija")
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.8,
+ func = function()
+ for i = 1, #G.hand.cards do
+ if G.hand.cards[i] and not G.hand.cards[i].destroyed then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ local _card = G.hand.cards[i]
+ if _card and not _card.destroyed then
+ assert(SMODS.change_base(_card, nil, _rank.key))
+ end
+ return true
+ end,
+ }))
+ end
+ end
+ return true
+ end,
+ }))
+
+ -- Flip cards back
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 1.2,
+ func = function()
+ for i = 1, #G.hand.cards do
+ if G.hand.cards[i] and not G.hand.cards[i].destroyed then
+ local percent = 0.85 + (i - 0.999) / (#G.hand.cards - 0.998) * 0.3
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.15,
+ func = function()
+ if G.hand.cards[i] and not G.hand.cards[i].destroyed then
+ G.hand.cards[i]:flip()
+ play_sound("tarot2", percent, 0.6)
+ G.hand.cards[i]:juice_up(0.3, 0.3)
+ end
+ return true
+ end,
+ }))
+ end
+ end
+ return true
+ end,
+ }))
+ delay(0.5)
+ end,
+ can_use = function(self, card)
+ return G.hand and #G.hand.cards >= card.ability.extra.destroy
+ end,
+ mp_credits = {
+ art = { "aura!" },
+ code = { "steph" },
+ },
+})
diff --git a/objects/consumables/sandbox/ectoplasm.lua b/objects/consumables/sandbox/ectoplasm.lua
new file mode 100644
index 00000000..94e6bedf
--- /dev/null
+++ b/objects/consumables/sandbox/ectoplasm.lua
@@ -0,0 +1,55 @@
+SMODS.Consumable({
+ key = "ectoplasm_sandbox",
+ set = "Spectral",
+ pos = { x = 8, y = 4 },
+ config = { mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.e_negative
+ return { vars = { G.GAME.ecto_minus or 1 } }
+ end,
+ in_pool = function(self)
+ return MP.is_ruleset_active("sandbox")
+ end,
+ use = function(self, card, area, copier)
+ local editionless_jokers = SMODS.Edition:get_edition_cards(G.jokers, true)
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.4,
+ func = function()
+ -- Randomly pick one of three negative effects
+ local effect = math.floor(pseudorandom("ectoplasm_sandbox") * 3) + 1
+
+ if effect == 1 then
+ G.GAME.round_resets.hands = G.GAME.round_resets.hands - 1
+ ease_hands_played(-1)
+ elseif effect == 2 then
+ G.GAME.round_resets.discards = G.GAME.round_resets.discards - 1
+ ease_discard(-1)
+ else
+ G.hand:change_size(-1)
+ end
+
+ -- positive effect: negative joker
+ if #editionless_jokers then
+ local eligible_card = pseudorandom_element(editionless_jokers, "ectoplasm")
+ eligible_card:set_edition({ negative = true })
+ end
+
+ card:juice_up(0.3, 0.5)
+ return true
+ end,
+ }))
+ end,
+ can_use = function(self, card)
+ return true
+ -- return G.GAME.round_resets.hands >= 1 and G.GAME.round_resets.discards >= 0
+ -- return next(SMODS.Edition:get_edition_cards(G.jokers, true))
+ end,
+ -- draw = function(self, card, layer)
+ -- -- This is for the Spectral shader. You don't need this with `set = "Spectral"`
+ -- -- Also look into SMODS.DrawStep if you make multiple cards that need the same shader
+ -- if (layer == "card" or layer == "both") and card.sprite_facing == "front" then
+ -- card.children.center:draw_shader("booster", nil, card.ARGS.send_to_shader)
+ -- end
+ -- end,
+})
diff --git a/objects/decks/00_violet.lua b/objects/decks/00_violet.lua
new file mode 100644
index 00000000..930bbb20
--- /dev/null
+++ b/objects/decks/00_violet.lua
@@ -0,0 +1,29 @@
+SMODS.Back({
+ key = "violet",
+ config = {},
+ atlas = "mp_decks",
+ pos = { x = 0, y = 0 },
+ mp_credits = { art = { "aura!" }, code = { "Toneblock" } },
+ apply = function(self)
+ SMODS.change_voucher_limit(1)
+ G.GAME.modifiers.mp_violet = true -- i forgot how you get the deck, whatever
+ end,
+})
+
+local set_cost_ref = Card.set_cost
+function Card:set_cost()
+ set_cost_ref(self)
+ if G.GAME.modifiers.mp_violet and self.config.center.set == "Voucher" then
+ if G.GAME.round_resets.ante == 1 then
+ self.cost = math.max(
+ 1,
+ math.floor(0.5 * (self.base_cost + self.extra_cost + 0.5) * (100 - G.GAME.discount_percent) / 100)
+ )
+ elseif G.GAME.round_resets.ante == 2 then
+ self.cost = math.max(
+ 1,
+ math.floor(0.7 * (self.base_cost + self.extra_cost + 0.5) * (100 - G.GAME.discount_percent) / 100)
+ )
+ end
+ end
+end
diff --git a/objects/decks/01_indigo.lua b/objects/decks/01_indigo.lua
new file mode 100644
index 00000000..c8f738aa
--- /dev/null
+++ b/objects/decks/01_indigo.lua
@@ -0,0 +1,108 @@
+SMODS.Back({
+ key = "indigo",
+ config = {},
+ atlas = "mp_decks",
+ pos = { x = 1, y = 0 },
+ mp_credits = { art = { "aura!" }, code = { "Toneblock" } },
+ apply = function(self)
+ G.GAME.modifiers.mp_indigo = true
+ G.GAME.modifiers.booster_choice_mod = (G.GAME.modifiers.booster_choice_mod or 0) + 1
+ G.GAME.banned_keys["j_red_card"] = true
+ end,
+})
+
+local function check_joker_space(card)
+ if card.config.center.set == "Joker" and card.edition and card.edition.negative then return true end
+ local c = 0
+ local un_c = G.jokers.config.card_limit
+ for i, v in ipairs(G.jokers.cards) do
+ if v.edition and v.edition.type == "negative" then
+ un_c = un_c - 1
+ elseif v.ability.eternal then
+ c = c + 1
+ else
+ break
+ end
+ end
+ return c < un_c
+end
+
+local function is_usable(card)
+ local center = card.config.center
+ local key = center.key
+ if center.set == "Enhanced" or center.set == "Default" or center.set == "Planet" then
+ return true
+ elseif center.set == "Joker" then
+ return check_joker_space(card)
+ elseif center.set == "Tarot" then
+ if key == "c_fool" then
+ return G.GAME.last_tarot_planet and G.GAME.last_tarot_planet ~= "c_fool"
+ elseif key == "c_judgement" then
+ return check_joker_space(card)
+ elseif key == "c_wheel_of_fortune" then
+ if card.eligible_strength_jokers and next(card.eligible_strength_jokers) then return true end
+ return false
+ elseif card.ability.consumeable.max_highlighted then
+ if #G.hand.cards >= (card.ability.consumeable.min_highlighted or 1) then return true end
+ return false
+ else
+ return true
+ end
+ elseif center.set == "Spectral" then
+ if
+ key == "c_familiar"
+ or key == "c_grim"
+ or key == "c_incantation"
+ or key == "c_immolate"
+ or key == "c_sigil"
+ or key == "c_ouija"
+ then
+ if #G.hand.cards > 1 then -- vanilla bug?
+ return true
+ end
+ return false
+ elseif key == "c_aura" then
+ local bool = false
+ for i, v in ipairs(G.hand.cards) do
+ if not v.edition then
+ bool = true
+ break
+ end
+ end
+ return bool
+ elseif key == "c_ectoplasm" or key == "c_hex" then
+ if card.eligible_editionless_jokers and next(card.eligible_editionless_jokers) then return true end
+ return false
+ elseif key == "c_wraith" or key == "c_soul" then
+ return check_joker_space(card)
+ elseif key == "c_ankh" then
+ if G.jokers.cards[1] then return check_joker_space(card) end
+ return false
+ elseif card.ability.consumeable.max_highlighted then
+ if #G.hand.cards >= (card.ability.consumeable.min_highlighted or 1) then return true end
+ return false
+ else
+ return true
+ end
+ end
+ return true -- hopefully no mod compat doesn't kill a run (it will)
+end
+
+local can_skip_ref = G.FUNCS.can_skip_booster
+G.FUNCS.can_skip_booster = function(e)
+ if G.GAME.modifiers.mp_indigo then
+ local softlock = true
+ for i, v in ipairs(G.pack_cards.cards) do
+ if is_usable(v) then
+ softlock = false
+ break
+ end
+ end
+ if not softlock then
+ e.config.colour = G.C.UI.BACKGROUND_INACTIVE
+ e.config.button = nil
+ return
+ end
+ end
+ return can_skip_ref(e)
+end
diff --git a/objects/decks/02_orange.lua b/objects/decks/02_orange.lua
new file mode 100644
index 00000000..c0470dba
--- /dev/null
+++ b/objects/decks/02_orange.lua
@@ -0,0 +1,44 @@
+SMODS.Back({
+ key = "orange",
+ config = { consumables = { "c_hanged_man", "c_hanged_man" } },
+ atlas = "mp_decks",
+ pos = { x = 2, y = 0 },
+ mp_credits = { art = { "aura!" }, code = { "Toneblock" } },
+ apply = function(self)
+ stop_use()
+ local lock = self.key
+ G.CONTROLLER.locks[lock] = true
+ -- "yeah just triple layer the event surely that works...WHAT THE SHIT"
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ local key = "p_arcana_mega_" .. (math.random(1, 2))
+ local card = Card(
+ G.play.T.x + G.play.T.w / 2 - G.CARD_W * 1.27 / 2,
+ G.play.T.y + G.play.T.h / 2 - G.CARD_H * 1.27 / 2,
+ G.CARD_W * 1.27,
+ G.CARD_H * 1.27,
+ G.P_CARDS.empty,
+ G.P_CENTERS["p_mp_standard_giga"],
+ { bypass_discovery_center = true, bypass_discovery_ui = true }
+ )
+ card.cost = 0
+ card.from_tag = true
+ delay(0.2)
+ G.FUNCS.use_card({ config = { ref_table = card } })
+ card:start_materialize()
+ G.CONTROLLER.locks[lock] = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ end,
+})
diff --git a/objects/decks/09_white.lua b/objects/decks/09_white.lua
new file mode 100644
index 00000000..9f914bf1
--- /dev/null
+++ b/objects/decks/09_white.lua
@@ -0,0 +1,43 @@
+
+--[[
+SMODS.Back({
+ key = "white",
+ config = {},
+ atlas = "mp_decks",
+ pos = { x = 2, y = 1 },
+ mp_credits = { art = { "aura!" }, code = { "Toneblock" } },
+ apply = function(self)
+ if MP.LOBBY.code then
+ G.GAME.modifiers.view_nemesis_deck = true
+ end
+ end,
+})
+
+-- new global table for white deck stuff because there's a lot of things and i want it to be as clean as possible
+-- some may argue it's worse. perhaps it is
+MP.WHITE = {
+ state = {
+ client = {},
+ nemesis = {},
+ }
+
+}
+
+function MP.WHITE.save_state()
+ local save = MP.WHITE.state.client
+
+ save.ante = G.GAME.round_resets.ante
+
+ local jokers_save = G.jokers:save()
+ save.jokers = MP.UTILS.str_pack_and_encode(jokers_save)
+
+ local deck_save = G.deck:save() -- gotta be careful that every card is actually in the deck at this point
+ save.deck = MP.UTILS.str_pack_and_encode(deck_save)
+end
+
+function MP.WHITE.send_state()
+end
+
+function MP.WHITE.request_state() -- need another function for "request until received"
+end
+]]
diff --git a/objects/decks/AA_oracle.lua b/objects/decks/AA_oracle.lua
new file mode 100644
index 00000000..35626a27
--- /dev/null
+++ b/objects/decks/AA_oracle.lua
@@ -0,0 +1,31 @@
+function oracle_apply_dollar_cap(mod, current_dollars, max_dollars)
+ return math.min(max_dollars - current_dollars, mod)
+end
+
+function oracle_should_show_max(mod, current_dollars, max_dollars)
+ return mod == 0 and current_dollars >= max_dollars
+end
+
+function oracle_show_max_alert(dollar_UI)
+ attention_text({
+ text = "MAX",
+ scale = 0.8,
+ hold = 0.7,
+ cover = dollar_UI.parent,
+ cover_colour = G.C.RED,
+ align = "cm",
+ })
+ play_sound("timpani", 0.9, 0.7)
+ play_sound("timpani", 1.2, 0.7)
+end
+
+SMODS.Back({
+ key = "oracle",
+ config = { vouchers = { "v_clearance_sale" }, consumables = { "c_medium" } },
+ atlas = "mp_decks",
+ pos = { x = 1, y = 1 },
+ apply = function(self)
+ G.GAME.modifiers.oracle_max = 50
+ end,
+ mp_credits = { art = { "aura!", "Ganpan140" }, code = { "Toneblock" } },
+})
diff --git a/objects/decks/BB_gradient.lua b/objects/decks/BB_gradient.lua
new file mode 100644
index 00000000..e931a61e
--- /dev/null
+++ b/objects/decks/BB_gradient.lua
@@ -0,0 +1,154 @@
+SMODS.Back({
+ key = "gradient",
+ config = {},
+ atlas = "mp_decks",
+ pos = { x = 0, y = 1 },
+ apply = function(self)
+ G.GAME.modifiers.mp_gradient = true -- i forgot how you get the deck, whatever
+ end,
+ mp_credits = { art = { "aura!", "Ganpan140" }, code = { "Toneblock" } },
+})
+
+-- we need to define a bunch of local functions first for some reason
+
+-- water is wet
+local function set_temp_id(card, key)
+ if not card.orig_id then card.orig_id = card.base.id end
+ card.base.id = card.orig_id + G.MP_GRADIENT
+ if key ~= "j_raised_fist" then -- otherwise it gets confused and triggers a second card
+ if card.base.id == 15 then
+ card.base.id = 2
+ elseif card.base.id == 1 then
+ card.base.id = 14
+ end
+ end
+end
+
+-- if code runs twice it needs a func ig
+local function reset_ids()
+ if G.MP_GRADIENT then
+ G.MP_GRADIENT = nil
+ for i, card in ipairs(G.playing_cards) do
+ card.base.id = card.orig_id
+ card.orig_id = nil
+ end
+ end
+ -- i am checking for G.MP_GRADIENT here. why?
+ -- i know this code. i built it myself. this function will always be run at the correct time.
+ -- or so i thought. alas, the day after release, i was humbled by a crashlog relating to this exact issue.
+ -- i have reordered the code before, this was nothing new. i knew exactly what it was caused by, and i recreated it fairly easily.
+ -- but the logic has expanded in complexity since then. no more can i follow where one line ends and the other begins.
+ -- the code was watered with passion and motivation, but it grew into thick, thorny vines that stab my hands and block my vision.
+ -- there is only pain for the person who decides to touch this. the code does not have emotion, it does not care.
+ -- in some twisted way, i am attached to it. it falls short of the greatness expected, but it accomplishes so much.
+ -- as a gift, for all it has done, i shift it, ever so slightly, that it thrives.
+
+ -- tldr: i have no idea what this shit is doing anymore, this check shouldn't be necessary
+ -- checking this prevents a crash and doesn't seem to break anything
+end
+
+local function get_bp(joker)
+ local key = joker.config.center.key
+ local count = 0
+ local pos = 0
+ for i, v in ipairs(G.jokers.cards) do
+ if v == joker then pos = i end
+ end
+ while (key == "j_blueprint" or key == "j_brainstorm") and count <= #G.jokers.cards do
+ if key == "j_blueprint" then
+ key = G.jokers.cards[pos + 1] and G.jokers.cards[pos + 1].config.center.key or "NULL"
+ pos = pos + 1
+ elseif key == "j_brainstorm" then
+ key = G.jokers.cards[1].config.center.key
+ pos = 1
+ end
+ count = count + 1
+ end
+ return key
+end
+
+-- hardcoded dumb stuff, because cards that could trigger but don't due to rng are dumb and stupid and don't return anything
+-- ALSO i have to add a whole get blueprint key thing (above) it's so stupid
+-- all this to avoid lovely patching? who cares
+local function valid_trigger(card, joker)
+ local key = joker.config.center.key
+ key = get_bp(joker)
+ local function rank_check(ranks)
+ for i, v in ipairs(ranks) do
+ if card:get_id() == v then return true end
+ end
+ return
+ end
+ if card and not card.base then -- ??????????????????????
+ return false
+ end
+ if key == "j_8_ball" then
+ return rank_check({ 8 }) -- this being a table looks stupid now
+ elseif key == "j_hit_the_road" then
+ return rank_check({ 11 })
+ elseif key == "j_business" or key == "j_reserved_parking" then
+ return card:is_face()
+ elseif key == "j_bloodstone" or key == "j_mp_bloodstone" or key == "j_mp_bloodstone2" then
+ return card:is_suit("Hearts")
+ end
+end
+
+local is_face_ref = Card.is_face
+function Card:is_face(from_boss)
+ local ret = is_face_ref(self, from_boss)
+ if G.GAME.modifiers.mp_gradient and not G.MP_GRADIENT then
+ local id = self:get_id() -- like seriously i want an explanation
+ if self.debuff and not from_boss then return end
+ if not ret and id == 10 or id == 14 then return true end
+ end
+ return ret
+end
+
+-- hardcoded functions because honk shoo
+local function passkey(joker)
+ local key = get_bp(joker)
+ if key == "j_superposition" or key == "j_sixth_sense" then return true end
+ return false
+end
+local function blacklist(joker)
+ local key = get_bp(joker)
+ if key == "j_photograph" or key == "j_faceless" or key == "j_ramen" then return true end
+ return false
+end
+
+-- infamous calculate joker hook
+local calculate_joker_ref = Card.calculate_joker
+function Card:calculate_joker(context)
+ if not context.blueprint then -- very important because bloopy recursively calls this
+ if G.GAME.modifiers.mp_gradient and (context.other_card or passkey(self)) and not blacklist(self) then
+ for i = 1, 3 do
+ G.MP_GRADIENT = -i + 2
+ for i, card in ipairs(G.playing_cards) do -- it's actually insane that this doesn't blow up the game??? this is being run thousands of times wastefully
+ set_temp_id(card, self.config.center.key)
+ end
+ local ret, post = calculate_joker_ref(self, context)
+ if ret or post or valid_trigger(context.other_card, self) then
+ reset_ids()
+ return ret, post
+ end
+ end
+ reset_ids()
+ end
+ end
+ return calculate_joker_ref(self, context)
+end
+
+-- a special hardcoded hook just for cloud nine! hook hook, hooray!
+local update_ref = Card.update
+function Card:update(dt)
+ local ret = update_ref(self, dt)
+ if G.GAME.modifiers.mp_gradient then
+ if self.ability.name == "Cloud 9" then
+ self.ability.nine_tally = 0
+ for k, v in pairs(G.playing_cards) do
+ local id = v:get_id()
+ if id == 8 or id == 9 or id == 10 then self.ability.nine_tally = self.ability.nine_tally + 1 end
+ end
+ end
+ end
+end
diff --git a/objects/decks/CC_heidelberg.lua b/objects/decks/CC_heidelberg.lua
new file mode 100644
index 00000000..bef4b083
--- /dev/null
+++ b/objects/decks/CC_heidelberg.lua
@@ -0,0 +1,27 @@
+SMODS.Atlas({
+ key = "b_heidelberg",
+ path = "b_heidelberg.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Back({
+ key = "heidelberg",
+ atlas = "b_heidelberg",
+ mp_credits = { art = { "aura!" }, code = { "steph" } },
+ calculate = function(self, back, context)
+ if context.ending_shop and G.consumeables.cards[1] then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ local card_to_copy, _ = pseudorandom_element(G.consumeables.cards, "mp_heidelberg")
+ local copied_card = copy_card(card_to_copy)
+ copied_card:set_edition("e_negative", true)
+ copied_card:add_to_deck()
+ G.consumeables:emplace(copied_card)
+ return true
+ end,
+ }))
+ return { message = localize("k_duplicated_ex") }
+ end
+ end,
+})
diff --git a/objects/decks/EC_echo_deck.lua b/objects/decks/EC_echo_deck.lua
new file mode 100644
index 00000000..44a50cb1
--- /dev/null
+++ b/objects/decks/EC_echo_deck.lua
@@ -0,0 +1,57 @@
+SMODS.Atlas({
+ key = "ec_other_sandbox",
+ path = "ec_other_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Back({ --Echo Deck
+ name = "Echo Deck",
+ key = "echodeck",
+ loc_txt = {
+ name = "Echo Deck",
+ text = {
+ "{C:attention}Retrigger{} all playing cards",
+ "{C:red}X1.2{} base Blind size",
+ "Increases by {C:red}X0.2{} each Ante",
+ },
+ },
+ order = 18,
+ unlocked = true,
+ discovered = true,
+ config = {},
+ loc_vars = function(self, info_queue, center)
+ return { vars = {} }
+ end,
+ pos = { x = 2, y = 0 },
+ atlas = "ec_other_sandbox",
+ apply = function(self, back)
+ G.GAME.starting_params.ante_scaling = 1.2
+ end,
+
+ calculate = function(self, back, context)
+ if context.cardarea == G.play and context.repetition then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = 1,
+ card = card,
+ }
+ elseif context.repetition and context.cardarea == G.hand then
+ if next(context.card_effects[1]) or #context.card_effects > 1 then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = 1,
+ card = card,
+ }
+ end
+ end
+
+ if context.end_of_round and not context.repetition and not context.individual and G.GAME.blind.boss then
+ G.GAME.starting_params.ante_scaling = G.GAME.starting_params.ante_scaling + 0.2
+ end
+ end,
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "neatoqueen" },
+ },
+})
diff --git a/objects/decks/ZZ_cocktail.lua b/objects/decks/ZZ_cocktail.lua
new file mode 100644
index 00000000..46a6008b
--- /dev/null
+++ b/objects/decks/ZZ_cocktail.lua
@@ -0,0 +1,648 @@
+SMODS.Back({
+ key = "cocktail",
+ config = {},
+ atlas = "mp_decks",
+ pos = { x = 4, y = 0 },
+ mod_whitelist = {
+ Multiplayer = true,
+ },
+ apply = function(self)
+ -- we need to fucking generate the seed early this is infuriating
+ local seed = G._MP_SET_SEED
+ local seeded = false
+ if seed then seeded = true end
+ G.GAME.pseudorandom.seed = seed or generate_starting_seed()
+ G.GAME.modifiers.mp_cocktail = {}
+ G.GAME.modifiers.mp_cocktail_sticker = {}
+ local decks, forced = MP.get_cocktail_decks(true)
+ pseudoshuffle(decks, pseudoseed("mp_cocktail"))
+ local back = G.GAME.selected_back
+
+ local function add_deck(num, deck, sticker)
+ G.GAME.modifiers.mp_cocktail[num] = deck
+ if sticker then G.GAME.modifiers.mp_cocktail_sticker[num] = deck end
+ if deck == "b_checkered" then -- hardcoded because cringe
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ for k, v in pairs(G.playing_cards) do
+ if v.base.suit == "Clubs" then v:change_suit("Spades") end
+ if v.base.suit == "Diamonds" then v:change_suit("Hearts") end
+ end
+ return true
+ end,
+ }))
+ end
+ end
+
+ for i = 1, #forced do
+ add_deck(i, forced[i], true)
+ end
+ for i = 1 + #forced, math.min(3, #decks) do
+ add_deck(i, decks[i], MP.cocktail_cfg_readpos("show", true) ~= "H" and true or false)
+ end
+ local function merge(t1, t2, safe)
+ local t3 = {}
+ for k, v in pairs(t1) do
+ if type(v) == "table" then
+ t3[k] = merge(v, {})
+ else
+ t3[k] = v
+ end
+ end
+ for k, v in pairs(t2) do
+ local existing = t3[k]
+
+ if type(existing) == "number" and type(v) == "number" then
+ t3[k] = existing + v
+ elseif type(existing) == "table" and type(v) == "table" then
+ t3[k] = merge(existing, v, true) -- risky but it works...
+ else
+ if type(v) == "table" then
+ t3[k] = merge(v, {})
+ else
+ local index = safe and #t3 + 1 or k
+ t3[index] = v
+ end
+ end
+ end
+ return t3
+ end
+ for i = 1, #G.GAME.modifiers.mp_cocktail do
+ back.effect.config = merge(back.effect.config, G.P_CENTERS[G.GAME.modifiers.mp_cocktail[i]].config)
+ if back.effect.config.voucher then
+ back.effect.config.vouchers = back.effect.config.vouchers or {}
+ back.effect.config.vouchers[#back.effect.config.vouchers + 1] = back.effect.config.voucher
+ back.effect.config.voucher = nil
+ end
+ local obj = G.P_CENTERS[G.GAME.modifiers.mp_cocktail[i]]
+ if obj.apply and type(obj.apply) == "function" then obj:apply(back) end
+ end
+ if MP.is_ruleset_active("smallworld") then MP.apply_fake_back_vouchers(back) end
+ back.effect.mp_cocktailed = true
+ if MP.cocktail_check_edited() then G.GAME.seeded = true end
+ end,
+ calculate = function(self, back, context)
+ for i = 1, #G.GAME.modifiers.mp_cocktail do
+ back:change_to(G.P_CENTERS[G.GAME.modifiers.mp_cocktail[i]])
+ local ret1, ret2 = back:trigger_effect(context)
+ back:change_to(G.P_CENTERS["b_mp_cocktail"])
+ if ret1 or ret2 then return ret1, ret2 end
+ end
+ end,
+ mp_credits = { art = { "aura!", "shai1n" }, code = { "Toneblock" } },
+})
+
+-- honestly this could have been a list
+-- whatever
+local sticker_x_pos = {
+ b_red = 0,
+ b_blue = 1,
+ b_yellow = 2,
+ b_green = 3,
+ b_black = 4,
+ b_magic = 5,
+ b_nebula = 6,
+ b_ghost = 7,
+ b_abandoned = 8,
+ b_checkered = 9,
+ b_zodiac = 10,
+ b_painted = 11,
+ b_anaglyph = 12,
+ b_plasma = 13,
+ b_erratic = 14,
+ b_mp_orange = 15,
+ b_mp_indigo = 16,
+ b_mp_violet = 17,
+ b_mp_white = 18,
+ b_mp_oracle = 19,
+ b_mp_gradient = 20,
+ b_mp_heidelberg = 21,
+ b_mp_echodeck = 22,
+}
+
+function MP.get_cocktail_decks(cull)
+ local ret = {}
+ local forced = {}
+ for k, v in pairs(G.P_CENTERS) do
+ if v.set == "Back" and k ~= "b_challenge" and k ~= "b_mp_cocktail" and sticker_x_pos[k] then
+ if not (v.mod and not G.P_CENTERS["b_mp_cocktail"].mod_whitelist[v.mod.id]) then ret[#ret + 1] = k end
+ end
+ end
+ table.sort(ret, function(a, b)
+ return G.P_CENTERS[a].order < G.P_CENTERS[b].order
+ end)
+ if cull then
+ local _ret = {}
+ for i, v in ipairs(ret) do
+ if MP.cocktail_cfg_readpos(i, true) == "1" then
+ _ret[#_ret + 1] = ret[i]
+ elseif MP.cocktail_cfg_readpos(i, true) == "2" then
+ forced[#forced + 1] = ret[i]
+ end
+ end
+ ret = _ret
+ end
+ return ret, forced
+end
+
+local change_to_ref = Back.change_to
+function Back:change_to(new_back)
+ if self.effect.mp_cocktailed then
+ local t = copy_table(self.effect.config)
+ local ret = change_to_ref(self, new_back)
+ self.effect.config = copy_table(t)
+ self.effect.mp_cocktailed = true
+ return ret
+ end
+ return change_to_ref(self, new_back)
+end
+
+local function is_cocktail_select(card)
+ if Galdur then
+ return Galdur.run_setup
+ and card.area == Galdur.run_setup.selected_deck_area
+ and card.config.center.key == "b_mp_cocktail"
+ else
+ return G.GAME.viewed_back
+ and G.GAME.viewed_back.effect
+ and G.GAME.viewed_back.effect.center.key == "b_mp_cocktail"
+ and card.facing == "back"
+ end
+end
+
+local click_ref = Card.click
+function Card:click() -- i'd rather deal with the cardarea but this is fine i suppose
+ click_ref(self)
+ if G.STAGE == G.STAGES.MAIN_MENU then
+ if is_cocktail_select(self) then
+ -- boilerplate robbed from cryptid's decaying corpse
+ if G.cocktail_select then
+ for i = 1, #G.cocktail_select do
+ G.cocktail_select[i]:remove()
+ G.cocktail_select[i] = nil
+ end
+ end
+ G.cocktail_select = {}
+ for i = 1, 2 do
+ G.cocktail_select[i] = CardArea(
+ G.ROOM.T.x + 0.2 * G.ROOM.T.w / 1.5,
+ G.ROOM.T.h,
+ 5.3 * G.CARD_W,
+ 1.03 * G.CARD_H,
+ { card_limit = 5, type = "title", highlight_limit = 999, collection = true }
+ )
+ end
+ local decks = MP.get_cocktail_decks()
+ local cfg = SMODS.Mods["Multiplayer"].config
+ for i, v in ipairs(decks) do
+ local row = math.floor((((i - 1) / #decks) * 2) + 1)
+ G.GAME.viewed_back = G.P_CENTERS[v]
+ local card = Card(
+ G.ROOM.T.x + 0.2 * G.ROOM.T.w / 2,
+ G.ROOM.T.h,
+ G.CARD_W,
+ G.CARD_H,
+ pseudorandom_element(G.P_CARDS),
+ G.P_CENTERS.c_base,
+ { playing_card = i, bypass_back = G.P_CENTERS[v].pos }
+ )
+ G.cocktail_select[row]:emplace(card)
+ card.sprite_facing = "back"
+ card.facing = "back"
+ card.mp_cocktail_select = v
+ local num = MP.cocktail_cfg_readpos(i)
+ card.highlighted = tonumber(num) >= 1 and true or false
+ card.mp_cocktail_forced = num == "2" and true or false
+ end
+ G.GAME.viewed_back = G.P_CENTERS["b_mp_cocktail"]
+ MP.show_cocktail_decks = MP.cocktail_cfg_readpos("show") ~= "H" and true or false
+ deck_tables = {}
+ for i = 1, #G.cocktail_select do
+ deck_tables[i] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0, no_fill = true },
+ nodes = {
+ { n = G.UIT.O, config = { object = G.cocktail_select[i] } },
+ },
+ }
+ end
+ local t = create_UIBox_generic_options({
+ back_func = "setup_run",
+ snap_back = true,
+ contents = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.0,
+ align = "cl",
+ },
+ nodes = {
+ create_toggle({
+ id = "show_cocktail_decks",
+ label = "Show active decks during run",
+ ref_table = MP,
+ ref_value = "show_cocktail_decks",
+ callback = function(bool)
+ MP.cocktail_cfg_edit(bool, "show")
+ end,
+ }),
+ },
+ },
+ { n = G.UIT.R, config = { align = "cl", padding = 0.4, minh = 0.4 } }, -- empty space row because i'm bad at ui
+ {
+ n = G.UIT.R,
+ config = { align = "cm", minw = 2.5, padding = 0.1, r = 0.1, colour = G.C.BLACK, emboss = 0.05 },
+ nodes = deck_tables,
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = localize("k_cocktail_select"), scale = 0.48, colour = G.C.WHITE },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = localize("k_cocktail_shiftclick"), scale = 0.32, colour = G.C.WHITE },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cl", padding = 0 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = localize("k_cocktail_rightclick"), scale = 0.32, colour = G.C.WHITE },
+ },
+ },
+ },
+ },
+ })
+ G.FUNCS.overlay_menu({
+ definition = t,
+ })
+ end
+ end
+end
+
+local draw_ref = Card.draw
+function Card:draw(layer)
+ draw_ref(self, layer)
+ if G.STAGE == G.STAGES.MAIN_MENU then
+ if not self.children.view_deck then
+ self.children.view_deck = UIBox({
+ definition = {
+ n = G.UIT.ROOT,
+ config = { align = "cm", padding = 0.1, r = 0.1, colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.05,
+ r = 0.1,
+ colour = adjust_alpha(G.C.BLACK, 0.5),
+ func = "set_button_pip",
+ focus_args = { button = "triggerright", orientation = "bm", scale = 0.6 },
+ button = "deck_info",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_edit"),
+ scale = 0.48,
+ colour = G.C.WHITE,
+ shadow = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_deck"),
+ scale = 0.38,
+ colour = G.C.WHITE,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ config = { align = "cm", offset = { x = 0, y = 0 }, major = self, parent = self },
+ })
+ self.children.view_deck.states.collide.can = false
+ end
+ local bool = self.states.hover.is and is_cocktail_select(self)
+ self.children.view_deck.states.visible = bool
+ end
+end
+
+SMODS.DrawStep({
+ key = "mp_cocktail_forced",
+ order = 5,
+ func = function(self)
+ if self.mp_cocktail_forced then self.children.back:draw_shader("foil", nil, self.ARGS.send_to_shader) end
+ end,
+ conditions = { vortex = false, facing = "back" },
+})
+
+local hover_ref = Card.hover
+function Card:hover()
+ hover_ref(self)
+ if self.mp_cocktail_select then
+ self.ability_UIBox_table = self:generate_UIBox_ability_table()
+ self.config.h_popup = G.UIDEF.card_h_popup(self)
+ self.config.h_popup_config = self:align_h_popup()
+ Node.hover(self)
+ end
+end
+
+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)
+ if card and card.mp_cocktail_select then
+ _c = G.P_CENTERS[card.mp_cocktail_select]
+ local ret = generate_card_ui_ref(
+ _c,
+ full_UI_table,
+ specific_vars,
+ "Back",
+ badges,
+ hide_desc,
+ main_start,
+ main_end,
+ card
+ )
+ if not _c.generate_ui or type(_c.generate_ui) ~= "function" then
+ -- i literally can't get the vars from anywhere else so we're thunk coding
+
+ local name_to_check = G.P_CENTERS[card.mp_cocktail_select].name
+
+ if name_to_check == "Blue Deck" then
+ specific_vars = { _c.config.hands }
+ elseif name_to_check == "Red Deck" then
+ specific_vars = { _c.config.discards }
+ elseif name_to_check == "Yellow Deck" then
+ specific_vars = { _c.config.dollars }
+ elseif name_to_check == "Green Deck" then
+ specific_vars = { _c.config.extra_hand_bonus, _c.config.extra_discard_bonus }
+ elseif name_to_check == "Black Deck" then
+ specific_vars = { _c.config.joker_slot, -_c.config.hands }
+ elseif name_to_check == "Magic Deck" then
+ specific_vars = {
+ localize({ type = "name_text", key = "v_crystal_ball", set = "Voucher" }),
+ localize({ type = "name_text", key = "c_fool", set = "Tarot" }),
+ }
+ elseif name_to_check == "Nebula Deck" then
+ specific_vars = { localize({ type = "name_text", key = "v_telescope", set = "Voucher" }), -1 }
+ elseif name_to_check == "Zodiac Deck" then
+ specific_vars = {
+ localize({ type = "name_text", key = "v_tarot_merchant", set = "Voucher" }),
+ localize({ type = "name_text", key = "v_planet_merchant", set = "Voucher" }),
+ localize({ type = "name_text", key = "v_overstock_norm", set = "Voucher" }),
+ }
+ elseif name_to_check == "Painted Deck" then
+ specific_vars = { _c.config.hand_size, _c.config.joker_slot }
+ elseif name_to_check == "Anaglyph Deck" then
+ specific_vars = { localize({ type = "name_text", key = "tag_double", set = "Tag" }) }
+ elseif name_to_check == "Plasma Deck" then
+ specific_vars = { _c.config.ante_scaling }
+ end
+
+ localize({ type = "descriptions", key = _c.key, set = _c.set, nodes = ret.main, vars = specific_vars })
+ end
+ return ret
+ end
+ return generate_card_ui_ref(
+ _c,
+ full_UI_table,
+ specific_vars,
+ card_type,
+ badges,
+ hide_desc,
+ main_start,
+ main_end,
+ card
+ )
+end
+
+local can_highlight_ref = CardArea.can_highlight
+function CardArea:can_highlight(card)
+ if card.mp_cocktail_select then return true end
+ return can_highlight_ref(self, card)
+end
+
+local highlight_ref = Card.highlight
+function Card:highlight(is_highlighted)
+ if self.mp_cocktail_select then
+ local shift = G.CONTROLLER.held_keys["lshift"] or G.CONTROLLER.held_keys["rshift"]
+ if shift and self.mp_cocktail_forced then
+ is_highlighted = false
+ self.mp_cocktail_forced = false
+ elseif self.mp_cocktail_forced then
+ is_highlighted = true
+ self.mp_cocktail_forced = false
+ elseif shift then
+ is_highlighted = true
+ if MP.cocktail_get_forced_num() < 3 then
+ self.mp_cocktail_forced = true
+ play_sound("foil1", 1.5, 0.3)
+ else
+ play_sound("timpani", 0.9, 0.7)
+ play_sound("timpani", 1.2, 0.7)
+ end
+ end
+ MP.cocktail_cfg_edit(self.mp_cocktail_forced and 2 or is_highlighted, self.mp_cocktail_select)
+ end
+ return highlight_ref(self, is_highlighted)
+end
+
+local r_cursor_press_ref = Controller.queue_R_cursor_press
+function Controller:queue_R_cursor_press(x, y)
+ local ret = r_cursor_press_ref(self, x, y)
+ if G.cocktail_select and G.cocktail_select[1].cards then -- bruh
+ local highlight = true
+ for i = 1, #G.cocktail_select do
+ for j = 1, #G.cocktail_select[i].cards do
+ if G.cocktail_select[i].cards[j].highlighted then
+ highlight = false
+ break
+ end
+ end
+ if not highlight then break end
+ end
+ for i = 1, #G.cocktail_select do
+ for j = 1, #G.cocktail_select[i].cards do
+ G.cocktail_select[i].cards[j].highlighted = highlight
+ G.cocktail_select[i].cards[j].mp_cocktail_forced = false
+ end
+ end
+ if highlight then
+ play_sound("cardSlide1")
+ else
+ play_sound("cardSlide2", nil, 0.3)
+ end
+ MP.cocktail_cfg_edit(highlight)
+ end
+ return ret
+end
+
+-- kill me
+G.E_MANAGER:add_event(Event({
+ func = function()
+ local decks = MP.get_cocktail_decks()
+ local cfg = SMODS.Mods["Multiplayer"].config
+ if (not cfg.cocktail) or #decks + 1 ~= #cfg.cocktail then
+ local string = ""
+ for i = 1, #decks do
+ string = string .. "1"
+ end
+ string = string .. "H"
+ cfg.cocktail = string
+ end
+ SMODS.save_mod_config(SMODS.Mods["Multiplayer"])
+ return true
+ end,
+}))
+
+function MP.cocktail_cfg_edit(bool, deck) -- strings are easier to send, and it's just ones and zeroes
+ local decks = MP.get_cocktail_decks()
+ local cfg = SMODS.Mods["Multiplayer"].config
+ local num = (bool == 2) and "2" or (bool and "1" or "0")
+ if not deck then
+ local string = ""
+ for i = 1, #decks do
+ string = string .. num
+ end
+ local show = MP.cocktail_cfg_readpos("show")
+ string = string .. show
+ cfg.cocktail = string
+ else
+ local function replace(str, pos, d)
+ return str:sub(1, pos - 1) .. d .. str:sub(pos + 1)
+ end
+ for i, v in ipairs(decks) do
+ if v == deck then
+ cfg.cocktail = replace(cfg.cocktail, i, num)
+ break
+ end
+ end
+ if deck == "show" then cfg.cocktail = replace(cfg.cocktail, #cfg.cocktail, bool and "S" or "H") end
+ end
+ MP.LOBBY.config.cocktail = cfg.cocktail
+ SMODS.save_mod_config(SMODS.Mods["Multiplayer"])
+end
+
+function MP.cocktail_cfg_readpos(pos, construct)
+ local decks = MP.get_cocktail_decks() -- copypasted code. unsure how to make this less messy without making it more messy
+ local cfg = SMODS.Mods["Multiplayer"].config
+ if pos == "show" then pos = #cfg.cocktail end
+ if construct then return MP.cocktail_cfg_get():sub(pos, pos) end
+ return cfg.cocktail:sub(pos, pos)
+end
+
+function MP.cocktail_cfg_get()
+ if MP.LOBBY.code and MP.LOBBY.deck and MP.LOBBY.deck.cocktail then
+ return MP.LOBBY.deck.cocktail
+ else
+ return SMODS.Mods["Multiplayer"].config.cocktail
+ end
+end
+
+function MP.cocktail_check_edited()
+ local str = MP.cocktail_cfg_get()
+ for i = 1, #str - 1 do
+ if string.sub(str, i, i) ~= "1" then return true end
+ end
+ if string.sub(str, #str, #str) ~= "H" then return true end
+ return false
+end
+
+function MP.cocktail_get_forced_num()
+ local str = SMODS.Mods["Multiplayer"].config.cocktail
+ local c = 0
+ for i = 1, #str - 1 do
+ if string.sub(str, i, i) == "2" then c = c + 1 end
+ end
+ return c
+end
+
+local localize_ref = localize
+function localize(args, misc_cat)
+ if args and type(args) == "table" and args.key then
+ local ret = localize_ref(args, misc_cat)
+ local key = args.key or args.node and args.node.config.center.key or "NULL"
+ if args.type == "name_text" and key == "b_mp_cocktail" then
+ if MP.cocktail_check_edited() then return ret .. "*" end
+ end
+ return ret
+ else
+ return localize_ref(args, misc_cat)
+ end
+end
+
+SMODS.Atlas({
+ key = "cocktail_deck_stickers",
+ path = "deck_stickers.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.DrawStep({
+ key = "back_cocktail",
+ order = 11,
+ func = function(self)
+ if G.STAGE == G.STAGES.RUN and G.GAME and G.GAME.modifiers and G.GAME.modifiers.mp_cocktail_sticker then
+ if self.area and self.area.config.type == "deck" then
+ for i, v in ipairs(G.GAME.modifiers.mp_cocktail_sticker) do
+ local num = math.min(i, 3)
+ local key = "mp_cocktail_" .. v .. num
+ if not G.shared_stickers[key] then
+ G.shared_stickers[key] = Sprite(
+ 0,
+ 0,
+ G.CARD_W,
+ G.CARD_H,
+ G.ASSET_ATLAS["mp_cocktail_deck_stickers"],
+ { x = sticker_x_pos[v], y = num - 1 }
+ )
+ end
+ G.shared_stickers[key].role.draw_major = self
+ local sticker_offset = self.sticker_offset or {}
+ G.shared_stickers[key]:draw_shader(
+ "dissolve",
+ nil,
+ nil,
+ true,
+ self.children.center,
+ nil,
+ self.sticker_rotation,
+ sticker_offset.x,
+ sticker_offset.y
+ )
+ end
+ end
+ end
+ end,
+ conditions = { vortex = false, facing = "back" },
+})
diff --git a/objects/decks/_decks.lua b/objects/decks/_decks.lua
new file mode 100644
index 00000000..7e9877ac
--- /dev/null
+++ b/objects/decks/_decks.lua
@@ -0,0 +1,42 @@
+SMODS.Atlas({
+ key = "decks",
+ path = "decks.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.DrawStep({
+ key = "back_multiplayer",
+ order = 11,
+ func = function(self)
+ if not Galdur and G.GAME.viewed_back and G.GAME.viewed_back.effect and G.GAME.viewed_back.effect.center.mod then
+ if G.GAME.viewed_back.effect.center.mod.id == "Multiplayer" and G.STAGE == G.STAGES.MAIN_MENU then
+ G.shared_stickers["mp_sticker_balanced"].role.draw_major = self
+ local sticker_offset = self.sticker_offset or {}
+ G.shared_stickers["mp_sticker_balanced"]:draw_shader(
+ "dissolve",
+ nil,
+ nil,
+ true,
+ self.children.center,
+ nil,
+ self.sticker_rotation,
+ sticker_offset.x,
+ sticker_offset.y
+ )
+ G.shared_stickers["mp_sticker_balanced"]:draw_shader(
+ "voucher",
+ nil,
+ self.ARGS.send_to_shader,
+ true,
+ self.children.center,
+ nil,
+ self.sticker_rotation,
+ sticker_offset.x,
+ sticker_offset.y
+ )
+ end
+ end
+ end,
+ conditions = { vortex = false, facing = "back" },
+})
diff --git a/objects/editions/phantom.lua b/objects/editions/phantom.lua
new file mode 100644
index 00000000..be932bd1
--- /dev/null
+++ b/objects/editions/phantom.lua
@@ -0,0 +1,43 @@
+function apply_phantom(card)
+ card.ability.eternal = true
+ card.ability.mp_sticker_nemesis = true
+end
+
+function remove_phantom(card)
+ card.ability.eternal = false
+ card.ability.mp_sticker_nemesis = false
+end
+
+SMODS.Edition({
+ key = "phantom",
+ shader = "voucher",
+ discovered = true,
+ unlocked = true,
+ config = {},
+ in_shop = false,
+ apply_to_float = true,
+ badge_colour = G.C.PURPLE,
+ sound = { sound = "negative", per = 1.5, vol = 0.4 },
+ disable_shadow = false,
+ disable_base_shader = true,
+ extra_cost = 0, -- Min sell value is set to -1 by Multiplayer (1 by default) so this is a hack to make the card this is applied to not have a sell value
+ on_apply = apply_phantom,
+ on_remove = remove_phantom,
+ on_load = apply_phantom,
+ prefix_config = { shader = false },
+ mp_credits = {
+ idea = { "Virtualized" },
+ art = { "Carter" },
+ code = { "Virtualized" },
+ },
+})
+
+local get_card_areas_ref = SMODS.get_card_areas
+function SMODS.get_card_areas(_type, _context)
+ if _type == "jokers" and MP.shared then
+ local t = get_card_areas_ref(_type, _context)
+ table.insert(t, MP.shared)
+ return t
+ end
+ return get_card_areas_ref(_type, _context)
+end
diff --git a/objects/enhancements/mp_glass.lua b/objects/enhancements/mp_glass.lua
new file mode 100644
index 00000000..80f26107
--- /dev/null
+++ b/objects/enhancements/mp_glass.lua
@@ -0,0 +1,58 @@
+MP.ReworkCenter("m_glass", {
+ rulesets = MP.UTILS.get_standard_rulesets(),
+ config = { Xmult = 1.5, extra = 4 },
+})
+
+MP.ReworkCenter("m_glass", {
+ rulesets = "sandbox",
+ config = { Xmult = 1.5, extra = 3 },
+})
+
+MP.ReworkCenter("m_glass", {
+ rulesets = "legacy_ranked",
+ config = { Xmult = 1.5, extra = 4 },
+})
+
+-- This is a glass that is permanently at X1.5 to be shown in ruleset descriptions
+-- (Because glass will show at X2 in rulesets otherwise)
+SMODS.Enhancement({
+ key = "display_glass",
+ config = { extra = { Xmult = 1.5, extra = 4 }, mp_sticker_balanced = true },
+ pos = { x = 5, y = 1 },
+ no_collection = true,
+ shatters = true,
+ loc_vars = function(self, info_queue, card)
+ local num, denom = SMODS.get_probability_vars(card, 1, card.ability.extra.extra, "glass")
+ return {
+ vars = {
+ card.ability.extra.Xmult,
+ num,
+ denom,
+ },
+ }
+ end,
+ in_pool = function(self, args)
+ return false
+ end,
+})
+
+SMODS.Enhancement({
+ key = "sandbox_display_glass",
+ config = { extra = { Xmult = 1.5, extra = 3 }, mp_sticker_balanced = true },
+ pos = { x = 5, y = 1 },
+ no_collection = true,
+ shatters = true,
+ loc_vars = function(self, info_queue, card)
+ local num, denom = SMODS.get_probability_vars(card, 1, card.ability.extra.extra, "glass")
+ return {
+ vars = {
+ card.ability.extra.Xmult,
+ num,
+ denom,
+ },
+ }
+ end,
+ in_pool = function(self, args)
+ return false
+ end,
+})
diff --git a/objects/jokers/conjoined_joker.lua b/objects/jokers/conjoined_joker.lua
new file mode 100644
index 00000000..32b903c8
--- /dev/null
+++ b/objects/jokers/conjoined_joker.lua
@@ -0,0 +1,65 @@
+SMODS.Atlas({
+ key = "conjoined_joker",
+ path = "j_conjoined_joker.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "conjoined_joker",
+ atlas = "conjoined_joker",
+ rarity = 2,
+ cost = 6,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { extra = { x_mult_gain = 0.5, max_x_mult = 3, x_mult = 1 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = { card.ability.extra.x_mult_gain, card.ability.extra.max_x_mult, card.ability.extra.x_mult } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_ruleset_active("sandbox")
+ end,
+ add_to_deck = function(self, card, from_debuffed)
+ if not from_debuffed and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.send_phantom("j_mp_conjoined_joker")
+ end
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ if not from_debuff and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.remove_phantom("j_mp_conjoined_joker")
+ end
+ end,
+ update = function(self, card, dt)
+ if MP.LOBBY.code then
+ if G.STAGE == G.STAGES.RUN then
+ card.ability.extra.x_mult = math.max(
+ math.min(1 + (MP.GAME.enemy.hands * card.ability.extra.x_mult_gain), card.ability.extra.max_x_mult),
+ 1
+ )
+ end
+ else
+ card.ability.extra.x_mult = 1
+ end
+ end,
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.jokers
+ and context.joker_main
+ and MP.is_pvp_boss()
+ and (not card.edition or card.edition.type ~= "mp_phantom")
+ then
+ return {
+ x_mult = card.ability.extra.x_mult,
+ }
+ end
+ end,
+ mp_credits = {
+ idea = { "Zilver" },
+ art = { "Nas4xou" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/defensive_joker.lua b/objects/jokers/defensive_joker.lua
new file mode 100644
index 00000000..1257c207
--- /dev/null
+++ b/objects/jokers/defensive_joker.lua
@@ -0,0 +1,54 @@
+SMODS.Atlas({
+ key = "defensive_joker",
+ path = "j_defensive_joker.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "defensive_joker",
+ atlas = "defensive_joker",
+ rarity = 1,
+ cost = 4,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { t_chips = 0, extra = { extra = 125, highstake = 75 } },
+ loc_vars = function(self, info_queue, card)
+ local chips = G.GAME.stake >= 6 and card.ability.extra.highstake or card.ability.extra.extra
+ return { vars = { chips, card.ability.t_chips } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ update = function(self, card, dt)
+ if not MP.LOBBY.code then
+ card.ability.t_chips = 0
+ return
+ end
+
+ if G.STAGE ~= G.STAGES.RUN then return end
+
+ local chips = G.GAME.stake >= 6 and card.ability.extra.highstake or card.ability.extra.extra
+ card.ability.t_chips = math.max((MP.GAME.enemy.lives - MP.GAME.lives) * chips, 0)
+ end,
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and context.joker_main then
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_chips",
+ vars = { card.ability.t_chips },
+ }),
+ chip_mod = card.ability.t_chips,
+ }
+ end
+ end,
+ mp_credits = {
+ idea = { "didon't" },
+ art = { "TheTrueRaven" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/lets_go_gambling.lua b/objects/jokers/lets_go_gambling.lua
new file mode 100644
index 00000000..699e6aed
--- /dev/null
+++ b/objects/jokers/lets_go_gambling.lua
@@ -0,0 +1,82 @@
+SMODS.Atlas({
+ key = "lets_go_gambling",
+ path = "j_lets_go_gambling.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "lets_go_gambling",
+ atlas = "lets_go_gambling",
+ rarity = 2,
+ cost = 6,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { extra = { odds = 4, xmult = 4, dollars = 10, nemesis_odds = 4, nemesis_dollars = 10 } },
+ loc_vars = function(self, info_queue, card)
+ local numerator, denominator =
+ SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_lets_go_gambling")
+ local nem_numerator, nem_denominator =
+ SMODS.get_probability_vars(card, 1, card.ability.extra.nemesis_odds, "j_mp_lets_go_gambling_misfire")
+ return {
+ vars = {
+ numerator,
+ denominator,
+ card.ability.extra.xmult,
+ card.ability.extra.dollars,
+ nem_numerator,
+ nem_denominator,
+ card.ability.extra.nemesis_dollars,
+ },
+ }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.jokers
+ and context.joker_main
+ and (not card.edition or card.edition.type ~= "mp_phantom")
+ then
+ local returns = nil
+ if SMODS.pseudorandom_probability(card, "j_mp_lets_go_gambling", 1, card.ability.extra.odds) then
+ returns = {}
+ returns.x_mult = card.ability.extra.xmult
+ returns.dollars = card.ability.extra.dollars
+ end
+ if
+ MP.is_pvp_boss()
+ and SMODS.pseudorandom_probability(
+ card,
+ "j_mp_lets_go_gambling_misfire",
+ 1,
+ card.ability.extra.nemesis_odds
+ )
+ then
+ returns = returns or {}
+ MP.ACTIONS.lets_go_gambling_nemesis()
+ returns.message = localize("k_oops_ex")
+ end
+ return returns
+ end
+ end,
+ add_to_deck = function(self, card, from_debuffed)
+ if not from_debuffed and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.send_phantom("j_mp_lets_go_gambling")
+ end
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ if not from_debuff and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.remove_phantom("j_mp_lets_go_gambling")
+ end
+ end,
+ mp_credits = {
+ idea = { "Dr. Monty" },
+ art = { "Carter" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/pacifist.lua b/objects/jokers/pacifist.lua
new file mode 100644
index 00000000..0f5bb674
--- /dev/null
+++ b/objects/jokers/pacifist.lua
@@ -0,0 +1,37 @@
+SMODS.Atlas({
+ key = "pacifist",
+ path = "j_pacifist.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "pacifist",
+ atlas = "pacifist",
+ rarity = 1,
+ cost = 4,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { extra = { x_mult = 10 } },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.x_mult } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and context.joker_main and not MP.is_pvp_boss() then
+ return {
+ x_mult = card.ability.extra.x_mult,
+ }
+ end
+ end,
+ mp_credits = {
+ idea = { "Zilver" },
+ art = { "zeathemays" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/penny_pincher.lua b/objects/jokers/penny_pincher.lua
new file mode 100644
index 00000000..5454534a
--- /dev/null
+++ b/objects/jokers/penny_pincher.lua
@@ -0,0 +1,40 @@
+SMODS.Atlas({
+ key = "penny_pincher",
+ path = "j_penny_pincher.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "penny_pincher",
+ atlas = "penny_pincher",
+ rarity = 1,
+ cost = 4,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { extra = { dollars = 1, nemesis_dollars = 3 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = { card.ability.extra.dollars, card.ability.extra.nemesis_dollars } }
+ end,
+ in_pool = function(self)
+ return MP.GAME.pincher_unlock -- do NOT replace this with G.GAME.round_resets.ante >= 3, order sets ante to 0
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ calc_dollar_bonus = function(self, card)
+ local spent = MP.GAME.enemy.spent_in_shop[MP.GAME.pincher_index]
+ local money = 0
+ if spent then money = math.floor(spent / card.ability.extra.nemesis_dollars) end
+ if money > 0 then return money end
+ end,
+ mp_credits = {
+ idea = { "Nxkoozie" },
+ art = { "Coo29" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/pizza.lua b/objects/jokers/pizza.lua
new file mode 100644
index 00000000..e43af070
--- /dev/null
+++ b/objects/jokers/pizza.lua
@@ -0,0 +1,57 @@
+SMODS.Atlas({
+ key = "pizza",
+ path = "j_pizza.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "pizza",
+ atlas = "pizza",
+ rarity = 1,
+ cost = 4,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = false,
+ perishable_compat = true,
+ config = { extra = { discards = 2, discards_nemesis = 1 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = { card.ability.extra.discards, card.ability.extra.discards_nemesis } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ add_to_deck = function(self, card, from_debuffed)
+ if not from_debuffed and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.send_phantom("j_mp_pizza")
+ end
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ if not from_debuff and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.remove_phantom("j_mp_pizza")
+ end
+ end,
+ calculate = function(self, card, context)
+ if context.mp_end_of_pvp and (not card.edition or card.edition.type ~= "mp_phantom") then
+ -- do things
+ MP.GAME.pizza_discards = MP.GAME.pizza_discards + card.ability.extra.discards
+ G.GAME.round_resets.discards = G.GAME.round_resets.discards + card.ability.extra.discards
+ ease_discard(card.ability.extra.discards)
+ MP.ACTIONS.eat_pizza(card.ability.extra.discards_nemesis)
+ local _card = context.blueprint_card or card
+ _card:remove_from_deck()
+ _card:start_dissolve({ G.C.RED }, nil, 1.6)
+ return {
+ message = localize("k_eaten_ex"),
+ colour = G.C.RED,
+ }
+ end
+ end,
+ mp_credits = {
+ idea = { "Virtualized" },
+ art = { "TheTrueRaven" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/sandbox/baseball.lua b/objects/jokers/sandbox/baseball.lua
new file mode 100644
index 00000000..04817831
--- /dev/null
+++ b/objects/jokers/sandbox/baseball.lua
@@ -0,0 +1,38 @@
+SMODS.Atlas({
+ key = "baseball_sandbox",
+ path = "j_baseball_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "baseball_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 3,
+ cost = 8,
+ atlas = "baseball_sandbox",
+ config = { extra = { xmult = 2 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.xmult } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.other_joker
+ and (
+ context.other_joker.config.center.rarity == 2
+ or context.other_joker.config.center.rarity == "Uncommon"
+ )
+ then
+ return {
+ xmult = card.ability.extra.xmult,
+ }
+ end
+ end,
+ mp_credits = { idea = { "Sylvie" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/bloodstone.lua b/objects/jokers/sandbox/bloodstone.lua
new file mode 100644
index 00000000..a8e18a68
--- /dev/null
+++ b/objects/jokers/sandbox/bloodstone.lua
@@ -0,0 +1,39 @@
+SMODS.Atlas({
+ key = "bloodstone_sandbox",
+ path = "j_bloodstone_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "bloodstone_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 7,
+ atlas = "bloodstone_sandbox",
+ config = { extra = { odds = 3, Xmult = 2 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ local numerator, denominator =
+ SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_bloodstone_sandbox")
+ return { vars = { numerator, denominator, card.ability.extra.Xmult, colours = { G.C.SUITS["Hearts"] } } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.individual
+ and context.cardarea == G.play
+ and context.other_card:is_suit("Hearts")
+ and SMODS.pseudorandom_probability(card, "j_mp_bloodstone_sandbox", 1, card.ability.extra.odds)
+ then
+ return {
+ xmult = card.ability.extra.Xmult,
+ }
+ end
+ end,
+ mp_credits = { idea = { "LocalThunk" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/castle.lua b/objects/jokers/sandbox/castle.lua
new file mode 100644
index 00000000..8dbacd5d
--- /dev/null
+++ b/objects/jokers/sandbox/castle.lua
@@ -0,0 +1,54 @@
+SMODS.Atlas({
+ key = "castle_sandbox",
+ path = "j_castle_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "castle_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ atlas = "castle_sandbox",
+ blueprint_compat = true,
+ perishable_compat = false,
+ rarity = 2,
+ cost = 6,
+ config = { extra = { chips = 0, chip_mod = 10, suit = nil }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ local suit = card.ability.extra.suit or G.GAME.current_round.castle_card.suit or "Spades"
+ return {
+ vars = {
+ string.upper(localize(suit, "suits_plural")),
+ colours = { G.C.SUITS[suit] },
+ card.ability.extra.chips,
+ card.ability.extra.chip_mod,
+ },
+ }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.discard
+ and not context.blueprint
+ and not context.other_card.debuff
+ and context.other_card:is_suit(card.ability.extra.suit)
+ then
+ card.ability.extra.chips = card.ability.extra.chips + card.ability.extra.chip_mod
+ return {
+ message = localize("k_upgrade_ex"),
+ colour = G.C.CHIPS,
+ }
+ end
+ if context.joker_main then return {
+ chips = card.ability.extra.chips,
+ } end
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ if card.ability.extra.suit == nil then card.ability.extra.suit = G.GAME.current_round.castle_card.suit end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/cloud_9.lua b/objects/jokers/sandbox/cloud_9.lua
new file mode 100644
index 00000000..efdb45f0
--- /dev/null
+++ b/objects/jokers/sandbox/cloud_9.lua
@@ -0,0 +1,63 @@
+SMODS.Atlas({
+ key = "cloud_9_sandbox",
+ path = "j_cloud_9_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+local function calculate_cloud_9_bonus(card)
+ local nine_tally = 0
+ if G.playing_cards ~= nil then
+ for k, v in pairs(G.playing_cards) do
+ if v:get_id() == 9 then nine_tally = nine_tally + 1 end
+ end
+ end
+
+ local base_bonus = math.min(nine_tally, 4)
+ local excess_nines = math.max(nine_tally - 4, 0)
+ local multiplied_bonus = excess_nines * card.ability.extra.money
+
+ return base_bonus + multiplied_bonus
+end
+
+SMODS.Joker({
+ key = "cloud_9_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ perishable_compat = true,
+ eternal_compat = true,
+ rarity = 2,
+ cost = 7,
+ atlas = "cloud_9_sandbox",
+ config = { extra = { money = 2, odds = 4 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ local numerator, denominator =
+ SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_cloud_9_sandbox")
+
+ return {
+ vars = {
+ numerator,
+ denominator,
+ calculate_cloud_9_bonus(card),
+ },
+ }
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+ calc_dollar_bonus = function(self, card)
+ return calculate_cloud_9_bonus(card)
+ end,
+ calculate = function(self, card, context)
+ if context.individual and context.cardarea == G.play and not context.other_card.debuff then
+ if SMODS.pseudorandom_probability(card, "j_mp_cloud_9_sandbox", 1, card.ability.extra.odds) then
+ SMODS.modify_rank(context.other_card, 9 - context.other_card:get_id())
+ play_sound("card1", 1)
+ context.other_card:juice_up(0.3, 0.3)
+ end
+ end
+ end,
+})
diff --git a/objects/jokers/sandbox/constellation.lua b/objects/jokers/sandbox/constellation.lua
new file mode 100644
index 00000000..3157bd13
--- /dev/null
+++ b/objects/jokers/sandbox/constellation.lua
@@ -0,0 +1,52 @@
+SMODS.Atlas({
+ key = "constellation_sandbox",
+ path = "j_constellation_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "constellation_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = false,
+ rarity = 2,
+ cost = 6,
+ atlas = "constellation_sandbox",
+ config = { extra = { Xmult = 1, Xmult_mod = 0.2, Xmult_loss = 0.2 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult } }
+ end,
+ calculate = function(self, card, context)
+ -- Gain mult when planet card is used
+ if context.using_consumeable and not context.blueprint and context.consumeable.ability.set == "Planet" then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_mod
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ }
+ end
+ -- Apply mult during main calculation
+ if context.joker_main then return {
+ xmult = card.ability.extra.Xmult,
+ } end
+ -- Lose mult at end of round
+ if context.end_of_round and not context.blueprint and not context.individual and not context.repetition then
+ card.ability.extra.Xmult = math.max(1, card.ability.extra.Xmult - card.ability.extra.Xmult_loss)
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_xmult_minus",
+ vars = { card.ability.extra.Xmult_loss },
+ }),
+ colour = G.C.RED,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/error.lua b/objects/jokers/sandbox/error.lua
new file mode 100644
index 00000000..1b54b0c2
--- /dev/null
+++ b/objects/jokers/sandbox/error.lua
@@ -0,0 +1,122 @@
+SMODS.Atlas({
+ key = "error_sandbox",
+ path = "j_ERROR_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+for i = 1, 21 do
+ SMODS.Joker({
+ key = "error_sandbox_" .. i,
+ loc_vars = function(self, info_queue, card)
+ local r_mults = {}
+ for i = 1, 333 do
+ r_mults[#r_mults + 1] = tostring(i)
+ end
+ local loc_mult = "(CURRENTLY " .. math.random(1, 333) .. ")"
+ main_end = {
+ { n = G.UIT.T, config = { text = loc_mult, colour = lighten(G.C.PURPLE, 0.2), scale = 0.4 } },
+ {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = r_mults,
+ colours = { G.C.MONEY },
+ pop_in_rate = 9999999,
+ silent = true,
+ random_element = true,
+ pop_delay = 1.52,
+ scale = 0.32,
+ min_cycle_time = 0,
+ }),
+ },
+ },
+ {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = {
+ { string = "SYSTEM_SERVICE_EXCEPTION", colour = lighten(G.C.BLUE, 0.3) },
+ { string = "KERNEL_DATA_INPAGE_ERROR", colour = G.C.BLUE },
+ { string = "IRQL_NOT_LESS_OR_EQUAL", colour = lighten(G.C.BLUE, 0.4) },
+ { string = "PAGE_FAULT_IN_NONPAGED_AREA", colour = G.C.CHIPS },
+ { string = "KMODE_EXCEPTION_NOT_HANDLED", colour = lighten(G.C.BLUE, 0.2) },
+ { string = "DRIVER_POWER_STATE_FAILURE", colour = G.C.SECONDARY_SET.Planet },
+ { string = "CRITICAL_PROCESS_DIED", colour = G.C.RED },
+ { string = "BAD_POOL_HEADER", colour = lighten(G.C.CHIPS, 0.3) },
+ { string = "MEMORY_MANAGEMENT", colour = G.C.BLUE },
+ { string = "SYSTEM_THREAD_EXCEPTION", colour = lighten(G.C.BLUE, 0.5) },
+ { string = "DPC_WATCHDOG_VIOLATION", colour = G.C.CHIPS },
+ { string = "CLOCK_WATCHDOG_TIMEOUT", colour = lighten(G.C.BLUE, 0.1) },
+ { string = "WHEA_UNCORRECTABLE_ERROR", colour = G.C.SECONDARY_SET.Planet },
+ { string = "PFN_LIST_CORRUPT", colour = lighten(G.C.CHIPS, 0.4) },
+ { string = "DRIVER_VERIFIER_DETECTED", colour = G.C.BLUE },
+ { string = "THREAD_STUCK_IN_DEVICE_DRIVER", colour = lighten(G.C.BLUE, 0.2) },
+ { string = "VIDEO_TDR_TIMEOUT_DETECTED", colour = G.C.CHIPS },
+ { string = "APC_INDEX_MISMATCH", colour = lighten(G.C.BLUE, 0.6) },
+ { string = "DRIVER_IRQL_NOT_LESS_OR_EQUAL", colour = G.C.SECONDARY_SET.Planet },
+ { string = "BUGCODE_USB_DRIVER", colour = lighten(G.C.CHIPS, 0.2) },
+ { string = "HYPERVISOR_ERROR", colour = G.C.BLUE },
+ { string = "UNEXPECTED_KERNEL_MODE_TRAP", colour = lighten(G.C.BLUE, 0.3) },
+ { string = "ATTEMPTED_WRITE_TO_READONLY_MEMORY", colour = G.C.CHIPS },
+ { string = "DRIVER_CORRUPTED_EXPOOL", colour = lighten(G.C.BLUE, 0.4) },
+ { string = "NTFS_FILE_SYSTEM", colour = G.C.SECONDARY_SET.Planet },
+ { string = "FAT_FILE_SYSTEM", colour = lighten(G.C.CHIPS, 0.3) },
+ { string = "KERNEL_SECURITY_CHECK_FAILURE", colour = G.C.BLUE },
+ { string = "STOP: 0x0000007E", colour = lighten(G.C.BLUE, 0.2) },
+ { string = "STOP: 0x000000D1", colour = G.C.CHIPS },
+ { string = "STOP: 0x0000001E", colour = lighten(G.C.BLUE, 0.5) },
+ { string = "STOP: 0x00000050", colour = G.C.SECONDARY_SET.Planet },
+ { string = "STOP: 0x000000A", colour = lighten(G.C.CHIPS, 0.4) },
+ { string = "corrupted heap", colour = G.C.BLIND.Boss },
+ { string = "BSOD", colour = G.C.BLUE },
+ { string = "malloc(): corrupted top size", colour = G.C.RED },
+ { string = "use after free", colour = G.C.PERISHABLE },
+ { string = "stack smashing detected", colour = G.C.ETERNAL },
+ { string = "double free or corruption", colour = lighten(G.C.RED, 0.2) },
+ { string = "zombie process", colour = lighten(G.C.L_BLACK, 0.5) },
+ { string = "killed by signal 9", colour = G.C.SO_1.Hearts },
+ {
+ string = "0x" .. string.format("%08X", math.random(0, 0xFFFFFFFF)),
+ colour = G.C.MONEY,
+ },
+ "$",
+ "€",
+ "¥",
+ "despair",
+ "£",
+ "₹",
+ "₽",
+ "₩",
+ "¢",
+ "₿",
+ "◊",
+ },
+ colours = { G.C.UI.TEXT_DARK },
+ pop_in_rate = 1,
+ silent = true,
+ random_element = true,
+ pop_delay = 0.38,
+ scale = 0.32,
+ min_cycle_time = 0,
+ }),
+ },
+ },
+ }
+ return {
+ main_end = main_end,
+ -- modified localization key trickery to ensure we always use this localization, thanks toneblock
+ key = "j_mp_error_sandbox",
+ }
+ end,
+
+ atlas = "error_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ mp_include = function(self)
+ return false
+ end,
+ mp_credits = { art = { "aura?" } },
+ })
+end
diff --git a/objects/jokers/sandbox/extra-credit/_ec_atlas.lua b/objects/jokers/sandbox/extra-credit/_ec_atlas.lua
new file mode 100644
index 00000000..e68e6ca0
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/_ec_atlas.lua
@@ -0,0 +1,11 @@
+-- Extra Credit Atlas Registration
+-- Shared atlas for all Extra Credit jokers ported to Sandbox
+-- Art by the Extra Credit team — see individual joker files for per-card credits
+-- This file is prefixed with underscore to ensure it loads before the joker files
+
+SMODS.Atlas({
+ key = "ec_jokers_sandbox",
+ path = "ec_jokers_sandbox.png",
+ px = 71,
+ py = 95,
+})
diff --git a/objects/jokers/sandbox/extra-credit/_ec_utils.lua b/objects/jokers/sandbox/extra-credit/_ec_utils.lua
new file mode 100644
index 00000000..328bbf82
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/_ec_utils.lua
@@ -0,0 +1,107 @@
+-- Extra Credit Utilities Module
+-- Shared utility functions for Extra Credit jokers ported to Sandbox
+-- This file is prefixed with underscore to ensure it loads before the joker files
+
+MP.EC = MP.EC or {}
+
+-- Talisman Compatibility wrapper
+to_big = to_big or function(x)
+ return x
+end
+to_number = to_number or function(x)
+ return x
+end
+
+--- Resets Tuxedo joker's selected suit for the round
+--- Randomly selects a suit different from the current one
+local function reset_tuxedo_card()
+ local tuxedo_suits = {}
+ G.GAME.current_round.tuxedo_card = G.GAME.current_round.tuxedo_card or {}
+ for k, suit in pairs(SMODS.Suits) do
+ if
+ k ~= G.GAME.current_round.tuxedo_card.suit
+ and (type(suit.in_pool) ~= "function" or suit:in_pool({ rank = "" }))
+ then
+ tuxedo_suits[#tuxedo_suits + 1] = k
+ end
+ end
+ local seed = "tux"
+
+ local tuxedo_card = pseudorandom_element(tuxedo_suits, pseudoseed(seed))
+ G.GAME.current_round.tuxedo_card.suit = tuxedo_card
+end
+
+--- Resets Farmer joker's selected suit for the round
+--- Randomly selects a suit different from the current one
+local function reset_farmer_card()
+ local farmer_suits = {}
+ G.GAME.current_round.farmer_card = G.GAME.current_round.farmer_card or {}
+ for k, suit in pairs(SMODS.Suits) do
+ if
+ k ~= G.GAME.current_round.farmer_card.suit
+ and (type(suit.in_pool) ~= "function" or suit:in_pool({ rank = "" }))
+ then
+ farmer_suits[#farmer_suits + 1] = k
+ end
+ end
+
+ local seed = "farm"
+
+ local farmer_card = pseudorandom_element(farmer_suits, pseudoseed(seed))
+ G.GAME.current_round.farmer_card.suit = farmer_card
+end
+
+--- Resets Go Fish joker's selected rank for the round
+--- Randomly selects a rank different from the current one
+local function reset_fish_rank()
+ local valid_fish_ranks = {}
+ G.GAME.current_round.fish_rank = G.GAME.current_round.fish_rank or {}
+ for k, rank in pairs(SMODS.Ranks) do
+ if
+ k ~= G.GAME.current_round.fish_rank.rank
+ and (type(rank.in_pool) ~= "function" or rank:in_pool({ suit = "" }))
+ then
+ valid_fish_ranks[#valid_fish_ranks + 1] = k
+ end
+ end
+
+ local seed = "fish"
+
+ local fish_pick = pseudorandom_element(valid_fish_ranks, pseudoseed(seed))
+ G.GAME.current_round.fish_rank.rank = fish_pick
+end
+
+--- Hook into game globals reset to initialize EC round state
+--- Called at start of each round
+local original_reset_game_globals = MP.reset_game_globals
+MP.reset_game_globals = function(run_start)
+ if original_reset_game_globals then original_reset_game_globals(run_start) end
+
+ -- Only initialize EC state when sandbox ruleset is active
+ if MP.is_ruleset_active("sandbox") then
+ reset_tuxedo_card()
+ reset_farmer_card()
+ reset_fish_rank()
+ end
+end
+
+-- Hoarder joker: gain sell value each time we earn money
+local original_ease_dollars = ease_dollars
+function ease_dollars(mod, x)
+ original_ease_dollars(mod, x)
+
+ if MP.is_ruleset_active("sandbox") and to_big(mod) > to_big(0) and G.jokers and G.jokers.cards then
+ for i = 1, #G.jokers.cards do
+ local card = G.jokers.cards[i]
+ if card.config.center.key == "j_mp_hoarder_sandbox" and not card.debuffed then
+ card.ability.extra_value = card.ability.extra_value + card.ability.extra
+ card:set_cost()
+ card_eval_status_text(card, "extra", nil, nil, nil, {
+ message = localize("k_val_up"),
+ colour = G.C.MONEY,
+ card = card,
+ })
+ end
+ end
+ end
+end
diff --git a/objects/jokers/sandbox/extra-credit/alloy.lua b/objects/jokers/sandbox/extra-credit/alloy.lua
new file mode 100644
index 00000000..575cf0c7
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/alloy.lua
@@ -0,0 +1,36 @@
+SMODS.Joker({
+ key = "alloy_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+
+ rarity = 2,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 4, y = 4 },
+
+ config = { mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_gold
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_steel
+ return
+ end,
+
+ calculate = function(self, card, context)
+ if context.check_enhancement then
+ if context.other_card.config.center.key == "m_gold" then return { m_steel = true } end
+ if context.other_card.config.center.key == "m_steel" then return { m_gold = true } end
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "dottykitty" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/ambrosia.lua b/objects/jokers/sandbox/extra-credit/ambrosia.lua
new file mode 100644
index 00000000..8c876d09
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/ambrosia.lua
@@ -0,0 +1,93 @@
+SMODS.Joker({
+ key = "ambrosia_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = false,
+
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 5, y = 2 },
+
+ config = {
+ extra = {},
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = {} }
+ end,
+
+ calculate = function(self, card, context)
+ if context.skip_blind then
+ for i = 1, G.consumeables.config.card_limit do
+ if #G.consumeables.cards + G.GAME.consumeable_buffer < G.consumeables.config.card_limit then
+ G.GAME.consumeable_buffer = G.GAME.consumeable_buffer + 1
+ G.E_MANAGER:add_event(Event({
+ trigger = "before",
+ delay = 0.0,
+ func = function()
+ local card = create_card("Spectral", G.consumeables, nil, nil, nil, nil, nil, "ambro")
+ card:add_to_deck()
+ G.consumeables:emplace(card)
+ G.GAME.consumeable_buffer = 0
+ card:juice_up(0.5, 0.5)
+ return true
+ end,
+ }))
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_plus_spectral"), colour = G.C.SECONDARY_SET.Spectral }
+ )
+ end
+ end
+ elseif context.selling_card then
+ if context.card.ability.set == "Spectral" then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = true
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_drank_ex"), colour = G.C.SECONDARY_SET.Spectral }
+ )
+ end
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "dottykitty" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/bobby.lua b/objects/jokers/sandbox/extra-credit/bobby.lua
new file mode 100644
index 00000000..59d2e17d
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/bobby.lua
@@ -0,0 +1,62 @@
+SMODS.Joker({
+ key = "bobby_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 2,
+ cost = 6,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 0, y = 3 },
+
+ config = {
+ extra = {
+ hands = 2,
+ discards = 4,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.hands, card.ability.extra.discards } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.setting_blind and not (context.blueprint_card or card).getting_sliced then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ if G.GAME.current_round.hands_left < 2 then
+ elseif G.GAME.current_round.hands_left == 2 then
+ ease_hands_played(-(card.ability.extra.hands / 2), true)
+ ease_discard((card.ability.extra.discards / 2), true, true)
+ card_eval_status_text(context.blueprint_card or card, "extra", nil, nil, nil, {
+ message = "+" .. tostring(card.ability.extra.discards / 2) .. " " .. localize(
+ "k_hud_discards"
+ ),
+ colour = G.C.RED,
+ })
+ elseif G.GAME.current_round.hands_left > 2 then
+ ease_hands_played(-card.ability.extra.hands, true)
+ ease_discard(card.ability.extra.discards, true, true)
+ card_eval_status_text(context.blueprint_card or card, "extra", nil, nil, nil, {
+ message = "+" .. tostring(card.ability.extra.discards) .. " " .. localize("k_hud_discards"),
+ colour = G.C.RED,
+ })
+ end
+ return true
+ end,
+ }))
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "R3venantR3mnant" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/candynecklace.lua b/objects/jokers/sandbox/extra-credit/candynecklace.lua
new file mode 100644
index 00000000..e16998b9
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/candynecklace.lua
@@ -0,0 +1,71 @@
+-- Candy Necklace - Extra Credit Joker ported to Sandbox
+-- Random Booster Pack Tag at shop end (5 uses)
+
+SMODS.Joker({
+ key = "candynecklace_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = false,
+ rarity = 2,
+ cost = 8,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 9, y = 0 },
+ config = {
+ extra = {
+ candies = 5,
+ flavours = { "tag_buffoon", "tag_charm", "tag_meteor", "tag_standard", "tag_ethereal" },
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.candies } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.ending_shop and card.ability.extra.candies > 0 then
+ local tag_key = pseudorandom_element(card.ability.extra.flavours, pseudoseed("candy_tag"))
+ add_tag(Tag(tag_key))
+
+ if not context.blueprint then
+ card.ability.extra.candies = card.ability.extra.candies - 1
+
+ if card.ability.extra.candies <= 0 then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = true
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return {
+ message = localize("k_eaten_ex"),
+ colour = G.C.MONEY,
+ }
+ end
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/chainlightning.lua b/objects/jokers/sandbox/extra-credit/chainlightning.lua
new file mode 100644
index 00000000..772e0e4c
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/chainlightning.lua
@@ -0,0 +1,64 @@
+SMODS.Joker({
+ key = "chainlightning_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 3,
+ cost = 9,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 2, y = 3 },
+
+ config = {
+ extra = {
+ Xmult = 1,
+ Xmult_mod = 0.1,
+ total = 0,
+ so_far = 0,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult, card.ability.extra.Xmult_mod } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.before then
+ card.ability.extra.Xmult = 1
+ card.ability.extra.total = card.ability.extra.total + 1
+ card.ability.extra.so_far = 0
+ elseif
+ context.cardarea == G.play
+ and context.individual
+ and context.other_card.config.center.key == "m_mult"
+ then
+ local thunk = card.ability.extra.Xmult
+ card.ability.extra.so_far = card.ability.extra.so_far + 1
+
+ if card.ability.extra.so_far == card.ability.extra.total then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_mod
+ card.ability.extra.so_far = 0
+ end
+
+ if thunk > 1 then return {
+ x_mult = thunk,
+ card = card,
+ } end
+ elseif context.after then
+ card.ability.extra.total = 0
+ card.ability.extra.Xmult = 1
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "Wingcap" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/clowncar.lua b/objects/jokers/sandbox/extra-credit/clowncar.lua
new file mode 100644
index 00000000..93db8ae3
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/clowncar.lua
@@ -0,0 +1,44 @@
+SMODS.Joker({
+ key = "clowncar_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 3,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 6, y = 2 },
+
+ config = { extra = { mult = 44, money = 3 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.mult, card.ability.extra.money } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.initial_scoring_step then
+ ease_dollars(-card.ability.extra.money)
+ card_eval_status_text(
+ card,
+ "jokers",
+ nil,
+ percent,
+ nil,
+ { message = "-$" .. tostring(card.ability.extra.money), colour = G.C.MONEY }
+ )
+ return {
+ mult = card.ability.extra.mult,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective", "steph" },
+ art = { "R3venantR3mnant" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/clowncollege.lua b/objects/jokers/sandbox/extra-credit/clowncollege.lua
new file mode 100644
index 00000000..a65c9674
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/clowncollege.lua
@@ -0,0 +1,61 @@
+-- Fill consumable slots with The Fool cards after Boss Blind is defeated
+
+SMODS.Joker({
+ key = "clowncollege_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ rarity = 2,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 4, y = 1 },
+ config = { extra = {}, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = { key = "c_fool", set = "Tarot" }
+ return { vars = {} }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.end_of_round
+ and not context.repetition
+ and not context.individual
+ and G.GAME.blind.boss
+ and not context.blueprint
+ then
+ for i = 1, G.consumeables.config.card_limit do
+ if #G.consumeables.cards + G.GAME.consumeable_buffer < G.consumeables.config.card_limit then
+ G.GAME.consumeable_buffer = G.GAME.consumeable_buffer + 1
+ G.E_MANAGER:add_event(Event({
+ trigger = "before",
+ delay = 0.0,
+ func = function()
+ local new_card = create_card("Tarot", G.consumeables, nil, nil, nil, nil, "c_fool")
+ new_card:add_to_deck()
+ G.consumeables:emplace(new_card)
+ G.GAME.consumeable_buffer = 0
+ new_card:juice_up(0.5, 0.5)
+ return true
+ end,
+ }))
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_plus_tarot"), colour = G.C.PURPLE }
+ )
+ end
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/couponsheet.lua b/objects/jokers/sandbox/extra-credit/couponsheet.lua
new file mode 100644
index 00000000..12f7fdc6
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/couponsheet.lua
@@ -0,0 +1,77 @@
+SMODS.Joker({
+ key = "couponsheet_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+
+ rarity = 3,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 8, y = 3 },
+
+ config = {
+ extra = {},
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = { key = "tag_coupon", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_voucher", set = "Tag" }
+ return { vars = {} }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.end_of_round
+ and not context.repetition
+ and not context.individual
+ and G.GAME.blind.boss
+ and not context.blueprint
+ then
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = "+1 Coupon Tag!", colour = G.C.FILTER }
+ )
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ add_tag(Tag("tag_coupon"))
+ play_sound("generic1", 0.9 + math.random() * 0.1, 0.8)
+ play_sound("holo1", 1.2 + math.random() * 0.1, 0.4)
+ return true
+ end,
+ }))
+ delay(0.3)
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = "+1 Voucher Tag!", colour = G.C.FILTER }
+ )
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ add_tag(Tag("tag_voucher"))
+ play_sound("generic1", 0.9 + math.random() * 0.1, 0.8)
+ play_sound("holo1", 1.2 + math.random() * 0.1, 0.4)
+ return true
+ end,
+ }))
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "neatoqueen" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/doublerainbow.lua b/objects/jokers/sandbox/extra-credit/doublerainbow.lua
new file mode 100644
index 00000000..5aa1d70d
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/doublerainbow.lua
@@ -0,0 +1,53 @@
+-- Double Rainbow - Extra Credit Joker ported to Sandbox
+-- Retrigger all Lucky Cards
+
+SMODS.Joker({
+ key = "doublerainbow_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ enhancement_gate = "m_lucky",
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 1, y = 0 },
+ config = { extra = 1, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_lucky
+ return {}
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.repetition
+ and context.cardarea == G.play
+ and SMODS.get_enhancements(context.other_card)["m_lucky"] == true
+ then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = 1,
+ card = card,
+ }
+ elseif
+ context.repetition
+ and context.cardarea == G.hand
+ and SMODS.get_enhancements(context.other_card)["m_lucky"] == true
+ then
+ if next(context.card_effects[1]) or #context.card_effects > 1 then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra,
+ card = card,
+ }
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/espresso.lua b/objects/jokers/sandbox/extra-credit/espresso.lua
new file mode 100644
index 00000000..8eff530a
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/espresso.lua
@@ -0,0 +1,98 @@
+-- Espresso - Extra Credit Joker ported to Sandbox
+-- Gain $30 and destroy this card when Blind is skipped, reduces by $5/round
+
+SMODS.Joker({
+ key = "espresso_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = false,
+ rarity = 1,
+ cost = 2,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 6, y = 1 },
+ config = { extra = { money = 30, m_loss = 5 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.money, card.ability.extra.m_loss } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.skip_blind and not context.blueprint then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ card_eval_status_text(card, "extra", nil, nil, nil, {
+ message = localize("k_drank_ex"),
+ colour = G.C.MONEY,
+ card = card,
+ })
+ return true
+ end,
+ }))
+ ease_dollars(card.ability.extra.money)
+ card:juice_up(0.5, 0.5)
+ delay(0.5)
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = true
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ elseif context.end_of_round and not context.individual and not context.repetition and not context.blueprint then
+ card.ability.extra.money = card.ability.extra.money - card.ability.extra.m_loss
+ if card.ability.extra.money <= 0 then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = true
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return {
+ message = "Too cold!",
+ colour = G.C.FILTER,
+ }
+ else
+ return {
+ message = "Cooled!",
+ colour = G.C.FILTER,
+ }
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/farmer.lua b/objects/jokers/sandbox/extra-credit/farmer.lua
new file mode 100644
index 00000000..fc73d1e9
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/farmer.lua
@@ -0,0 +1,50 @@
+SMODS.Joker({
+ key = "farmer_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 1,
+ cost = 6,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 9, y = 1 },
+
+ config = { extra = { dollars = 2 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ local current_suit = G.GAME.current_round.farmer_card and G.GAME.current_round.farmer_card.suit or "Spades"
+ return {
+ vars = {
+ card.ability.extra.dollars,
+ localize(current_suit, "suits_singular"),
+ colours = { G.C.SUITS[current_suit] },
+ },
+ }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.hand
+ and context.end_of_round
+ and context.individual
+ and not context.repetition
+ and context.other_card:is_suit(G.GAME.current_round.farmer_card.suit)
+ then
+ delay(0.15)
+ return {
+ dollars = 2,
+ card = context.other_card,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "HonuKane" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/forklift.lua b/objects/jokers/sandbox/extra-credit/forklift.lua
new file mode 100644
index 00000000..81880b53
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/forklift.lua
@@ -0,0 +1,43 @@
+-- Forklift - Extra Credit Joker ported to Sandbox
+-- +2 Consumable Slots
+
+SMODS.Joker({
+ key = "forklift_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ rarity = 1,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 0, y = 0 },
+ config = { extra = { card_limit = 2 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.card_limit } }
+ end,
+
+ add_to_deck = function(self, card, from_debuff)
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.consumeables.config.card_limit = G.consumeables.config.card_limit + card.ability.extra.card_limit
+ return true
+ end,
+ }))
+ end,
+
+ remove_from_deck = function(self, card, from_debuff)
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.consumeables.config.card_limit = G.consumeables.config.card_limit - card.ability.extra.card_limit
+ return true
+ end,
+ }))
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/gofish.lua b/objects/jokers/sandbox/extra-credit/gofish.lua
new file mode 100644
index 00000000..873300dc
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/gofish.lua
@@ -0,0 +1,51 @@
+SMODS.Joker({
+ key = "gofish_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ rarity = 2,
+ cost = 6,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 9, y = 2 },
+
+ config = { extra = { fished = false }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ local current_rank = G.GAME.current_round.fish_rank and G.GAME.current_round.fish_rank.rank or "Ace"
+ return { vars = { current_rank } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.first_hand_drawn and not context.blueprint then
+ card.ability.extra.fished = false
+ juice_card_until(card, function()
+ return not card.ability.extra.fished
+ end, true)
+ elseif context.before and not context.blueprint and not card.ability.extra.fished then
+ for _, scoring_card in ipairs(context.scoring_hand) do
+ if scoring_card.base.value == G.GAME.current_round.fish_rank.rank and not scoring_card.debuff then
+ card.ability.extra.fish = card.ability.extra.fish or {}
+ card.ability.extra.fish[#card.ability.extra.fish + 1] = scoring_card
+ card.ability.extra.fished = true
+ end
+ end
+ elseif context.after and not context.blueprint and card.ability.extra.fish then
+ for _, target in ipairs(card.ability.extra.fish) do
+ SMODS.destroy_cards(target)
+ end
+ card.ability.extra.fish = nil
+ elseif context.end_of_round then
+ card.ability.extra.fished = true
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective", "steph" },
+ art = { "bishopcorrigan" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/hoarder.lua b/objects/jokers/sandbox/extra-credit/hoarder.lua
new file mode 100644
index 00000000..5e093a1f
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/hoarder.lua
@@ -0,0 +1,31 @@
+SMODS.Joker({
+ key = "hoarder_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 9, y = 3 },
+
+ config = {
+ extra = 1,
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra } }
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective", "steph" },
+ art = { "neatoqueen" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/jokalisa.lua b/objects/jokers/sandbox/extra-credit/jokalisa.lua
new file mode 100644
index 00000000..8ad71bce
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/jokalisa.lua
@@ -0,0 +1,68 @@
+SMODS.Joker({
+ key = "jokalisa_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = false,
+
+ rarity = 3,
+ cost = 8,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 3, y = 3 },
+
+ config = {
+ extra = {
+ Xmult = 1,
+ Xmult_mod = 0.1,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult, card.ability.extra.Xmult_mod } }
+ end,
+
+ calculate = function(self, card, context)
+ local contains = function(table_, value)
+ for _, v in pairs(table_) do
+ if v == value then return true end
+ end
+ return false
+ end
+
+ if context.before and not context.blueprint then
+ local enhanced = {}
+ for i = 1, #context.scoring_hand do
+ for k, v in pairs(SMODS.get_enhancements(context.scoring_hand[i])) do
+ if v then
+ if not contains(enhanced, k) then enhanced[#enhanced + 1] = k end
+ end
+ end
+ end
+ if #enhanced > 0 then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + (card.ability.extra.Xmult_mod * #enhanced)
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ card = card,
+ colour = G.C.RED,
+ }
+ end
+ elseif context.cardarea == G.jokers and context.joker_main and card.ability.extra.Xmult > 1 then
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ Xmult_mod = card.ability.extra.Xmult,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "R3venantR3mnant" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/jokeroftheyear.lua b/objects/jokers/sandbox/extra-credit/jokeroftheyear.lua
new file mode 100644
index 00000000..4aea1ada
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/jokeroftheyear.lua
@@ -0,0 +1,43 @@
+SMODS.Joker({
+ key = "jokeroftheyear_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 3,
+ cost = 9,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 5, y = 3 },
+
+ config = {
+ extra = {
+ reps = 1,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return {}
+ end,
+
+ calculate = function(self, card, context)
+ if context.cardarea == G.play and context.repetition and #context.scoring_hand == 5 then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra.reps,
+ card = card,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "neatoqueen" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/lucky7.lua b/objects/jokers/sandbox/extra-credit/lucky7.lua
new file mode 100644
index 00000000..faa0a4f0
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/lucky7.lua
@@ -0,0 +1,70 @@
+SMODS.Joker({
+ key = "lucky7_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+
+ rarity = 1,
+ cost = 6,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 7, y = 3 },
+
+ config = {
+ extra = {
+ lucky = false,
+ checked = false,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_lucky
+ return
+ end,
+
+ calculate = function(self, card, context)
+ if context.before and not context.blueprint then
+ local has_seven = false
+ for i = 1, #context.scoring_hand do
+ if context.scoring_hand[i]:get_id() == 7 and not context.scoring_hand[i].debuff then
+ has_seven = true
+ break
+ end
+ end
+
+ -- enh_cache abuse hours. it's fine. it's better than injection.
+ -- we're healing.
+ if has_seven then
+ for i = 1, #context.scoring_hand do
+ context.scoring_hand[i].gambling = true
+ if SMODS.enh_cache and SMODS.enh_cache.write then
+ SMODS.enh_cache:write(context.scoring_hand[i], nil)
+ end
+ end
+ end
+ end
+
+ if context.check_enhancement then
+ if context.other_card.gambling then return {
+ m_lucky = true,
+ } end
+ end
+
+ if context.after then
+ for i = 1, #context.scoring_hand do
+ context.scoring_hand[i].gambling = nil
+ end
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective", "steph" },
+ art = { "bishopcorrigan" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/montehaul.lua b/objects/jokers/sandbox/extra-credit/montehaul.lua
new file mode 100644
index 00000000..bf14a16a
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/montehaul.lua
@@ -0,0 +1,65 @@
+-- Monte Haul - Extra Credit Joker ported to Sandbox
+-- After 1 round, sell this card to gain 2 random Joker Tags
+
+SMODS.Joker({
+ key = "montehaul_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = false,
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 5, y = 1 },
+ config = {
+ extra = {
+ monty_rounds = 0,
+ flavours = { "tag_foil", "tag_holo", "tag_polychrome", "tag_negative", "tag_uncommon", "tag_rare" },
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = { key = "tag_foil", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_holo", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_polychrome", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_negative", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_uncommon", set = "Tag" }
+ info_queue[#info_queue + 1] = { key = "tag_rare", set = "Tag" }
+ return { vars = { card.ability.extra.monty_rounds } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.end_of_round and not context.blueprint and not context.individual and not context.repetition then
+ card.ability.extra.monty_rounds = card.ability.extra.monty_rounds + 1
+ if card.ability.extra.monty_rounds >= 1 then
+ local eval = function(c)
+ return not c.REMOVED
+ end
+ juice_card_until(card, eval, true)
+ return {
+ message = localize("k_active_ex"),
+ colour = G.C.FILTER,
+ }
+ end
+ elseif context.selling_self and card.ability.extra.monty_rounds >= 1 then
+ for i = 1, 2 do
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ add_tag(Tag(pseudorandom_element(card.ability.extra.flavours, pseudoseed("monty"))))
+ play_sound("generic1", 0.9 + math.random() * 0.1, 0.8)
+ play_sound("holo1", 1.2 + math.random() * 0.1, 0.4)
+ return true
+ end,
+ }))
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "UselessReptile8" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/pocketaces.lua b/objects/jokers/sandbox/extra-credit/pocketaces.lua
new file mode 100644
index 00000000..ff70bc96
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/pocketaces.lua
@@ -0,0 +1,43 @@
+-- Pocket Aces - Extra Credit Joker ported to Sandbox
+-- Gives $ at end of round, played Aces increase payout, resets each Ante
+
+SMODS.Joker({
+ key = "pocketaces_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ perishable_compat = true,
+ rarity = 2,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 5, y = 0 },
+ config = { extra = { money = 0, m_gain = 2 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.money, card.ability.extra.m_gain } }
+ end,
+
+ calc_dollar_bonus = function(self, card)
+ local thunk = card.ability.extra.money
+ if G.GAME.blind.boss then card.ability.extra.money = 0 end
+ return thunk
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.individual
+ and context.cardarea == G.play
+ and context.other_card:get_id() == 14
+ and not context.blueprint
+ then
+ card.ability.extra.money = card.ability.extra.money + card.ability.extra.m_gain
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "Wingcap" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/pyromancer.lua b/objects/jokers/sandbox/extra-credit/pyromancer.lua
new file mode 100644
index 00000000..73cdb0c3
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/pyromancer.lua
@@ -0,0 +1,46 @@
+SMODS.Joker({
+ key = "pyromancer_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 1,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 1, y = 3 },
+
+ config = {
+ extra = {
+ mult = 20,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.mult } }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.jokers
+ and context.joker_main
+ and G.GAME.current_round.hands_left <= G.GAME.current_round.discards_left
+ then
+ return {
+ message = localize({ type = "variable", key = "a_mult", vars = { card.ability.extra.mult } }),
+ mult_mod = card.ability.extra.mult,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "Wingcap" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/shipoftheseus.lua b/objects/jokers/sandbox/extra-credit/shipoftheseus.lua
new file mode 100644
index 00000000..5629ad38
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/shipoftheseus.lua
@@ -0,0 +1,84 @@
+SMODS.Joker({
+ key = "shipoftheseus_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = false,
+
+ rarity = 3,
+ cost = 9,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 7, y = 2 },
+
+ config = {
+ extra = {
+ Xmult = 1,
+ Xmult_mod = 0.4,
+ tick = false,
+ },
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult, card.ability.extra.Xmult_mod } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.remove_playing_cards then
+ card.ability.extra.tick = false
+ for k, val in ipairs(context.removed) do
+ if not context.blueprint then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_mod
+ card.ability.extra.tick = true
+ end
+ G.playing_card = (G.playing_card and G.playing_card + 1) or 1
+ local _card = copy_card(val, nil, nil, G.playing_card)
+ _card:add_to_deck()
+ G.deck.config.card_limit = G.deck.config.card_limit + 1
+ G.deck:emplace(_card)
+ table.insert(G.playing_cards, _card)
+ playing_card_joker_effects({ true })
+
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ _card:start_materialize()
+
+ return true
+ end,
+ }))
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_copied_ex"), colour = G.C.FILTER }
+ )
+ end
+
+ if not context.blueprint and card.ability.extra.tick then
+ delay(0.3)
+ card_eval_status_text(card, "extra", nil, nil, nil, {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ colour = G.C.FILTER,
+ })
+ end
+ elseif context.cardarea == G.jokers and context.joker_main and card.ability.extra.Xmult > 1 then
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ Xmult_mod = card.ability.extra.Xmult,
+ }
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective", "steph" },
+ art = { "neatoqueen" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/starfruit.lua b/objects/jokers/sandbox/extra-credit/starfruit.lua
new file mode 100644
index 00000000..966486be
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/starfruit.lua
@@ -0,0 +1,80 @@
+-- Starfruit - Extra Credit Joker ported to Sandbox
+-- First played hand each round has a chance to gain 1 level (5 uses, then self-destructs)
+
+SMODS.Joker({
+ key = "starfruit_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = false,
+ rarity = 1,
+ cost = 6,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 2, y = 0 },
+ config = { extra = { uses = 5, odds = 2 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ local num, denom = SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_starfruit_sandbox")
+ return { vars = { card.ability.extra.uses, num, denom } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and G.GAME.current_round.hands_played == 0 and context.before then
+ if SMODS.pseudorandom_probability(card, "j_mp_starfruit_sandbox", 1, card.ability.extra.odds) then
+ local text = context.scoring_name
+ card_eval_status_text(
+ context.blueprint_card or card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_level_up_ex") }
+ )
+ update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, {
+ handname = localize(text, "poker_hands"),
+ chips = G.GAME.hands[text].chips,
+ mult = G.GAME.hands[text].mult,
+ level = G.GAME.hands[text].level,
+ })
+ level_up_hand(context.blueprint_card or card, text, nil, 1)
+ end
+
+ if not context.blueprint then
+ card.ability.extra.uses = card.ability.extra.uses - 1
+ if card.ability.extra.uses <= 0 then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = true
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return {
+ message = localize("k_eaten_ex"),
+ colour = G.C.MONEY,
+ }
+ end
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/trafficlight.lua b/objects/jokers/sandbox/extra-credit/trafficlight.lua
new file mode 100644
index 00000000..919d1325
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/trafficlight.lua
@@ -0,0 +1,62 @@
+-- Traffic Light - Extra Credit Joker ported to Sandbox
+-- X2.5 Mult, decreases X1 each hand played, resets after X0.5
+
+SMODS.Joker({
+ key = "trafficlight_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 7, y = 1 },
+ config = { extra = { Xmult = 2.5, Xmult_mod = 1 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult, card.ability.extra.Xmult_mod } }
+ end,
+
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and context.joker_main then
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ Xmult_mod = card.ability.extra.Xmult,
+ }
+ elseif context.after and not context.blueprint then
+ card.ability.extra.Xmult = card.ability.extra.Xmult - card.ability.extra.Xmult_mod
+
+ if card.ability.extra.Xmult < 0.5 then
+ card.ability.extra.Xmult = 2.5
+ return {
+ message = "Go!",
+ colour = G.C.GREEN,
+ }
+ elseif card.ability.extra.Xmult == 1.5 then
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_xmult_minus",
+ vars = { card.ability.extra.Xmult_mod },
+ }),
+ colour = G.C.FILTER,
+ }
+ elseif card.ability.extra.Xmult == 0.5 then
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_xmult_minus",
+ vars = { card.ability.extra.Xmult_mod },
+ }),
+ colour = G.C.RED,
+ }
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "Wingcap" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/tuxedo.lua b/objects/jokers/sandbox/extra-credit/tuxedo.lua
new file mode 100644
index 00000000..24db8a61
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/tuxedo.lua
@@ -0,0 +1,54 @@
+SMODS.Joker({
+ key = "tuxedo_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 4, y = 2 },
+
+ config = { extra = { reps = 1 }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ local current_suit = G.GAME.current_round.tuxedo_card and G.GAME.current_round.tuxedo_card.suit or "Spades"
+ return { vars = { localize(current_suit, "suits_singular"), colours = { G.C.SUITS[current_suit] } } }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.play
+ and context.repetition
+ and context.other_card:is_suit(G.GAME.current_round.tuxedo_card.suit)
+ then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra.reps,
+ card = card,
+ }
+ elseif
+ context.repetition
+ and context.cardarea == G.hand
+ and context.other_card:is_suit(G.GAME.current_round.tuxedo_card.suit)
+ then
+ if next(context.card_effects[1]) or #context.card_effects > 1 then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra.reps,
+ card = card,
+ }
+ end
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "dottykitty" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/warlock.lua b/objects/jokers/sandbox/extra-credit/warlock.lua
new file mode 100644
index 00000000..2495c02c
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/warlock.lua
@@ -0,0 +1,82 @@
+-- Warlock - Extra Credit Joker ported to Sandbox
+-- Lucky Cards have chance to be destroyed and spawn a Spectral Card
+
+SMODS.Joker({
+ key = "warlock_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ enhancement_gate = "m_lucky",
+ rarity = 2,
+ cost = 7,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 6, y = 0 },
+ config = { extra = { odds = 7, succeed = false }, mp_sticker_extra_credit = true },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_lucky
+ local num, denom = SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_warlock_sandbox")
+ return { vars = { num, denom } }
+ end,
+
+ calculate = function(self, card, context)
+ if
+ context.cardarea == G.play
+ and context.individual
+ and SMODS.get_enhancements(context.other_card)["m_lucky"] == true
+ then
+ if SMODS.pseudorandom_probability(card, "j_mp_warlock_sandbox", 1, card.ability.extra.odds) then
+ card.ability.extra.succeed = true
+ end
+ end
+
+ if context.cardarea == G.jokers and context.after and card.ability.extra.succeed then
+ card.ability.extra.succeed = false
+
+ if #G.consumeables.cards < G.consumeables.config.card_limit then
+ -- Find and destroy a random lucky card from the played hand
+ local lucky_cards = {}
+ for _, played_card in ipairs(context.scoring_hand or {}) do
+ if SMODS.get_enhancements(played_card)["m_lucky"] then table.insert(lucky_cards, played_card) end
+ end
+
+ if #lucky_cards > 0 then
+ local target = pseudorandom_element(lucky_cards, pseudoseed("warlock_target"))
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ target.T.r = -0.2
+ target:juice_up(0.3, 0.4)
+ return true
+ end,
+ }))
+
+ SMODS.destroy_cards(target)
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.4,
+ func = function()
+ local spectral =
+ pseudorandom_element(G.P_CENTER_POOLS.Spectral, pseudoseed("warlock_spectral"))
+ SMODS.add_card({ set = "Spectral", key = spectral.key })
+ return true
+ end,
+ }))
+
+ return {
+ message = localize("k_plus_spectral"),
+ colour = G.C.SECONDARY_SET.Spectral,
+ }
+ end
+ end
+ end
+ end,
+
+ mp_credits = { code = { "CampfireCollective" }, art = { "dottykitty" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/extra-credit/werewolf.lua b/objects/jokers/sandbox/extra-credit/werewolf.lua
new file mode 100644
index 00000000..2124199c
--- /dev/null
+++ b/objects/jokers/sandbox/extra-credit/werewolf.lua
@@ -0,0 +1,61 @@
+SMODS.Joker({
+ key = "werewolf_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+
+ rarity = 2,
+ cost = 5,
+ atlas = "ec_jokers_sandbox",
+ pos = { x = 1, y = 2 },
+
+ config = {
+ extra = {},
+
+ mp_sticker_extra_credit = true,
+ },
+
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_wild
+ return { vars = {} }
+ end,
+
+ calculate = function(self, card, context)
+ if context.before and not context.blueprint then
+ local thunk = 0
+ local contains = function(table_, value)
+ for _, v in pairs(table_) do
+ if v == value then return true end
+ end
+ return false
+ end
+
+ for k, v in ipairs(context.full_hand) do
+ if contains(SMODS.get_enhancements(v), true) and v.config.center.key ~= "m_wild" then
+ thunk = thunk + 1
+ v:set_ability(G.P_CENTERS.m_wild, nil, true)
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ v:juice_up()
+ return true
+ end,
+ }))
+ end
+ end
+ if thunk > 0 then return {
+ message = "Awooo!",
+ colour = G.C.PURPLE,
+ } end
+ end
+ end,
+
+ mp_credits = {
+ code = { "CampfireCollective" },
+ art = { "bishopcorrigan", "splatter_proto" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/faceless.lua b/objects/jokers/sandbox/faceless.lua
new file mode 100644
index 00000000..7a244e97
--- /dev/null
+++ b/objects/jokers/sandbox/faceless.lua
@@ -0,0 +1,54 @@
+SMODS.Atlas({
+ key = "faceless_sandbox",
+ path = "j_faceless_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "faceless_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 1,
+ cost = 4,
+ atlas = "faceless_sandbox",
+ config = { extra = { dollars = 15 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.dollars } }
+ end,
+ calculate = function(self, card, context)
+ if context.discard and context.other_card == context.full_hand[#context.full_hand] then
+ local jacks = 0
+ local queens = 0
+ local kings = 0
+
+ for _, discarded_card in ipairs(context.full_hand) do
+ local rank = discarded_card:get_id()
+ if rank == 11 then jacks = jacks + 1 end
+ if rank == 12 then queens = queens + 1 end
+ if rank == 13 then kings = kings + 1 end
+ end
+
+ if jacks >= 1 and queens >= 1 and kings >= 1 then
+ G.GAME.dollar_buffer = (G.GAME.dollar_buffer or 0) + card.ability.extra.dollars
+ return {
+ dollars = card.ability.extra.dollars,
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.GAME.dollar_buffer = 0
+ return true
+ end,
+ }))
+ end,
+ }
+ end
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/golden_ticket.lua b/objects/jokers/sandbox/golden_ticket.lua
new file mode 100644
index 00000000..982fac04
--- /dev/null
+++ b/objects/jokers/sandbox/golden_ticket.lua
@@ -0,0 +1,46 @@
+SMODS.Joker({
+ key = "golden_ticket_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 5,
+ pos = { x = 5, y = 3 },
+ config = { extra = { dollars = 5, odds = 2 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_gold
+ local numerator, denominator =
+ SMODS.get_probability_vars(card, 1, card.ability.extra.odds, "j_mp_golden_ticket_sandbox")
+ return { vars = { card.ability.extra.dollars, numerator, denominator } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.individual
+ and context.cardarea == G.play
+ and SMODS.has_enhancement(context.other_card, "m_gold")
+ then
+ if SMODS.pseudorandom_probability(card, "j_mp_golden_ticket_sandbox", 1, card.ability.extra.odds) then
+ G.GAME.dollar_buffer = (G.GAME.dollar_buffer or 0) + card.ability.extra.dollars
+ return {
+ dollars = card.ability.extra.dollars,
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.GAME.dollar_buffer = 0
+ return true
+ end,
+ }))
+ end,
+ }
+ end
+ end
+ end,
+ in_pool = function(self, args)
+ return true
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/hit_the_road.lua b/objects/jokers/sandbox/hit_the_road.lua
new file mode 100644
index 00000000..c3ed7a8a
--- /dev/null
+++ b/objects/jokers/sandbox/hit_the_road.lua
@@ -0,0 +1,45 @@
+SMODS.Atlas({
+ key = "hit_the_road_sandbox",
+ path = "j_hit_the_road_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "hit_the_road_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+
+ blueprint_compat = true,
+ rarity = 3,
+ cost = 8,
+ atlas = "hit_the_road_sandbox",
+ config = { extra = { xmult_gain = 0.75, xmult = 1 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.xmult_gain, card.ability.extra.xmult } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.discard
+ and not context.blueprint
+ and not context.other_card.debuff
+ and context.other_card:get_id() == 11
+ then
+ card.ability.extra.xmult = card.ability.extra.xmult + card.ability.extra.xmult_gain
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.xmult } }),
+ colour = G.C.RED,
+ remove = true,
+ }
+ end
+ if context.joker_main then return {
+ xmult = card.ability.extra.xmult,
+ } end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/idol.lua b/objects/jokers/sandbox/idol.lua
new file mode 100644
index 00000000..4570c663
--- /dev/null
+++ b/objects/jokers/sandbox/idol.lua
@@ -0,0 +1,225 @@
+SMODS.Atlas({
+ key = "idol_sandbox_zealot",
+ path = "j_idol_sandbox_bw.png",
+ px = 71,
+ py = 95,
+})
+
+-- Override reset_idol_card to do weighted rank selection for Zealot Idol
+-- Runs globally for all players so the pseudorandom queue stays in sync
+local original_reset_idol_card = reset_idol_card
+function reset_idol_card()
+ original_reset_idol_card()
+
+ G.GAME.current_round.zealot_idol = { id = 14, rank = "Ace" }
+
+ if G.playing_cards == nil then return end
+
+ local count_map = {}
+ local valid_ranks = {}
+
+ for _, v in ipairs(G.playing_cards) do
+ if v.ability.effect ~= "Stone Card" then
+ local val = v.base.value
+ if not count_map[val] then
+ count_map[val] = { count = 0, card = v }
+ table.insert(valid_ranks, count_map[val])
+ end
+ count_map[val].count = count_map[val].count + 1
+ end
+ end
+
+ if #valid_ranks == 0 then return end
+
+ local value_order = {}
+ for i, rank in ipairs(SMODS.Rank.obj_buffer) do
+ value_order[rank] = i
+ end
+
+ table.sort(valid_ranks, function(a, b)
+ if a.count ~= b.count then return a.count > b.count end
+ return value_order[a.card.base.value] < value_order[b.card.base.value]
+ end)
+
+ local total_weight = 0
+ for _, entry in ipairs(valid_ranks) do
+ total_weight = total_weight + entry.count
+ end
+
+ local raw_random = pseudorandom("zealot_idol" .. G.GAME.round_resets.ante)
+ local threshold = 0
+ for _, entry in ipairs(valid_ranks) do
+ threshold = threshold + (entry.count / total_weight)
+ if raw_random < threshold then
+ G.GAME.current_round.zealot_idol = { id = entry.card.base.id, rank = entry.card.base.value }
+ return
+ end
+ end
+
+ G.GAME.current_round.zealot_idol = { id = valid_ranks[1].card.base.id, rank = valid_ranks[1].card.base.value }
+end
+
+SMODS.Joker({
+ key = "idol_sandbox_zealot",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 6,
+ atlas = "idol_sandbox_zealot",
+ config = { extra = { xmult = 1.5 }, mp_sticker_balanced = true },
+ add_to_deck = function(self, card, from_debuff)
+ G.GAME.banned_keys["j_mp_idol_sandbox_collector"] = true
+ if G.shop_jokers and G.shop_jokers.cards then
+ for i = #G.shop_jokers.cards, 1, -1 do
+ local shop_card = G.shop_jokers.cards[i]
+ if shop_card.config.center.key == "j_mp_idol_sandbox_collector" then
+ shop_card.T.r = -0.2
+ shop_card:juice_up(0.3, 0.4)
+ shop_card:start_dissolve()
+ break
+ end
+ end
+ end
+ end,
+ loc_vars = function(self, info_queue, card)
+ local zealot = G.GAME.current_round.zealot_idol or { rank = "Ace" }
+ return {
+ vars = {
+ localize(zealot.rank, "ranks"),
+ card.ability.extra.xmult,
+ },
+ }
+ end,
+ calculate = function(self, card, context)
+ local zealot = G.GAME.current_round.zealot_idol
+ if
+ zealot
+ and context.individual
+ and context.cardarea == G.play
+ and context.other_card:get_id() == zealot.id
+ then
+ return {
+ xmult = card.ability.extra.xmult,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
+
+SMODS.Atlas({
+ key = "idol_sandbox_collector",
+ path = "j_idol_sandbox_color.png",
+ px = 71,
+ py = 95,
+})
+
+local function get_most_common_card()
+ local count_map = {}
+ local valid_idol_cards = {}
+
+ if G.playing_cards == nil then return { id = 14, rank = "Ace", suit = "Spades", count = 1 } end
+
+ for _, v in ipairs(G.playing_cards) do
+ 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 }
+ table.insert(valid_idol_cards, count_map[key])
+ end
+ count_map[key].count = count_map[key].count + 1
+ end
+ end
+
+ if #valid_idol_cards == 0 then return { id = 14, rank = "Ace", suit = "Spades", count = 0 } end
+
+ -- Sort by count descending first, then by suit/value for consistency
+ local value_order = {}
+ for i, rank in ipairs(SMODS.Rank.obj_buffer) do
+ value_order[rank] = i
+ end
+
+ local suit_order = {}
+ for i, suit in ipairs(SMODS.Suit.obj_buffer) do
+ suit_order[suit] = i
+ end
+
+ table.sort(valid_idol_cards, function(a, b)
+ if a.count ~= b.count then return a.count > b.count 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 a_value = a.card.base.value
+ local b_value = b.card.base.value
+ return value_order[a_value] < value_order[b_value]
+ end)
+
+ -- Return the most common card
+ local most_common = valid_idol_cards[1]
+ return {
+ id = most_common.card.base.id,
+ rank = most_common.card.base.value,
+ suit = most_common.card.base.suit,
+ count = most_common.count,
+ }
+end
+
+SMODS.Joker({
+ key = "idol_sandbox_collector",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 6,
+ atlas = "idol_sandbox_collector",
+ config = { extra = { xmult = 1.0, xmult_per_card = 0.05 }, mp_sticker_balanced = true },
+ add_to_deck = function(self, card, from_debuff)
+ G.GAME.banned_keys["j_mp_idol_sandbox_zealot"] = true
+ if G.shop_jokers and G.shop_jokers.cards then
+ for i = #G.shop_jokers.cards, 1, -1 do
+ local shop_card = G.shop_jokers.cards[i]
+ if shop_card.config.center.key == "j_mp_idol_sandbox_zealot" then
+ shop_card.T.r = -0.2
+ shop_card:juice_up(0.3, 0.4)
+ shop_card:start_dissolve()
+ break
+ end
+ end
+ end
+ end,
+ loc_vars = function(self, info_queue, card)
+ local most_common_card = get_most_common_card()
+ local xmult = card.ability.extra.xmult + card.ability.extra.xmult_per_card * most_common_card.count
+ return {
+ vars = {
+ localize(most_common_card.rank, "ranks"),
+ localize(most_common_card.suit, "suits_plural"),
+ xmult,
+ card.ability.extra.xmult_per_card,
+ colours = { G.C.SUITS[most_common_card.suit] },
+ },
+ }
+ end,
+ calculate = function(self, card, context)
+ local most_common_card = get_most_common_card()
+ if
+ context.individual
+ and context.cardarea == G.play
+ and context.other_card:get_id() == most_common_card.id
+ and context.other_card:is_suit(most_common_card.suit)
+ then
+ return {
+ xmult = card.ability.extra.xmult + card.ability.extra.xmult_per_card * most_common_card.count,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" }, idea = { "Fantom" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/juggler.lua b/objects/jokers/sandbox/juggler.lua
new file mode 100644
index 00000000..699a838d
--- /dev/null
+++ b/objects/jokers/sandbox/juggler.lua
@@ -0,0 +1,65 @@
+SMODS.Atlas({
+ key = "juggler_sandbox",
+ path = "j_juggler_sandbox.png",
+ px = 71,
+ py = 95,
+})
+SMODS.Joker({
+ key = "juggler_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ rarity = 1,
+ cost = 4,
+ atlas = "juggler_sandbox",
+ config = { extra = { h_size = 3 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.h_size } }
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ G.hand:change_size(card.ability.extra.h_size)
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ G.hand:change_size(-card.ability.extra.h_size)
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint then
+ if #context.full_hand < 5 then
+ card.ability.extra.h_size = card.ability.extra.h_size - 1
+ G.hand:change_size(-1)
+ card_eval_status_text(card, "extra", nil, nil, nil, { message = "-1 Hand Size" }) -- TODO localize
+
+ if card.ability.extra.h_size <= 0 then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = false
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ card_eval_status_text(card, "extra", nil, nil, nil, { message = localize("k_extinct_ex") })
+ end
+ end
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/loyalty_card.lua b/objects/jokers/sandbox/loyalty_card.lua
new file mode 100644
index 00000000..f614d24a
--- /dev/null
+++ b/objects/jokers/sandbox/loyalty_card.lua
@@ -0,0 +1,72 @@
+SMODS.Atlas({
+ key = "loyalty_card_sandbox",
+ path = "j_loyalty_card_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "loyalty_card_sandbox",
+
+ unlocked = true,
+ discovered = true,
+ no_collection = MP.sandbox_no_collection,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 5,
+ atlas = "loyalty_card_sandbox",
+ config = { extra = { Xmult = 6, every = 4, loyalty_remaining = 4, poker_hand = "???" }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return {
+ vars = {
+ card.ability.extra.poker_hand,
+ math.abs(card.ability.extra.every - card.ability.extra.loyalty_remaining),
+ card.ability.extra.loyalty_remaining,
+ },
+ }
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ local _poker_hands = {}
+ for handname, _ in pairs(G.GAME.hands) do
+ if SMODS.is_poker_hand_visible(handname) then _poker_hands[#_poker_hands + 1] = handname end
+ end
+ card.ability.extra.poker_hand = pseudorandom_element(_poker_hands, "loyalty_card_sandbox")
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval then
+ if context.scoring_name == card.ability.extra.poker_hand then
+ -- Played the loyal hand - decrease loyalty_remaining
+ if card.ability.extra.loyalty_remaining > 0 then
+ card.ability.extra.loyalty_remaining = card.ability.extra.loyalty_remaining - 1
+ end
+ else
+ -- Played a different hand - relationship broken, reset loyalty
+ card.ability.extra.loyalty_remaining = card.ability.extra.every
+ return {
+ message = localize("k_reset"),
+ colour = G.C.RED,
+ }
+ end
+ end
+ if context.joker_main then
+ if not context.blueprint then
+ if card.ability.extra.loyalty_remaining == 0 then
+ local eval = function(card)
+ return card.ability.extra.loyalty_remaining == 0 and not G.RESET_JIGGLES
+ end
+ juice_card_until(card, eval, true)
+ end
+ end
+ if card.ability.extra.loyalty_remaining == 0 then
+ card.ability.extra.loyalty_remaining = card.ability.extra.every
+ return {
+ xmult = card.ability.extra.Xmult,
+ }
+ end
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/lucky_cat.lua b/objects/jokers/sandbox/lucky_cat.lua
new file mode 100644
index 00000000..8201869f
--- /dev/null
+++ b/objects/jokers/sandbox/lucky_cat.lua
@@ -0,0 +1,49 @@
+SMODS.Atlas({
+ key = "lucky_cat_sandbox",
+ path = "j_lucky_cat_sandbox.png",
+ px = 71,
+ py = 95,
+})
+SMODS.Joker({
+ key = "lucky_cat_sandbox",
+
+ unlocked = true,
+ discovered = true,
+ no_collection = MP.sandbox_no_collection,
+ atlas = "lucky_cat_sandbox",
+ blueprint_compat = true,
+ perishable_compat = false,
+ rarity = 2,
+ cost = 6,
+ config = { extra = { Xmult_gain = 0.25, Xmult = 1 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_lucky
+
+ return { vars = { card.ability.extra.Xmult_gain, card.ability.extra.Xmult } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.individual
+ and context.cardarea == G.play
+ and context.other_card.lucky_trigger
+ and not context.blueprint
+ then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_gain
+ if SMODS.pseudorandom_probability(card, "j_lucky_cat_mp_sandbox", 1, 4) then
+ context.other_card:set_ability("m_glass", nil, true)
+ end
+ return {
+ message = localize("k_upgrade_ex"),
+ colour = G.C.MULT,
+ message_card = card,
+ }
+ end
+ if context.joker_main then return {
+ xmult = card.ability.extra.Xmult,
+ } end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/magnet_sandbox.lua b/objects/jokers/sandbox/magnet_sandbox.lua
new file mode 100644
index 00000000..8c70be6f
--- /dev/null
+++ b/objects/jokers/sandbox/magnet_sandbox.lua
@@ -0,0 +1,107 @@
+SMODS.Atlas({
+ key = "magnet",
+ path = "j_magnet.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "magnet_sandbox",
+ atlas = "magnet",
+ rarity = 3,
+ cost = 7,
+ unlocked = true,
+ discovered = true,
+ no_collection = MP.sandbox_no_collection,
+ blueprint_compat = false,
+ eternal_compat = false,
+ perishable_compat = true,
+ config = { extra = { rounds = 2, current_rounds = 0, max_rounds = 5 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return {
+ vars = {
+ card.ability.extra.rounds,
+ card.ability.extra.current_rounds,
+ card.ability.extra.max_rounds,
+ },
+ }
+ end,
+ add_to_deck = function(self, card, from_debuffed)
+ if not from_debuffed and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.send_phantom("j_mp_magnet_sandbox")
+ end
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ if not from_debuff and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.remove_phantom("j_mp_magnet_sandbox")
+ end
+ end,
+ calculate = function(self, card, context)
+ if
+ context.end_of_round
+ and not context.other_card
+ and not context.blueprint
+ and not context.debuffed
+ and (not card.edition or card.edition.type ~= "mp_phantom")
+ then
+ local removed = false
+ card.ability.extra.current_rounds = card.ability.extra.current_rounds + 1
+ if card.ability.extra.current_rounds > card.ability.extra.max_rounds then
+ removed = true
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = false
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ card_eval_status_text(card, "extra", nil, nil, nil, { message = localize("k_no_reward") })
+ end
+ if card.ability.extra.current_rounds == card.ability.extra.rounds then
+ local eval = function(card)
+ return not card.REMOVED
+ end
+ juice_card_until(card, eval, true)
+ end
+ if not removed then
+ return {
+ message = (card.ability.extra.current_rounds < card.ability.extra.rounds)
+ and (card.ability.extra.current_rounds .. "/" .. card.ability.extra.rounds)
+ or localize("k_active_ex"),
+ colour = G.C.FILTER,
+ }
+ end
+ end
+ if
+ context.selling_self
+ and (card.ability.extra.current_rounds >= card.ability.extra.rounds)
+ and not context.blueprint
+ then
+ MP.ACTIONS.magnet()
+ end
+ end,
+
+ mp_credits = {
+ idea = { "Zilver" },
+ art = { "Ganpan140" },
+ code = { "Virtualized" },
+ },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key) and MP.LOBBY.config.multiplayer_jokers
+ end,
+})
diff --git a/objects/jokers/sandbox/mail.lua b/objects/jokers/sandbox/mail.lua
new file mode 100644
index 00000000..f7b51e87
--- /dev/null
+++ b/objects/jokers/sandbox/mail.lua
@@ -0,0 +1,59 @@
+-- Pausing this because baby steps
+SMODS.Atlas({
+ key = "mail_sandbox",
+ path = "j_mail_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "mail_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 1,
+ cost = 4,
+ atlas = "mail_sandbox",
+ config = { extra = { dollars = 5, rank = nil, card_id = nil }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ local rank = card.ability.extra.rank or (G.GAME.current_round.mail_card or {}).rank or "Ace"
+ return {
+ vars = {
+ card.ability.extra.dollars,
+ localize(rank, "ranks"),
+ },
+ }
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ -- Don't overwrite rank/card_id if card is re-added after debuff
+ if card.ability.extra.rank == nil then
+ card.ability.extra.rank = G.GAME.current_round.mail_card.rank
+ card.ability.extra.card_id = G.GAME.current_round.mail_card.id
+ end
+ end,
+ calculate = function(self, card, context)
+ if
+ context.discard
+ and not context.other_card.debuff
+ and context.other_card:get_id() == card.ability.extra.card_id
+ then
+ G.GAME.dollar_buffer = (G.GAME.dollar_buffer or 0) + card.ability.extra.dollars
+ return {
+ dollars = card.ability.extra.dollars,
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.GAME.dollar_buffer = 0
+ return true
+ end,
+ }))
+ end,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/misprint.lua b/objects/jokers/sandbox/misprint.lua
new file mode 100644
index 00000000..caf55511
--- /dev/null
+++ b/objects/jokers/sandbox/misprint.lua
@@ -0,0 +1,37 @@
+SMODS.Atlas({
+ key = "misprint_sandbox",
+ path = "j_misprint_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "misprint_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ atlas = "misprint_sandbox",
+ blueprint_compat = true,
+ rarity = 1,
+ cost = 4,
+ ruleset = "sandbox",
+ config = { extra = { max = 46, min = -23, mult = "???", color = G.C.MULT }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.mult, colours = { card.ability.extra.color } } }
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ local numerator, denominator = SMODS.get_probability_vars(card, 1, 2, "j_mp_misprint_sandbox")
+ card.ability.extra.mult = numerator
+ * pseudorandom("misprint_sandbox", card.ability.extra.min, card.ability.extra.max)
+ if numerator > 1 then card.ability.extra.color = G.C.GREEN end
+ end,
+ calculate = function(self, card, context)
+ if context.joker_main then return {
+ mult = card.ability.extra.mult,
+ } end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/order.lua b/objects/jokers/sandbox/order.lua
new file mode 100644
index 00000000..0c6e8556
--- /dev/null
+++ b/objects/jokers/sandbox/order.lua
@@ -0,0 +1,49 @@
+SMODS.Atlas({
+ key = "order_sandbox",
+ path = "j_order_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "order_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 3,
+ cost = 8,
+ atlas = "order_sandbox",
+ config = { extra = { Xmult = 3, Xmult_mod = 0.5, type = "Straight" }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult_mod, card.ability.extra.Xmult } }
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint then
+ if next(context.poker_hands[card.ability.extra.type]) then
+ -- Played a Straight - increase Xmult
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_mod
+ return {
+ message = localize("k_upgrade_ex"),
+ colour = G.C.RED,
+ }
+ else
+ -- Didn't play a Straight - reset to base value
+ card.ability.extra.Xmult = 3
+ return {
+ message = localize("k_reset"),
+ colour = G.C.RED,
+ }
+ end
+ end
+ if context.joker_main and next(context.poker_hands[card.ability.extra.type]) then
+ return {
+ xmult = card.ability.extra.Xmult,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/photograph.lua b/objects/jokers/sandbox/photograph.lua
new file mode 100644
index 00000000..439ebd91
--- /dev/null
+++ b/objects/jokers/sandbox/photograph.lua
@@ -0,0 +1,40 @@
+SMODS.Atlas({
+ key = "photograph_sandbox",
+ path = "j_photograph_sandbox.png",
+ px = 71,
+ py = 95,
+})
+SMODS.Joker({
+ key = "photograph_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ atlas = "photograph_sandbox",
+ rarity = 1,
+ cost = 5,
+ pixel_size = { h = 95 / 1.2 },
+ config = { extra = { xmult = 4 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.xmult } }
+ end,
+ calculate = function(self, card, context)
+ if context.individual and context.cardarea == G.play then
+ local face_count = 0
+ for i = 1, #context.scoring_hand do
+ if context.scoring_hand[i]:is_face() then face_count = face_count + 1 end
+ end
+
+ if face_count == 1 and context.other_card:is_face() then
+ return {
+ xmult = card.ability.extra.xmult,
+ }
+ end
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/ride_the_bus.lua b/objects/jokers/sandbox/ride_the_bus.lua
new file mode 100644
index 00000000..76345840
--- /dev/null
+++ b/objects/jokers/sandbox/ride_the_bus.lua
@@ -0,0 +1,70 @@
+SMODS.Atlas({
+ key = "ride_the_bus_sandbox",
+ path = "j_ride_the_bus_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "ride_the_bus_sandbox",
+ blueprint_compat = true,
+ perishable_compat = false,
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ rarity = 1,
+ cost = 6,
+ atlas = "ride_the_bus_sandbox",
+ config = { extra = { mult_gain = 1, mult = 0, max_gain = 5 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.mult } }
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint then
+ local faces = false
+ for _, playing_card in ipairs(context.scoring_hand) do
+ if playing_card:is_face() then
+ faces = true
+ break
+ end
+ end
+ if faces then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1")
+ card.T.r = -0.2
+ card:juice_up(0.3, 0.4)
+ card.states.drag.is = false
+ card.children.center.pinch.x = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.jokers:remove_card(card)
+ card:remove()
+ card = nil
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ card_eval_status_text(card, "extra", nil, nil, nil, { message = localize("k_extinct_ex") })
+ else
+ card.ability.extra.mult = card.ability.extra.mult + card.ability.extra.mult_gain
+ if card.ability.extra.mult_gain < card.ability.extra.max_gain then
+ card.ability.extra.mult_gain = card.ability.extra.mult_gain + 1
+ end
+ end
+ end
+ if context.joker_main then return {
+ mult = card.ability.extra.mult,
+ } end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/runner.lua b/objects/jokers/sandbox/runner.lua
new file mode 100644
index 00000000..407e718c
--- /dev/null
+++ b/objects/jokers/sandbox/runner.lua
@@ -0,0 +1,41 @@
+SMODS.Atlas({
+ key = "runner_sandbox",
+ path = "j_runner_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "runner_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ blueprint_compat = true,
+ perishable_compat = false,
+
+ unlocked = true,
+ discovered = true,
+ rarity = 1,
+ cost = 5,
+ atlas = "runner_sandbox",
+ config = { extra = { chips = 0, chip_mod = 50 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.chips } }
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint and next(context.poker_hands["Straight"]) then
+ card.ability.extra.chips = card.ability.extra.chips + card.ability.extra.chip_mod
+ return {
+ message = localize("k_upgrade_ex"),
+ colour = G.C.CHIPS,
+ }
+ end
+ if context.joker_main and next(context.poker_hands["Straight"]) then
+ return {
+ chips = card.ability.extra.chips,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/satellite.lua b/objects/jokers/sandbox/satellite.lua
new file mode 100644
index 00000000..6fb12e00
--- /dev/null
+++ b/objects/jokers/sandbox/satellite.lua
@@ -0,0 +1,49 @@
+SMODS.Atlas({
+ key = "satellite_sandbox",
+ path = "j_satellite_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "satellite_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ rarity = 2,
+ cost = 6,
+ atlas = "satellite_sandbox",
+ config = { extra = { dollars = 1 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.dollars } }
+ end,
+ calculate = function(self, card, context)
+ if context.using_consumeable and not context.blueprint and context.consumeable.ability.set == "Planet" then
+ card.ability.extra.dollars = card.ability.extra.dollars + 1
+ return {
+ -- todo fix
+ message = localize({ type = "variable", key = "k_val_up", vars = { 1 } }),
+ }
+ end
+ if context.ending_shop and not context.blueprint then
+ if card.ability.extra.dollars > 0 then
+ local decrease = math.min(card.ability.extra.dollars, 2)
+ card.ability.extra.dollars = card.ability.extra.dollars - decrease
+ play_sound("slice1", 0.96 + math.random() * 0.08)
+ play_sound("slice1", 0.86 + math.random() * 0.08)
+ -- todo fix
+ return {
+ message = localize({ type = "variable", key = "k_melted_ex", vars = { decrease } }),
+ }
+ end
+ end
+ end,
+ calc_dollar_bonus = function(self, card)
+ return card.ability.extra.dollars > 0 and card.ability.extra.dollars or nil
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/square.lua b/objects/jokers/sandbox/square.lua
new file mode 100644
index 00000000..4af1831e
--- /dev/null
+++ b/objects/jokers/sandbox/square.lua
@@ -0,0 +1,40 @@
+SMODS.Atlas({
+ key = "square_sandbox",
+ path = "j_square_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "square_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = false,
+ rarity = 1,
+ cost = 4,
+ atlas = "square_sandbox",
+ pixel_size = { h = 71 },
+ config = { extra = { chips = 64, chip_mod = 16 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.chips, card.ability.extra.chip_mod } }
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint and #context.full_hand == 4 then
+ card.ability.extra.chips = card.ability.extra.chips + card.ability.extra.chip_mod
+ return {
+ message = localize("k_upgrade_ex"),
+ colour = G.C.CHIPS,
+ }
+ end
+ if context.joker_main and #context.full_hand == 4 then return {
+ chips = card.ability.extra.chips,
+ } end
+ end,
+ mp_credits = { idea = { "Owen" }, code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/steel_joker.lua b/objects/jokers/sandbox/steel_joker.lua
new file mode 100644
index 00000000..eb1942b0
--- /dev/null
+++ b/objects/jokers/sandbox/steel_joker.lua
@@ -0,0 +1,36 @@
+SMODS.Atlas({
+ key = "steel_joker_sandbox",
+ path = "j_steel_joker_sandbox.png",
+ px = 71,
+ py = 95,
+})
+SMODS.Joker({
+ key = "steel_joker_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 7,
+ atlas = "steel_joker_sandbox",
+ config = { extra = { repetitions = 1 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ info_queue[#info_queue + 1] = G.P_CENTERS.m_steel
+ end,
+ calculate = function(self, card, context)
+ if
+ context.repetition
+ and context.cardarea == G.play
+ and SMODS.has_enhancement(context.other_card, "m_steel")
+ then
+ return {
+ repetitions = card.ability.extra.repetitions,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/throwback.lua b/objects/jokers/sandbox/throwback.lua
new file mode 100644
index 00000000..b62427f7
--- /dev/null
+++ b/objects/jokers/sandbox/throwback.lua
@@ -0,0 +1,92 @@
+local function calculate_total_mult(card)
+ return 1 + (G.GAME.skips * card.ability.extra.base_mult) + card.ability.extra.owned_skip_mult
+end
+
+local function apply_skip_bonus(card)
+ card.ability.extra.owned_skip_mult = card.ability.extra.owned_skip_mult + card.ability.extra.skip_bonus
+ local total_mult = calculate_total_mult(card)
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_xmult",
+ vars = { total_mult },
+ }),
+ }
+end
+
+local function apply_round_penalty(card)
+ local prev_mult = card.ability.extra.owned_skip_mult
+ card.ability.extra.owned_skip_mult =
+ math.max(0, card.ability.extra.owned_skip_mult - card.ability.extra.round_penalty)
+ if prev_mult > card.ability.extra.owned_skip_mult then
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_xmult_minus",
+ vars = { card.ability.extra.round_penalty },
+ }),
+ colour = G.C.RED,
+ }
+ end
+end
+
+SMODS.Atlas({
+ key = "throwback_sandbox",
+ path = "j_throwback_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "throwback_sandbox",
+ no_collection = MP.sandbox_no_collection,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 6,
+ atlas = "throwback_sandbox",
+ config = {
+ extra = {
+ base_mult = 0.25,
+ skip_bonus = 0.75,
+ round_penalty = 0.75,
+ owned_skip_mult = 0,
+ skipped_this_round = false,
+ },
+ mp_sticker_balanced = true,
+ },
+ loc_vars = function(self, info_queue, card)
+ return {
+ vars = {
+ calculate_total_mult(card),
+ card.ability.extra.base_mult,
+ card.ability.extra.skip_bonus,
+ card.ability.extra.round_penalty,
+ },
+ }
+ end,
+ calculate = function(self, card, context)
+ if context.skip_blind and not context.blueprint then
+ card.ability.extra.skipped_this_round = true
+ return apply_skip_bonus(card)
+ end
+ if context.end_of_round and context.game_over == false and context.main_eval and not context.blueprint then
+ if not card.ability.extra.skipped_this_round then
+ return apply_round_penalty(card)
+ else
+ card.ability.extra.skipped_this_round = false
+ end
+ end
+ if context.joker_main then
+ local total_mult = calculate_total_mult(card)
+ return {
+ xmult = total_mult,
+ }
+ end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/sandbox/vampire.lua b/objects/jokers/sandbox/vampire.lua
new file mode 100644
index 00000000..1528d014
--- /dev/null
+++ b/objects/jokers/sandbox/vampire.lua
@@ -0,0 +1,73 @@
+SMODS.Atlas({
+ key = "vampire_sandbox",
+ path = "j_vampire_sandbox.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "vampire_sandbox",
+ no_collection = MP.sandbox_no_collection,
+
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = false,
+ rarity = 2,
+ cost = 7,
+ atlas = "vampire_sandbox",
+ config = { extra = { Xmult_gain = 0.2, Xmult = 1, stone_money = 3 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.Xmult_gain, card.ability.extra.Xmult, card.ability.extra.stone_money } }
+ end,
+ calculate = function(self, card, context)
+ if context.before and context.main_eval and not context.blueprint then
+ local enhanced = {}
+ local stone_cards = {}
+ for _, scored_card in ipairs(context.scoring_hand) do
+ if
+ next(SMODS.get_enhancements(scored_card))
+ and not scored_card.debuff
+ and not scored_card.vampired
+ and not SMODS.has_enhancement(scored_card, "m_stone") -- todo like this - does it even work?
+ then
+ enhanced[#enhanced + 1] = scored_card
+ scored_card.vampired = true
+ scored_card:set_ability("m_stone", nil, true)
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ scored_card:juice_up()
+ scored_card.vampired = nil
+ return true
+ end,
+ }))
+ elseif SMODS.has_enhancement(scored_card, "m_stone") and not scored_card.debuff then
+ stone_cards[#stone_cards + 1] = scored_card
+ end
+ end
+
+ if #enhanced > 0 then
+ card.ability.extra.Xmult = card.ability.extra.Xmult + card.ability.extra.Xmult_gain * #enhanced
+ return {
+ message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra.Xmult } }),
+ colour = G.C.MULT,
+ }
+ end
+
+ if #stone_cards > 0 then
+ ease_dollars(card.ability.extra.stone_money * #stone_cards)
+ return {
+ message = localize("$") .. (card.ability.extra.stone_money * #stone_cards),
+ colour = G.C.MONEY,
+ }
+ end
+ end
+ if context.joker_main then return {
+ xmult = card.ability.extra.Xmult,
+ } end
+ end,
+ mp_credits = { code = { "steph" } },
+ mp_include = function(self)
+ return MP.SANDBOX.is_joker_allowed(self.key)
+ end,
+})
diff --git a/objects/jokers/skip_off.lua b/objects/jokers/skip_off.lua
new file mode 100644
index 00000000..7c7213f3
--- /dev/null
+++ b/objects/jokers/skip_off.lua
@@ -0,0 +1,63 @@
+SMODS.Atlas({
+ key = "skip_off",
+ path = "j_skip_off.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "skip_off",
+ atlas = "skip_off",
+ rarity = 2,
+ cost = 5,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = true,
+ perishable_compat = true,
+ config = { extra = { hands = 0, discards = 0, extra_hands = 1, extra_discards = 1 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return {
+ vars = {
+ card.ability.extra.extra_hands,
+ card.ability.extra.extra_discards,
+ card.ability.extra.hands,
+ card.ability.extra.discards,
+ G.GAME.skips ~= nil and MP.GAME.enemy.skips ~= nil and localize({
+ type = "variable",
+ key = MP.GAME.enemy.skips > G.GAME.skips and "a_mp_skips_behind"
+ or MP.GAME.enemy.skips == G.GAME.skips and "a_mp_skips_tied"
+ or "a_mp_skips_ahead",
+ vars = { math.abs(MP.GAME.enemy.skips - G.GAME.skips) },
+ })[1] or "",
+ },
+ }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ update = function(self, card, dt)
+ if G.STAGE == G.STAGES.RUN and G.GAME.skips ~= nil and MP.GAME.enemy.skips ~= nil then
+ local skip_diff = (math.max(G.GAME.skips - MP.GAME.enemy.skips, 0))
+ card.ability.extra.hands = skip_diff * card.ability.extra.extra_hands
+ card.ability.extra.discards = skip_diff * card.ability.extra.extra_discards
+ end
+ end,
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and context.setting_blind and not context.blueprint then
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ ease_hands_played(card.ability.extra.hands)
+ ease_discard(card.ability.extra.discards, nil, true)
+ return true
+ end,
+ }))
+ end
+ end,
+ mp_credits = {
+ idea = { "Dr. Monty", "Carter" },
+ art = { "Aura!" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/speedrun.lua b/objects/jokers/speedrun.lua
new file mode 100644
index 00000000..d2284da0
--- /dev/null
+++ b/objects/jokers/speedrun.lua
@@ -0,0 +1,65 @@
+SMODS.Atlas({
+ key = "speedrun",
+ path = "j_speedrun.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "speedrun",
+ atlas = "speedrun",
+ rarity = 2,
+ cost = 6,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = true,
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = {} }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ calculate = function(self, card, context)
+ if
+ context.mp_speedrun
+ and (not card.edition or card.edition.type ~= "mp_phantom")
+ and #G.consumeables.cards + G.GAME.consumeable_buffer < G.consumeables.config.card_limit
+ then
+ G.GAME.consumeable_buffer = G.GAME.consumeable_buffer + 1
+ G.E_MANAGER:add_event(Event({
+ trigger = "before",
+ delay = 0.0,
+ func = function()
+ local card = create_card("Spectral", G.consumeables, nil, nil, nil, nil, nil, "mp_speedrun")
+ card:add_to_deck()
+ G.consumeables:emplace(card)
+ G.GAME.consumeable_buffer = 0
+ return true
+ end,
+ }))
+ return {
+ message = localize("k_plus_spectral"),
+ colour = G.C.SECONDARY_SET.Spectral,
+ card = card,
+ }
+ end
+ end,
+ add_to_deck = function(self, card, from_debuffed)
+ if not from_debuffed and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.send_phantom("j_mp_speedrun")
+ end
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ if not from_debuff and (not card.edition or card.edition.type ~= "mp_phantom") then
+ MP.ACTIONS.remove_phantom("j_mp_speedrun")
+ end
+ end,
+ mp_credits = {
+ idea = { "Virtualized" },
+ art = { "Aura!" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/jokers/standard/bloodstone.lua b/objects/jokers/standard/bloodstone.lua
new file mode 100644
index 00000000..7874daca
--- /dev/null
+++ b/objects/jokers/standard/bloodstone.lua
@@ -0,0 +1,68 @@
+-- this is kinda strange but we can just override the logic for pvp only rather than re-implementing it again, bc if we don't return anything, it'll run the normal logic
+
+SMODS.Joker({
+ key = "bloodstone",
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = true,
+ eternal_compat = true,
+ rarity = 2,
+ cost = 7,
+ pos = { x = 0, y = 8 },
+ no_collection = true,
+ mp_include = function(self)
+ return MP.UTILS.is_standard_ruleset() and MP.LOBBY.code
+ end,
+ config = { extra = { odds = 2, Xmult = 1.5 } },
+ loc_vars = function(self, info_queue, card)
+ return {
+ key = "j_bloodstone",
+ vars = {
+ "" .. (G.GAME and G.GAME.probabilities.normal or 1),
+ card.ability.extra.odds,
+ card.ability.extra.Xmult,
+ },
+ }
+ end,
+ calculate = function(self, card, context)
+ if MP.is_pvp_boss() then
+ if not context.blueprint then
+ if context.before then
+ G.GAME.round_resets.mp_bloodstone = G.GAME.round_resets.mp_bloodstone or {}
+ G.GAME.round_resets.mp_bloodstone[MP.order_round_based(true)] = G.GAME.round_resets.mp_bloodstone[MP.order_round_based(
+ true
+ )] or {}
+ G.GAME.round_resets.mp_bsindex = 0
+ end
+ end
+ if context.individual and context.cardarea == G.play then
+ if context.other_card:is_suit("Hearts") then
+ local stored_queue = G.GAME.round_resets.mp_bloodstone[MP.order_round_based(true)]
+ G.GAME.round_resets.mp_bsindex = G.GAME.round_resets.mp_bsindex + 1 -- increment before indexing
+ stored_queue[G.GAME.round_resets.mp_bsindex] = stored_queue[G.GAME.round_resets.mp_bsindex]
+ or pseudorandom("bloodstone" .. MP.order_round_based(true))
+ if
+ stored_queue[G.GAME.round_resets.mp_bsindex]
+ < G.GAME.probabilities.normal / card.ability.extra.odds
+ then
+ return {
+ x_mult = card.ability.extra.Xmult,
+ card = card,
+ }
+ end
+ end
+ end
+ elseif context.individual and context.cardarea == G.play then
+ if
+ context.other_card:is_suit("Hearts")
+ and pseudorandom("bloodstone") < G.GAME.probabilities.normal / card.ability.extra.odds
+ then
+ return {
+ x_mult = card.ability.extra.Xmult,
+ card = card,
+ }
+ end
+ end
+ end,
+})
diff --git a/objects/jokers/standard/hanging_chad.lua b/objects/jokers/standard/hanging_chad.lua
new file mode 100644
index 00000000..67903ad2
--- /dev/null
+++ b/objects/jokers/standard/hanging_chad.lua
@@ -0,0 +1,39 @@
+SMODS.Joker({
+ key = "hanging_chad",
+ no_collection = true,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ perishable_compat = true,
+ eternal_compat = true,
+ rarity = 1,
+ cost = 4,
+ pos = { x = 9, y = 6 },
+ config = { extra = 1, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = {
+ card.ability.extra,
+ } }
+ end,
+ calculate = function(self, card, context)
+ if context.cardarea == G.play and context.repetition then
+ if context.other_card == context.scoring_hand[1] then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra,
+ card = card,
+ }
+ end
+ if context.other_card == context.scoring_hand[2] then
+ return {
+ message = localize("k_again_ex"),
+ repetitions = card.ability.extra,
+ card = card,
+ }
+ end
+ end
+ end,
+ mp_include = function(self)
+ return (MP.UTILS.is_standard_ruleset() or MP.LOBBY.config.ruleset == "ruleset_mp_sandbox") and MP.LOBBY.code
+ end,
+})
diff --git a/objects/jokers/standard/seltzer.lua b/objects/jokers/standard/seltzer.lua
new file mode 100644
index 00000000..546cb305
--- /dev/null
+++ b/objects/jokers/standard/seltzer.lua
@@ -0,0 +1,38 @@
+SMODS.Joker({
+ key = "seltzer",
+ no_collection = true,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = false,
+ rarity = 2,
+ cost = 5,
+ pos = { x = 3, y = 15 },
+ config = { extra = { hands_left = 8 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.hands_left } }
+ end,
+ calculate = function(self, card, context)
+ if context.repetition and context.cardarea == G.play then return {
+ repetitions = 1,
+ } end
+ if context.after and not context.blueprint then
+ if card.ability.extra.hands_left - 1 <= 0 then
+ SMODS.destroy_cards(card, nil, nil, true)
+ return {
+ message = localize("k_drank_ex"),
+ colour = G.C.FILTER,
+ }
+ else
+ card.ability.extra.hands_left = card.ability.extra.hands_left - 1
+ return {
+ message = card.ability.extra.hands_left .. "",
+ colour = G.C.FILTER,
+ }
+ end
+ end
+ end,
+ mp_include = function(self)
+ return MP.UTILS.is_standard_ruleset() and MP.LOBBY.code
+ end,
+})
diff --git a/objects/jokers/standard/ticket.lua b/objects/jokers/standard/ticket.lua
new file mode 100644
index 00000000..b226ea2e
--- /dev/null
+++ b/objects/jokers/standard/ticket.lua
@@ -0,0 +1,38 @@
+SMODS.Joker({
+ key = "ticket",
+ no_collection = true,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ rarity = 2,
+ cost = 6,
+ pos = { x = 5, y = 3 },
+ config = { extra = { dollars = 3 }, mp_sticker_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 } }
+ end,
+ calculate = function(self, card, context)
+ if
+ context.individual
+ and context.cardarea == G.play
+ and SMODS.has_enhancement(context.other_card, "m_gold")
+ then
+ G.GAME.dollar_buffer = (G.GAME.dollar_buffer or 0) + card.ability.extra.dollars
+ return {
+ dollars = card.ability.extra.dollars,
+ func = function() -- This is for timing purposes, it runs after the dollar manipulation
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ G.GAME.dollar_buffer = 0
+ return true
+ end,
+ }))
+ end,
+ }
+ end
+ end,
+ mp_include = function(self)
+ return MP.UTILS.is_standard_ruleset() and MP.LOBBY.code
+ end,
+})
diff --git a/objects/jokers/standard/turtle_bean.lua b/objects/jokers/standard/turtle_bean.lua
new file mode 100644
index 00000000..44969c7d
--- /dev/null
+++ b/objects/jokers/standard/turtle_bean.lua
@@ -0,0 +1,46 @@
+SMODS.Joker({
+ key = "turtle_bean",
+ no_collection = true,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = false,
+ eternal_compat = false,
+ rarity = 2,
+ cost = 5,
+ pos = { x = 4, y = 13 },
+ config = { extra = { h_size = 4, h_mod = 1 }, mp_sticker_balanced = true },
+ loc_vars = function(self, info_queue, card)
+ return { vars = { card.ability.extra.h_size, card.ability.extra.h_mod } }
+ end,
+ calculate = function(self, card, context)
+ if context.end_of_round and context.game_over == false and context.main_eval and not context.blueprint then
+ if card.ability.extra.h_size - card.ability.extra.h_mod <= 0 then
+ SMODS.destroy_cards(card, nil, nil, true)
+ return {
+ message = localize("k_eaten_ex"),
+ colour = G.C.FILTER,
+ }
+ else
+ card.ability.extra.h_size = card.ability.extra.h_size - card.ability.extra.h_mod
+ G.hand:change_size(-card.ability.extra.h_mod)
+ return {
+ message = localize({
+ type = "variable",
+ key = "a_handsize_minus",
+ vars = { card.ability.extra.h_mod },
+ }),
+ colour = G.C.FILTER,
+ }
+ end
+ end
+ end,
+ add_to_deck = function(self, card, from_debuff)
+ G.hand:change_size(card.ability.extra.h_size)
+ end,
+ remove_from_deck = function(self, card, from_debuff)
+ G.hand:change_size(-card.ability.extra.h_size)
+ end,
+ mp_include = function(self)
+ return MP.UTILS.is_standard_ruleset() and MP.LOBBY.code
+ end,
+})
diff --git a/objects/jokers/taxes.lua b/objects/jokers/taxes.lua
new file mode 100644
index 00000000..ca84474b
--- /dev/null
+++ b/objects/jokers/taxes.lua
@@ -0,0 +1,61 @@
+local function next_taxes_total_mult_gain(card)
+ local sells = MP.GAME.enemy.sells_per_ante[G.GAME.round_resets.ante] or 0
+
+ -- If PvP hasn't been reached for the first time, accumulate sells from previous Antes
+ if G.GAME.round_resets.ante <= MP.LOBBY.config.pvp_start_round then
+ for i = 1, G.GAME.round_resets.ante - 1 do
+ sells = sells + (MP.GAME.enemy.sells_per_ante[i] or 0)
+ end
+ end
+
+ return sells * card.ability.extra.mult_gain
+end
+
+SMODS.Atlas({
+ key = "taxes",
+ path = "j_taxes.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Joker({
+ key = "taxes",
+ atlas = "taxes",
+ rarity = 1,
+ cost = 5,
+ unlocked = true,
+ discovered = true,
+ blueprint_compat = true,
+ eternal_compat = true,
+ perishable_compat = false,
+ config = { extra = { mult_gain = 4, mult = 0 } },
+ loc_vars = function(self, info_queue, card)
+ MP.UTILS.add_nemesis_info(info_queue)
+ return { vars = { card.ability.extra.mult_gain, card.ability.extra.mult } }
+ end,
+ mp_include = function(self)
+ return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers
+ end,
+ calculate = function(self, card, context)
+ if context.cardarea == G.jokers and context.joker_main then
+ return {
+ mult = card.ability.extra.mult,
+ }
+ elseif
+ context.cardarea == G.jokers
+ and context.setting_blind
+ and not context.blueprint
+ and context.blind.key == "bl_mp_nemesis"
+ then
+ card.ability.extra.mult = card.ability.extra.mult + next_taxes_total_mult_gain(card)
+ return {
+ message = localize("k_filed_ex"),
+ }
+ end
+ end,
+ mp_credits = {
+ idea = { "Zwei" },
+ art = { "Kittyknight" },
+ code = { "Virtualized" },
+ },
+})
diff --git a/objects/stakes/00_planet.lua b/objects/stakes/00_planet.lua
new file mode 100644
index 00000000..59288443
--- /dev/null
+++ b/objects/stakes/00_planet.lua
@@ -0,0 +1,22 @@
+SMODS.Stake({
+ name = "Planet Stake",
+ key = "planet",
+ unlocked = true,
+ applied_stakes = {},
+ above_stake = "gold",
+ atlas = "sandbox_stakes",
+ pos = { x = 0, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ -- green to black
+ G.GAME.modifiers.no_blind_reward = G.GAME.modifiers.no_blind_reward or {}
+ G.GAME.modifiers.no_blind_reward.Small = true
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1
+ G.GAME.modifiers.enable_eternals_in_shop = true
+ -- no blue
+ -- purple and orange
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1
+ G.GAME.modifiers.enable_perishables_in_shop = true -- orange
+ end,
+ colour = G.C.Planet,
+})
diff --git a/objects/stakes/01_spectral.lua b/objects/stakes/01_spectral.lua
new file mode 100644
index 00000000..e9505a67
--- /dev/null
+++ b/objects/stakes/01_spectral.lua
@@ -0,0 +1,15 @@
+SMODS.Stake({
+ name = "Spectral Stake",
+ unlocked = true,
+ key = "spectral",
+ applied_stakes = { "planet" },
+ atlas = "sandbox_stakes",
+ pos = { x = 1, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.enable_rentals_in_shop = true -- gold
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1 -- yeehaw
+ end,
+ colour = HEX("000000"),
+ above_stake = "planet",
+})
diff --git a/objects/stakes/02_spectralplus.lua b/objects/stakes/02_spectralplus.lua
new file mode 100644
index 00000000..0048a0f3
--- /dev/null
+++ b/objects/stakes/02_spectralplus.lua
@@ -0,0 +1,15 @@
+SMODS.Stake({
+ name = "Spectral+ Stake",
+ unlocked = true,
+ key = "spectralplus",
+ applied_stakes = { "spectral" },
+ atlas = "sandbox_stakes",
+ pos = { x = 2, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1
+ end,
+ colour = HEX("000000"),
+ shiny = true,
+ above_stake = "spectral",
+})
diff --git a/objects/stakes/1_plastic.lua b/objects/stakes/1_plastic.lua
new file mode 100644
index 00000000..fded915e
--- /dev/null
+++ b/objects/stakes/1_plastic.lua
@@ -0,0 +1,78 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Plastic Stake",
+ key = "plastic",
+ unlocked = true,
+ applied_stakes = { "white" },
+ prefix_config = { applied_stakes = { mod = false } },
+ atlas = "alt_mp_stakes",
+ pos = { x = 1, y = 0 },
+ sticker_pos = { x = 3, y = 1 }, -- no sticker ig
+ modifiers = function()
+ G.GAME.modifiers.mp_modified_interest_rate = 10
+ end,
+ colour = HEX("FF9696"),
+ -- shiny = true,
+ })
+
+ local evaluate_round_ref = G.FUNCS.evaluate_round
+ G.FUNCS.evaluate_round = function()
+ if G.GAME.modifiers.mp_modified_interest_rate then
+ -- ok we're doing something really stupid to avoid jank
+ -- basically our target is in the middle of the function and it's difficult to find a patchable line that keeps any possible mod compat
+ -- so i'm faking no interest but having a separate variable that lets us know if we're faking no interest so we can actually run our own interest behaviour
+ -- this ALSO breaks if another mod is doing the exact same thing but holy shit i do not care there's no way a mod is doing the exact same thing
+ -- ...behaviour override is kinda cringe but it's better than a bunch of obsessive and fragile at patches
+ G.GAME.modifiers.TRUE_no_interest = G.GAME.modifiers.no_interest
+ G.GAME.modifiers.no_interest = true
+ local ret = evaluate_round_ref()
+ G.GAME.modifiers.no_interest = G.GAME.modifiers.TRUE_no_interest
+ return ret
+ end
+ return evaluate_round_ref()
+ end
+
+ -- change ttm behaviour
+ -- annoying
+ -- yes i know this is a buff
+ SMODS.Joker:take_ownership("to_the_moon", {
+ loc_vars = function(self, info_queue, card)
+ return {
+ vars = { card.ability.extra, G.GAME.modifiers and G.GAME.modifiers.mp_modified_interest_rate or 5 },
+ key = self.key .. "_mp",
+ }
+ end,
+ }, true)
+
+ local set_ability_ref = Card.set_ability
+ function Card:set_ability(center, initial, delay_sprites)
+ set_ability_ref(self, center, initial, delay_sprites)
+ if center == G.P_CENTERS.j_to_the_moon then
+ if G.GAME.modifiers.mp_modified_interest_rate then
+ self.ability.extra = self.ability.extra / (5 / G.GAME.modifiers.mp_modified_interest_rate)
+ end
+ end
+ end
+
+ -- technically incorrect but for consistency purposes
+ SMODS.Voucher:take_ownership("seed_money", {
+ loc_vars = function(self, info_queue, card)
+ return {
+ vars = {
+ card.ability.extra / (5 / (G.GAME.modifiers and G.GAME.modifiers.mp_modified_interest_rate or 5)),
+ },
+ }
+ end,
+ }, true)
+
+ SMODS.Voucher:take_ownership("money_tree", {
+ loc_vars = function(self, info_queue, card)
+ return {
+ vars = {
+ card.ability.extra / (5 / (G.GAME.modifiers and G.GAME.modifiers.mp_modified_interest_rate or 5)),
+ },
+ }
+ end,
+ }, true)
+end
diff --git a/objects/stakes/2_pebble.lua b/objects/stakes/2_pebble.lua
new file mode 100644
index 00000000..19797283
--- /dev/null
+++ b/objects/stakes/2_pebble.lua
@@ -0,0 +1,18 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Pebble Stake",
+ key = "pebble",
+ unlocked = true,
+ applied_stakes = { "plastic" },
+ above_stake = "plastic",
+ atlas = "alt_mp_stakes",
+ pos = { x = 2, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1
+ end,
+ colour = HEX("949494"),
+ -- shiny = true,
+ })
+end
diff --git a/objects/stakes/3_ferrite.lua b/objects/stakes/3_ferrite.lua
new file mode 100644
index 00000000..9fa0c19c
--- /dev/null
+++ b/objects/stakes/3_ferrite.lua
@@ -0,0 +1,18 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Ferrite Stake",
+ key = "ferrite",
+ unlocked = true,
+ applied_stakes = { "pebble" },
+ above_stake = "pebble",
+ atlas = "alt_mp_stakes",
+ pos = { x = 3, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.mp_enable_persistent_jokers = true
+ end,
+ colour = HEX("B2B2B2"),
+ -- shiny = true,
+ })
+end
diff --git a/objects/stakes/4_pyrite.lua b/objects/stakes/4_pyrite.lua
new file mode 100644
index 00000000..8dc80bec
--- /dev/null
+++ b/objects/stakes/4_pyrite.lua
@@ -0,0 +1,31 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Pyrite Stake",
+ key = "pyrite",
+ unlocked = true,
+ applied_stakes = { "ferrite" },
+ above_stake = "ferrite",
+ atlas = "alt_mp_stakes",
+ pos = { x = 4, y = 0 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.mp_extra_reroll_increment = 1
+ end,
+ colour = HEX("F2D955"),
+ -- shiny = true,
+ })
+
+ local calculate_reroll_cost_ref = calculate_reroll_cost
+ function calculate_reroll_cost(skip_increment)
+ calculate_reroll_cost_ref(skip_increment)
+ if G.GAME.modifiers and G.GAME.modifiers.mp_extra_reroll_increment then
+ if not skip_increment then
+ G.GAME.current_round.reroll_cost_increase = G.GAME.current_round.reroll_cost_increase
+ + G.GAME.modifiers.mp_extra_reroll_increment
+ end
+ G.GAME.current_round.reroll_cost = (G.GAME.round_resets.temp_reroll_cost or G.GAME.round_resets.reroll_cost)
+ + G.GAME.current_round.reroll_cost_increase
+ end
+ end
+end
diff --git a/objects/stakes/5_jade.lua b/objects/stakes/5_jade.lua
new file mode 100644
index 00000000..edf5ca02
--- /dev/null
+++ b/objects/stakes/5_jade.lua
@@ -0,0 +1,18 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Jade Stake",
+ key = "jade",
+ unlocked = true,
+ applied_stakes = { "pyrite" },
+ above_stake = "pyrite",
+ atlas = "alt_mp_stakes",
+ pos = { x = 0, y = 1 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.scaling = (G.GAME.modifiers.scaling or 1) + 1
+ end,
+ colour = HEX("3EA93C"),
+ -- shiny = true,
+ })
+end
diff --git a/objects/stakes/6_crystal.lua b/objects/stakes/6_crystal.lua
new file mode 100644
index 00000000..30ea3659
--- /dev/null
+++ b/objects/stakes/6_crystal.lua
@@ -0,0 +1,18 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Crystal Stake",
+ key = "crystal",
+ unlocked = true,
+ applied_stakes = { "jade" },
+ above_stake = "jade",
+ atlas = "alt_mp_stakes",
+ pos = { x = 1, y = 1 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.mp_enable_unreliable_jokers = true
+ end,
+ colour = HEX("BCF9FF"),
+ shiny = true,
+ })
+end
diff --git a/objects/stakes/7_antimatter.lua b/objects/stakes/7_antimatter.lua
new file mode 100644
index 00000000..454496bf
--- /dev/null
+++ b/objects/stakes/7_antimatter.lua
@@ -0,0 +1,18 @@
+if MP.EXPERIMENTAL.alt_stakes then
+ SMODS.Stake({
+ mp_alt_stake = true,
+ name = "Antimatter Stake",
+ key = "antimatter",
+ unlocked = true,
+ applied_stakes = { "crystal" },
+ above_stake = "crystal",
+ atlas = "alt_mp_stakes",
+ pos = { x = 2, y = 1 },
+ sticker_pos = { x = 3, y = 1 },
+ modifiers = function()
+ G.GAME.modifiers.mp_enable_draining_jokers = true
+ end,
+ colour = HEX("4F6367"),
+ shiny = true,
+ })
+end
diff --git a/objects/stakes/atlas.lua b/objects/stakes/atlas.lua
new file mode 100644
index 00000000..068bc7af
--- /dev/null
+++ b/objects/stakes/atlas.lua
@@ -0,0 +1,13 @@
+SMODS.Atlas({
+ key = "sandbox_stakes",
+ path = "stakes-chips.png",
+ px = 29,
+ py = 29,
+})
+
+SMODS.Atlas({
+ key = "alt_mp_stakes",
+ path = "alt_mp_stakes.png",
+ px = 29,
+ py = 29,
+})
diff --git a/objects/stickers/1_persistent.lua b/objects/stickers/1_persistent.lua
new file mode 100644
index 00000000..8dacd6d9
--- /dev/null
+++ b/objects/stickers/1_persistent.lua
@@ -0,0 +1,155 @@
+SMODS.Sticker({
+ key = "sticker_persistent",
+ atlas = "alt_stickers",
+ pos = { x = 0, y = 0 },
+ badge_colour = HEX("5541CC"),
+ default_compat = false,
+ needs_enable_flag = true,
+ apply = function(self, card, val)
+ if card and card.edition and card.edition.type == "mp_phantom" then return end
+ old_val = card.ability.mp_sticker_persistent or false
+ card.ability.mp_sticker_persistent = val
+ if old_val ~= val then card:set_cost() end
+ end,
+ calculate = function(self, card, context)
+ if context.end_of_round and not context.repetition and not context.individual then -- should be main_eval i think but i'm copying from vremade so cry about it
+ card.ability.mp_extra_sell_price = (card.ability.mp_extra_sell_price or 0) + 3
+ card_eval_status_text(
+ card,
+ "extra",
+ nil,
+ nil,
+ nil,
+ { message = localize("k_cost_up"), colour = G.C.RED, delay = 0.45 }
+ )
+ card:set_cost()
+ end
+ end,
+})
+
+local is_eternal_ref = SMODS.is_eternal
+function SMODS.is_eternal(card, trigger)
+ local ret = is_eternal_ref(card, trigger)
+ if card.ability.mp_sticker_persistent and not (trigger and trigger.from_sell) then ret = true end
+ return ret
+end
+
+-- make sell button red
+local can_sell_card_ref = G.FUNCS.can_sell_card
+G.FUNCS.can_sell_card = function(e)
+ if e.config.ref_table.ability and e.config.ref_table.ability.mp_sticker_persistent then
+ if e.config.ref_table:can_sell_card() and e.config.ref_table.ability.mp_sell_price <= G.GAME.dollars then
+ e.config.colour = G.C.RED
+ e.config.button = "sell_card"
+ else
+ e.config.colour = G.C.UI.BACKGROUND_INACTIVE
+ e.config.button = nil
+ end
+ else
+ return can_sell_card_ref(e)
+ end
+end
+
+-- replicate
+-- :(
+-- i don't like hooking like this
+local sell_card_ref = Card.sell_card
+function Card:sell_card()
+ if not self.ability.mp_sticker_persistent then return sell_card_ref(self) end
+
+ G.CONTROLLER.locks.selling_card = true
+ stop_use()
+ local area = self.area
+ G.CONTROLLER:save_cardarea_focus(area == G.jokers and "jokers" or "consumeables")
+
+ if self.children.use_button then
+ self.children.use_button:remove()
+ self.children.use_button = nil
+ end
+ if self.children.sell_button then
+ self.children.sell_button:remove()
+ self.children.sell_button = nil
+ end
+
+ self:calculate_joker({ selling_self = true })
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.2,
+ func = function()
+ self:juice_up(0.3, 0.4)
+ return true
+ end,
+ }))
+ delay(0.2)
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ ease_dollars(-self.ability.mp_sell_price)
+ self:start_dissolve({ G.C.RED })
+ delay(0.3)
+
+ inc_career_stat("c_cards_sold", 1)
+ if self.ability.set == "Joker" then inc_career_stat("c_jokers_sold", 1) end
+ if self.ability.set == "Joker" and G.GAME.blind and G.GAME.blind.name == "Verdant Leaf" then
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ G.GAME.blind:disable()
+ return true
+ end,
+ }))
+ end
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blocking = false,
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ G.CONTROLLER.locks.selling_card = nil
+ G.CONTROLLER:recall_cardarea_focus(area == G.jokers and "jokers" or "consumeables")
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+end
+
+local set_cost_ref = Card.set_cost
+function Card:set_cost()
+ set_cost_ref(self)
+ if self.ability.mp_sticker_persistent then
+ self.ability.mp_sell_price = self.sell_cost + (self.ability.mp_extra_sell_price or 0)
+ self.sell_cost_label = self.facing == "back" and "?" or self.ability.mp_sell_price
+ end
+end
+
+local update_ref = Card.update
+function Card:update(dt)
+ update_ref(self, dt)
+ if self.ability.mp_sticker_persistent then
+ self.sell_cost_label = self.facing == "back" and "?" or self.ability.mp_sell_price
+ end
+end
+
+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 card and card.ability and card.ability.mp_sticker_persistent and not G.OVERLAY_MENU then -- check for card and for tag
+ generate_card_ui_ref({ key = "mp_internal_sell_value", set = "Other", vars = { card.sell_cost } }, ret) -- don't need to assign this to ret because lua
+ end
+ return ret
+end
diff --git a/objects/stickers/2_unreliable.lua b/objects/stickers/2_unreliable.lua
new file mode 100644
index 00000000..50d09774
--- /dev/null
+++ b/objects/stickers/2_unreliable.lua
@@ -0,0 +1,16 @@
+SMODS.Sticker({
+ key = "sticker_unreliable",
+ atlas = "alt_stickers",
+ pos = { x = 1, y = 0 },
+ badge_colour = HEX("7CA39A"),
+ default_compat = false,
+ needs_enable_flag = true,
+})
+
+local calculate_joker_ref = Card.calculate_joker
+function Card:calculate_joker(context)
+ if self.ability.mp_sticker_unreliable and G.GAME.current_round.hands_left == 0 then
+ if not self.edition or self.edition.type ~= "mp_phantom" then return end
+ end
+ return calculate_joker_ref(self, context)
+end
diff --git a/objects/stickers/3_draining.lua b/objects/stickers/3_draining.lua
new file mode 100644
index 00000000..3acc2130
--- /dev/null
+++ b/objects/stickers/3_draining.lua
@@ -0,0 +1,14 @@
+SMODS.Sticker({
+ key = "sticker_draining",
+ atlas = "alt_stickers",
+ pos = { x = 2, y = 0 },
+ badge_colour = HEX("A13333"),
+ default_compat = false,
+ needs_enable_flag = true,
+ calculate = function(self, card, context)
+ if card and card.edition and card.edition.type == "mp_phantom" then return end
+ if context.joker_main then return {
+ x_mult = 0.75,
+ } end
+ end,
+})
diff --git a/objects/stickers/_stickers.lua b/objects/stickers/_stickers.lua
new file mode 100644
index 00000000..ad72ab19
--- /dev/null
+++ b/objects/stickers/_stickers.lua
@@ -0,0 +1,828 @@
+SMODS.Atlas({
+ key = "alt_stickers",
+ path = "alt_stickers.png",
+ px = 71,
+ py = 95,
+})
+
+local set_ability_ref = Card.set_ability
+function Card:set_ability(center, initial, delay_sprites)
+ set_ability_ref(self, center, initial, delay_sprites)
+ for _, v in ipairs({ "persistent", "unreliable", "draining" }) do
+ if G.GAME.modifiers and G.GAME.modifiers["mp_enable_" .. v .. "_jokers"] then
+ SMODS.Stickers["mp_sticker_" .. v]:apply(self, center["mp_forced_" .. v])
+ end
+ end
+end
+
+-- big table for the alt stickers as they need specifications
+local sticker_tables = {
+ j_joker = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_greedy_joker = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_lusty_joker = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_wrathful_joker = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_gluttenous_joker = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_jolly = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_zany = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mad = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_crazy = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_droll = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_sly = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_wily = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_clever = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_devious = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_crafty = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_half = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_stencil = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_four_fingers = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mime = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_credit_card = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_ceremonial = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_banner = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mystic_summit = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_marble = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_loyalty_card = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_8_ball = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_misprint = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_dusk = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_raised_fist = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_chaos = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_fibonacci = {
+ persistent = true,
+ unreliable = true,
+ draining = false,
+ },
+ j_steel_joker = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_scary_face = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_abstract = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_delayed_grat = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_hack = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_pareidolia = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_gros_michel = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_even_steven = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_odd_todd = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_scholar = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_business = {
+ persistent = false,
+ unreliable = true,
+ draining = true,
+ },
+ j_supernova = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_ride_the_bus = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_space = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_egg = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_burglar = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_blackboard = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_runner = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_ice_cream = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_dna = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_splash = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_blue_joker = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_sixth_sense = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_constellation = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_hiker = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_faceless = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_green_joker = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_superposition = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_todo_list = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_cavendish = {
+ persistent = true,
+ unreliable = true,
+ draining = false,
+ },
+ j_card_sharp = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_red_card = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_madness = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_square = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_seance = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_riff_raff = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_vampire = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_shortcut = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_hologram = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_vagabond = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_baron = {
+ persistent = true,
+ unreliable = true,
+ draining = false,
+ },
+ j_cloud_9 = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_rocket = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_obelisk = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_midas_mask = {
+ persistent = true,
+ unreliable = true,
+ draining = true,
+ },
+ j_luchador = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_photograph = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_gift = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_turtle_bean = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_erosion = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_reserved_parking = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_mail = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_to_the_moon = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_hallucination = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_fortune_teller = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_juggler = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_drunkard = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_stone = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_golden = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_lucky_cat = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_baseball = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_bull = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_diet_cola = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_trading = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_flash = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_popcorn = {
+ persistent = false,
+ unreliable = true,
+ draining = true,
+ },
+ j_trousers = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_ancient = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_ramen = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_walkie_talkie = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_selzer = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_castle = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_smiley = {
+ persistent = true,
+ unreliable = true,
+ draining = false,
+ },
+ j_campfire = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_ticket = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_mr_bones = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_acrobat = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_sock_and_buskin = {
+ persistent = false,
+ unreliable = true,
+ draining = true,
+ },
+ j_swashbuckler = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_troubadour = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_certificate = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_smeared = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_throwback = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_hanging_chad = {
+ persistent = true,
+ unreliable = true,
+ draining = true,
+ },
+ j_rough_gem = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_bloodstone = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_arrowhead = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_onyx_agate = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_glass = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_ring_master = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_flower_pot = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_blueprint = {
+ persistent = true,
+ unreliable = true,
+ draining = false,
+ },
+ j_wee = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_merry_andy = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_oops = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_idol = {
+ persistent = false,
+ unreliable = true,
+ draining = true,
+ },
+ j_seeing_double = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_matador = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_hit_the_road = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_duo = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_trio = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_family = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_order = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_tribe = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_stuntman = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_invisible = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_brainstorm = {
+ persistent = true,
+ unreliable = false,
+ draining = true,
+ },
+ j_satellite = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_shoot_the_moon = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_drivers_license = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_cartomancer = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_astronomer = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_burnt = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_bootstraps = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_caino = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_triboulet = {
+ persistent = false,
+ unreliable = true,
+ draining = true,
+ },
+ j_yorick = {
+ persistent = true,
+ unreliable = false,
+ draining = false,
+ },
+ j_chicot = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_perkeo = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_mp_conjoined_joker = {
+ persistent = false,
+ unreliable = true,
+ draining = false,
+ },
+ j_mp_defensive_joker = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mp_lets_go_gambling = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_mp_pacifist = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mp_penny_pincher = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mp_pizza = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mp_skip_off = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+ j_mp_speedrun = {
+ persistent = false,
+ unreliable = false,
+ draining = true,
+ },
+ j_mp_taxes = {
+ persistent = false,
+ unreliable = false,
+ draining = false,
+ },
+}
+-- told you it was big
+
+G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ for center, table in pairs(sticker_tables) do
+ for k, v in pairs(table) do
+ G.P_CENTERS[center]["mp_forced_" .. k] = v
+ end
+ end
+ return true
+ end,
+}))
diff --git a/objects/stickers/balanced.lua b/objects/stickers/balanced.lua
new file mode 100644
index 00000000..d3a8a9e4
--- /dev/null
+++ b/objects/stickers/balanced.lua
@@ -0,0 +1,15 @@
+SMODS.Atlas({
+ key = "sticker_balanced",
+ path = "sticker_balanced.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Sticker({
+ key = "sticker_balanced",
+ atlas = "sticker_balanced",
+ badge_colour = G.C.MULTIPLAYER,
+ default_compat = false,
+ needs_enable_flag = true,
+ hide_badge = true,
+})
diff --git a/objects/stickers/extra_credit.lua b/objects/stickers/extra_credit.lua
new file mode 100644
index 00000000..71bb4f74
--- /dev/null
+++ b/objects/stickers/extra_credit.lua
@@ -0,0 +1,12 @@
+SMODS.Sticker({
+ key = "sticker_extra_credit",
+ atlas = "ec_other_sandbox",
+ pos = {
+ x = 1,
+ y = 1,
+ },
+ badge_colour = HEX("FBA105"),
+ default_compat = false,
+ needs_enable_flag = true,
+ hide_badge = false,
+})
diff --git a/objects/stickers/nemesis.lua b/objects/stickers/nemesis.lua
new file mode 100644
index 00000000..16d84865
--- /dev/null
+++ b/objects/stickers/nemesis.lua
@@ -0,0 +1,15 @@
+SMODS.Atlas({
+ key = "sticker_nemesis",
+ path = "sticker_nemesis.png",
+ px = 71,
+ py = 95,
+})
+
+SMODS.Sticker({
+ key = "sticker_nemesis",
+ atlas = "sticker_nemesis",
+ badge_colour = G.C.MULTIPLAYER,
+ default_compat = false,
+ needs_enable_flag = true,
+ hide_badge = true,
+})
diff --git a/objects/tags/gambling_sandbox.lua b/objects/tags/gambling_sandbox.lua
new file mode 100644
index 00000000..9772d4a2
--- /dev/null
+++ b/objects/tags/gambling_sandbox.lua
@@ -0,0 +1,67 @@
+SMODS.Atlas({
+ key = "gambling_sandbox",
+ path = "tag_gambling_sandbox.png",
+ px = 32,
+ py = 32,
+})
+
+-- Gambling Tag: 1 in 2 chance to generate a rare joker in shop
+SMODS.Tag({
+ key = "gambling_sandbox",
+ atlas = "gambling_sandbox",
+ object_type = "Tag",
+ dependencies = {
+ items = {},
+ },
+ in_pool = function(self)
+ return MP.is_ruleset_active("sandbox")
+ end,
+ name = "Gambling Tag",
+ discovered = true,
+ min_ante = 2, -- less degeneracy
+ no_collection = MP.sandbox_no_collection,
+ config = {
+ type = "store_joker_create",
+ odds = 2,
+ },
+ requires = "j_blueprint",
+ loc_vars = function(self)
+ return { vars = { G.GAME.probabilities.normal or 1, self.config.odds } }
+ end,
+ apply = function(self, tag, context)
+ if context.type == "store_joker_create" then
+ local card = nil
+ -- 1 in 2 chance to proc
+ if pseudorandom("tagroll") < G.GAME.probabilities.normal / tag.config.odds then
+ -- count owned rare jokers to prevent duplicates
+ local rares_owned = { 0 }
+ for k, v in ipairs(G.jokers.cards) do
+ if v.config.center.rarity == 3 and not rares_owned[v.config.center.key] then
+ rares_owned[1] = rares_owned[1] + 1
+ rares_owned[v.config.center.key] = true
+ end
+ end
+
+ -- only proc if unowned rares exist
+ -- funny edge case that i've never seen happen, but if localthunk saw it i will obey
+ if #G.P_JOKER_RARITY_POOLS[3] > rares_owned[1] then
+ card = create_card("Joker", context.area, nil, 1, nil, nil, nil, "rta")
+ create_shop_card_ui(card, "Joker", context.area)
+ card.states.visible = false
+ tag:yep("+", G.C.RED, function()
+ card:start_materialize()
+ card.ability.couponed = true -- free card
+ card:set_cost()
+ return true
+ end)
+ else
+ tag:nope() -- all rares owned
+ end
+ else
+ tag:nope() -- failed roll
+ end
+ tag.triggered = true
+ return card
+ end
+ end,
+})
diff --git a/objects/tags/investment_sandbox.lua b/objects/tags/investment_sandbox.lua
new file mode 100644
index 00000000..422f2cd6
--- /dev/null
+++ b/objects/tags/investment_sandbox.lua
@@ -0,0 +1,36 @@
+SMODS.Tag({
+ key = "investment_sandbox",
+ pos = { x = 2, y = 1 },
+ config = { base_dollars = 5, ante_dollars = 10 },
+ loc_vars = function(self, info_queue, tag)
+ local total_ante_dollars = (G.GAME.round_resets.ante or 0) * 10
+ local total_dollars = tag.config.base_dollars + total_ante_dollars
+ return { vars = { tag.config.base_dollars, tag.config.ante_dollars, total_dollars } }
+ end,
+ apply = function(self, tag, context)
+ if context.type == "eval" then
+ -- ante change is triggered before reward -> base on last ante
+ local last_ante = ((G.GAME.round_resets.ante or 0) - 1)
+ local total_ante_dollars = last_ante * tag.config.ante_dollars
+ local total_dollars = tag.config.base_dollars + total_ante_dollars
+ if G.GAME.last_blind and G.GAME.last_blind.boss then
+ tag:yep("+", G.C.GOLD, function()
+ return true
+ end)
+ tag.triggered = true
+ return {
+ dollars = total_dollars,
+ condition = localize("ph_defeat_the_boss"),
+ pos = tag.pos,
+ tag = tag,
+ }
+ end
+ end
+ end,
+ unlocked = true,
+ discovered = true,
+ no_collection = MP.sandbox_no_collection,
+ in_pool = function(self)
+ return MP.is_ruleset_active("sandbox")
+ end,
+})
diff --git a/objects/tags/juggle_sandbox.lua b/objects/tags/juggle_sandbox.lua
new file mode 100644
index 00000000..46824917
--- /dev/null
+++ b/objects/tags/juggle_sandbox.lua
@@ -0,0 +1,26 @@
+-- Juggle Tag
+SMODS.Tag({
+ key = "juggle_sandbox",
+ pos = { x = 5, y = 1 },
+ config = { h_size = 3 },
+ loc_vars = function(self, info_queue, tag)
+ return { vars = { tag.config.h_size } }
+ end,
+ apply = function(self, tag, context)
+ if context.type == "round_start_bonus" and MP.is_pvp_boss() then
+ tag:yep("+", G.C.BLUE, function()
+ return true
+ end)
+ G.hand:change_size(tag.config.h_size)
+ G.GAME.round_resets.temp_handsize = (G.GAME.round_resets.temp_handsize or 0) + tag.config.h_size
+ tag.triggered = true
+ return true
+ end
+ end,
+ unlocked = true,
+ discovered = true,
+ no_collection = MP.sandbox_no_collection,
+ in_pool = function(self)
+ return MP.is_ruleset_active("sandbox")
+ end,
+})
diff --git a/overrides/disable_restart.lua b/overrides/disable_restart.lua
new file mode 100644
index 00000000..a744b134
--- /dev/null
+++ b/overrides/disable_restart.lua
@@ -0,0 +1,4 @@
+local key_hold_update_ref = Controller.key_hold_update
+function Controller:key_hold_update(key, dt)
+ if not MP.LOBBY.code then key_hold_update_ref(self, key, dt) end
+end
diff --git a/overrides/game.lua b/overrides/game.lua
new file mode 100644
index 00000000..47fbe745
--- /dev/null
+++ b/overrides/game.lua
@@ -0,0 +1,73 @@
+local ease_dollars_ref = ease_dollars
+function ease_dollars(mod, instant)
+ sendTraceMessage(string.format("Client sent message: action:moneyMoved,amount:%s", tostring(mod)), "MULTIPLAYER")
+ return ease_dollars_ref(mod, instant)
+end
+
+-- Certain Steamodded builds still call save_run while saving is disabled
+-- In multiplayer runs this can crash when SMODS serializes transient hand data
+local save_run_ref = save_run
+function save_run(...)
+ if G and G.F_NO_SAVING then return end
+ return save_run_ref(...)
+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"
+ )
+ 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"
+ )
+
+ -- Update reroll stats if in a multiplayer game
+ if MP.LOBBY.code and MP.GAME.stats then
+ MP.GAME.stats.reroll_count = MP.GAME.stats.reroll_count + 1
+ MP.GAME.stats.reroll_cost_total = MP.GAME.stats.reroll_cost_total + G.GAME.current_round.reroll_cost
+ end
+
+ return reroll_shop_ref(e)
+end
+
+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"
+ )
+ 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"
+ )
+ end
+ return use_card_ref(e, mute, nosave)
+end
+
+-- Hook for end of pvp context (slightly scuffed)
+local evaluate_round_ref = G.FUNCS.evaluate_round
+G.FUNCS.evaluate_round = function()
+ if G.after_pvp then
+ G.after_pvp = nil
+ SMODS.calculate_context({ mp_end_of_pvp = true })
+ end
+ evaluate_round_ref()
+end
diff --git a/overrides/hide_content.lua b/overrides/hide_content.lua
new file mode 100644
index 00000000..e2b54754
--- /dev/null
+++ b/overrides/hide_content.lua
@@ -0,0 +1,80 @@
+-- small file because it feels wrong to add it somewhere else
+
+function MP.should_hide_mp_content()
+ if (not MP.LOBBY.code) or not MP.Rulesets[MP.LOBBY.config.ruleset].multiplayer_content then -- check for vanilla context
+ if SMODS.Mods["Multiplayer"].config.hide_mp_content then return true end
+ end
+ return false
+end
+
+local hidden_tbl = { "Stake", "Back" } -- Challenges are at bottom of file
+
+local inject_ref = SMODS.injectItems
+function SMODS.injectItems()
+ local ret = inject_ref()
+ for _, hidden in ipairs(hidden_tbl) do
+ G.P_CENTER_POOLS[hidden .. "_non_mp"] = {}
+ for i, v in ipairs(G.P_CENTER_POOLS[hidden]) do
+ if not v.mod or v.mod.id ~= "Multiplayer" then table.insert(G.P_CENTER_POOLS[hidden .. "_non_mp"], v) end
+ end
+ end
+ G.CHALLENGES_non_mp = {}
+ for i, v in ipairs(G.CHALLENGES) do
+ if not v.mod or v.mod.id ~= "Multiplayer" then table.insert(G.CHALLENGES_non_mp, v) end
+ end
+ return ret
+end
+
+local function hook(orig, type)
+ return function(...)
+ local temp = G.P_CENTER_POOLS[type]
+ if MP.should_hide_mp_content() then G.P_CENTER_POOLS[type] = G.P_CENTER_POOLS[type .. "_non_mp"] end
+ local results = orig(...)
+ G.P_CENTER_POOLS[type] = temp
+ return results
+ end
+end
+
+local hooks = {
+ Stake = {
+ { tbl = G.UIDEF, str = "deck_stake_column" },
+ { tbl = G.UIDEF, str = "current_stake" },
+ { tbl = G.UIDEF, str = "stake_option" },
+ { tbl = G.UIDEF, str = "run_setup_option" },
+ },
+ Back = {
+ { tbl = G.UIDEF, str = "run_setup_option" },
+ { tbl = G.FUNCS, str = "change_viewed_back" },
+ { tbl = G.FUNCS, str = "change_selected_back" },
+ },
+}
+
+for k, v in pairs(hooks) do
+ for i, vv in ipairs(v) do
+ local orig = vv.tbl[vv.str]
+ vv.tbl[vv.str] = hook(orig, k)
+ end
+end
+
+-- slightly modified exception code for challenges
+
+local ch_hooks = {
+ { tbl = G.UIDEF, str = "challenges" },
+ { tbl = G.UIDEF, str = "challenge_list" },
+ { tbl = G.UIDEF, str = "challenge_list_page" },
+}
+
+local function ch_hook(orig)
+ return function(...)
+ local temp = G.CHALLENGES
+ if MP.should_hide_mp_content() then G.CHALLENGES = G.CHALLENGES_non_mp end
+ local results = orig(...)
+ G.CHALLENGES = temp
+ return results
+ end
+end
+
+for i, v in pairs(ch_hooks) do
+ local orig = v.tbl[v.str]
+ v.tbl[v.str] = ch_hook(orig)
+end
diff --git a/overrides/mod_badges.lua b/overrides/mod_badges.lua
new file mode 100644
index 00000000..94cf2250
--- /dev/null
+++ b/overrides/mod_badges.lua
@@ -0,0 +1,98 @@
+-- Credit to Cryptid devs for this function
+local create_mod_badges_ref = SMODS.create_mod_badges
+function SMODS.create_mod_badges(obj, badges)
+ create_mod_badges_ref(obj, badges)
+ if not SMODS.config.no_mod_badges and obj and obj.mp_credits then
+ obj.mp_credits.art = obj.mp_credits.art or {}
+ obj.mp_credits.idea = obj.mp_credits.idea or {}
+ obj.mp_credits.code = obj.mp_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
+ -- Math reproduced from DynaText:update_text
+ 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
+ local scale_fac = calced_text_width > max_text_width and max_text_width / calced_text_width or 1
+ return scale_fac
+ end
+ -- doesn't work for decks since they lack tooltips but ONE DAY MAYBE???
+ if obj.mp_credits.art or obj.mp_credits.code or obj.mp_credits.idea then
+ local scale_fac = {}
+ local min_scale_fac = 1
+ local strings = { "MULTIPLAYER" }
+ for _, v in ipairs({ "art", "idea", "code" }) do
+ if obj.mp_credits[v] then
+ for i = 1, #obj.mp_credits[v] do
+ strings[#strings + 1] =
+ localize({ type = "variable", key = "a_mp_" .. v, vars = { obj.mp_credits[v][i] } })[1]
+ end
+ end
+ end
+ for i = 1, #strings do
+ scale_fac[i] = calc_scale_fac(strings[i])
+ min_scale_fac = math.min(min_scale_fac, scale_fac[i])
+ end
+ local ct = {}
+ for i = 1, #strings do
+ ct[i] = {
+ string = strings[i],
+ }
+ end
+ local mp_badge = {
+ 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 or "ERROR",
+ colours = { obj.mp_credits and obj.mp_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 } },
+ },
+ },
+ },
+ }
+ local function eq_col(x, y)
+ for i = 1, 4 do
+ if x[1] ~= y[1] then return false end
+ end
+ return true
+ end
+ for i = 1, #badges do
+ if eq_col(badges[i].nodes[1].config.colour, G.C.MULTIPLAYER) then
+ badges[i].nodes[1].nodes[2].config.object:remove()
+ badges[i] = mp_badge
+ break
+ end
+ end
+ end
+ end
+end
diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua
new file mode 100644
index 00000000..4763a9c3
--- /dev/null
+++ b/rulesets/_rulesets.lua
@@ -0,0 +1,198 @@
+G.P_CENTER_POOLS.Ruleset = {}
+MP.Rulesets = {}
+MP.Ruleset = SMODS.GameObject:extend({
+ obj_table = {},
+ obj_buffer = {},
+ required_params = {
+ "key",
+ "multiplayer_content",
+ "banned_jokers",
+ "banned_consumables",
+ "banned_vouchers",
+ "banned_enhancements",
+ "banned_tags",
+ "banned_blinds",
+ "reworked_jokers",
+ "reworked_consumables",
+ "reworked_vouchers",
+ "reworked_enhancements",
+ "reworked_tags",
+ "reworked_blinds",
+ "create_info_menu",
+ },
+ class_prefix = "ruleset",
+ inject = function(self)
+ MP.Rulesets[self.key] = self
+ if not G.P_CENTER_POOLS.Ruleset then G.P_CENTER_POOLS.Ruleset = {} end
+ table.insert(G.P_CENTER_POOLS.Ruleset, self)
+ end,
+ process_loc_text = function(self)
+ SMODS.process_loc_text(G.localization.descriptions["Ruleset"], self.key, self.loc_txt)
+ end,
+ is_disabled = function(self)
+ return false
+ end,
+ force_lobby_options = function(self)
+ return false
+ end,
+})
+
+function MP.is_ruleset_active(ruleset_name)
+ local key = "ruleset_mp_" .. ruleset_name
+ if MP.LOBBY.code then
+ return MP.LOBBY.config.ruleset == key
+ elseif MP.SP and MP.SP.ruleset then
+ return MP.SP.ruleset == key
+ end
+ return false
+end
+
+function MP.get_active_ruleset()
+ if MP.LOBBY.code then
+ return MP.LOBBY.config.ruleset
+ elseif MP.SP and MP.SP.ruleset then
+ return MP.SP.ruleset
+ end
+ return nil
+end
+
+function MP.ApplyBans()
+ local ruleset_key = nil
+ local gamemode = nil
+
+ if MP.LOBBY.code and MP.LOBBY.config.ruleset then
+ ruleset_key = MP.LOBBY.config.ruleset
+ gamemode = MP.Gamemodes["gamemode_mp_" .. MP.LOBBY.type]
+ elseif MP.SP and MP.SP.ruleset then
+ ruleset_key = MP.SP.ruleset
+ end
+
+ if ruleset_key then
+ local ruleset = MP.Rulesets[ruleset_key]
+ local banned_tables = {
+ "jokers",
+ "consumables",
+ "vouchers",
+ "enhancements",
+ "tags",
+ "blinds",
+ }
+ for _, table in ipairs(banned_tables) do
+ for _, v in ipairs(ruleset["banned_" .. table]) do
+ G.GAME.banned_keys[v] = true
+ end
+ if gamemode then
+ for _, v in ipairs(gamemode["banned_" .. table]) do
+ G.GAME.banned_keys[v] = true
+ end
+ end
+ for _, v in pairs(MP.DECK["BANNED_" .. string.upper(table)]) do
+ G.GAME.banned_keys[v] = true
+ end
+ end
+ for _, v in ipairs(ruleset["banned_silent"] or {}) do
+ G.GAME.banned_keys[v] = true
+ end
+ end
+end
+
+-- Rework a center for specific ruleset(s). Use MP.LoadReworks() to swap in the active ruleset.
+---@param key string e.g. "j_hanging_chad"
+---@param opts table { rulesets, loc_key?, silent?, ...center properties }
+function MP.ReworkCenter(key, opts)
+ local center = G.P_CENTERS[key]
+ opts = opts or {}
+
+ -- Meta keys (not center properties)
+ local reserved = { rulesets = true, loc_key = true, silent = true }
+ local rulesets = opts.rulesets
+ local loc_key = opts.loc_key
+ local silent = opts.silent
+
+ -- Convert single ruleset to list
+ if type(rulesets) == "string" then rulesets = { rulesets } end
+
+ -- Wrap loc_vars to inject loc_key if provided
+ if loc_key then
+ local user_loc_vars = opts.loc_vars or function()
+ return {}
+ end
+ opts.loc_vars = function(self, info_queue, card)
+ local result = user_loc_vars(self, info_queue, card)
+ result.key = loc_key
+ return result
+ end
+ end
+
+ -- do we need to inject generate_ui for loc_vars to work?
+ local needs_generate_ui = opts.loc_vars
+ and not opts.generate_ui
+ and not (center.generate_ui and type(center.generate_ui) == "function")
+
+ -- Apply changes to all specified rulesets
+ for _, rs in ipairs(rulesets) do
+ local prefix = "mp_" .. rs .. "_"
+
+ -- Store all reworked properties
+ for k, v in pairs(opts) do
+ if not reserved[k] then
+ center[prefix .. k] = v
+ if not center["mp_vanilla_" .. k] then center["mp_vanilla_" .. k] = center[k] or "NULL" end
+ end
+ end
+
+ -- Auto-inject generate_ui when adding loc_vars to vanilla centers
+ if needs_generate_ui then
+ center[prefix .. "generate_ui"] = SMODS.Center.generate_ui
+ if not center.mp_vanilla_generate_ui then center.mp_vanilla_generate_ui = center.generate_ui or "NULL" end
+ end
+
+ -- Mark this center as having reworks
+ center.mp_reworks = center.mp_reworks or {}
+ center.mp_reworks[rs] = true
+ center.mp_reworks["vanilla"] = true
+
+ center.mp_silent = center.mp_silent or {}
+ center.mp_silent[rs] = silent
+ end
+end
+
+-- You can call this function without a ruleset to set it to vanilla
+-- You can also call this function with a key to only affect that specific joker (might be useful)
+function MP.LoadReworks(ruleset, key)
+ ruleset = ruleset or "vanilla"
+ if string.sub(ruleset, 1, 11) == "ruleset_mp_" then ruleset = string.sub(ruleset, 12, #ruleset) end
+ local function process(key_, ruleset_)
+ local center = G.P_CENTERS[key_]
+ for k, v in pairs(center) do
+ if string.sub(k, 1, #ruleset_) == ruleset_ then
+ local orig = string.sub(k, #ruleset_ + 1)
+ if orig == "rarity" then
+ SMODS.remove_pool(G.P_JOKER_RARITY_POOLS[center[orig]], center.key)
+ table.insert(G.P_JOKER_RARITY_POOLS[center[k]], center)
+ table.sort(G.P_JOKER_RARITY_POOLS[center[k]], function(a, b)
+ return a.order < b.order
+ end)
+ end
+ if center[k] == "NULL" then
+ center[orig] = nil
+ else
+ center[orig] = center[k]
+ end
+ end
+ end
+ end
+ if key then
+ process(key, "mp_" .. ruleset .. "_")
+ else
+ for k, v in pairs(G.P_CENTERS) do
+ if v.mp_reworks then
+ if v.mp_reworks[ruleset] then
+ process(k, "mp_" .. ruleset .. "_")
+ elseif v.mp_reworks["vanilla"] then -- Check vanilla separately to reset reworked jokers
+ process(k, "mp_vanilla_")
+ end
+ end
+ end
+ end
+end
diff --git a/rulesets/badlatro.lua b/rulesets/badlatro.lua
new file mode 100644
index 00000000..1073a362
--- /dev/null
+++ b/rulesets/badlatro.lua
@@ -0,0 +1,76 @@
+MP.Ruleset({
+ key = "badlatro",
+ multiplayer_content = true,
+ banned_jokers = {
+ "j_caino",
+ "j_perkeo",
+ "j_triboulet",
+ "j_yorick",
+ "j_blueprint",
+ "j_ancient",
+ "j_baron",
+ "j_baseball",
+ "j_dna",
+ "j_family",
+ "j_trio",
+ "j_vagabond",
+ "j_acrobat",
+ "j_card_sharp",
+ "j_cartomancer",
+ "j_certificate",
+ "j_dusk",
+ "j_fibonacci",
+ "j_hologram",
+ "j_loyalty_card",
+ "j_lucky_cat",
+ "j_midas_mask",
+ "j_bloodstone",
+ "j_arrowhead",
+ "j_onyx_agate",
+ "j_selzer",
+ "j_trading",
+ "j_abstract",
+ "j_blue_joker",
+ "j_cavendish",
+ "j_photograph",
+ "j_hanging_chad",
+ "j_mail",
+ "j_brainstorm",
+ "j_mime",
+ "j_steel_joker",
+ "j_reserved_parking",
+ "j_mp_defensive_joker",
+ },
+ banned_consumables = {
+ "c_justice",
+ "c_deja_vu",
+ "c_trance",
+ },
+ banned_vouchers = {
+ "v_magic_trick",
+ },
+ banned_enhancements = {
+ "m_glass",
+ },
+ banned_tags = {
+ "tag_uncommon",
+ "tag_meteor",
+ "tag_garbage",
+ "tag_top_up",
+ "tag_handy",
+ },
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = false,
+ description_key = "k_badlatro_description",
+ })
+ end,
+}):inject()
diff --git a/rulesets/blitz.lua b/rulesets/blitz.lua
new file mode 100644
index 00000000..2002342d
--- /dev/null
+++ b/rulesets/blitz.lua
@@ -0,0 +1,43 @@
+MP.Ruleset({
+ key = "blitz",
+ multiplayer_content = true,
+ standard = true,
+ banned_silent = {
+ "j_hanging_chad",
+ "j_ticket",
+ "j_selzer",
+ "j_turtle_bean",
+ "j_bloodstone",
+ "c_ouija",
+ },
+ banned_jokers = {},
+ banned_consumables = {
+ "c_justice",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {
+ "j_mp_hanging_chad",
+ "j_mp_ticket",
+ "j_mp_seltzer",
+ "j_mp_turtle_bean",
+ },
+ reworked_consumables = {
+ "c_mp_ouija_standard",
+ },
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = false,
+ description_key = "k_blitz_description",
+ })
+ end,
+}):inject()
diff --git a/rulesets/legacy_ranked.lua b/rulesets/legacy_ranked.lua
new file mode 100644
index 00000000..b0a0ac71
--- /dev/null
+++ b/rulesets/legacy_ranked.lua
@@ -0,0 +1,36 @@
+MP.Ruleset({
+ key = "legacy_ranked",
+ multiplayer_content = false,
+ banned_silent = {},
+ banned_jokers = {},
+ banned_consumables = {},
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = false,
+ forced_lobby_options = true,
+ forced_gamemode_text = "k_attrition",
+ description_key = "k_legacy_ranked_description",
+ })
+ end,
+ forced_gamemode = "gamemode_mp_attrition",
+ forced_lobby_options = true,
+ is_disabled = function(self)
+ return MP.UTILS.check_smods_version() or MP.UTILS.check_lovely_version()
+ end,
+ force_lobby_options = function(self)
+ MP.LOBBY.config.the_order = true
+ return true
+ end,
+}):inject()
diff --git a/rulesets/majorleague.lua b/rulesets/majorleague.lua
new file mode 100644
index 00000000..2809f841
--- /dev/null
+++ b/rulesets/majorleague.lua
@@ -0,0 +1,36 @@
+MP.Ruleset({
+ key = "majorleague",
+ multiplayer_content = false,
+ banned_jokers = {},
+ banned_consumables = {},
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = false,
+ forced_lobby_options = true,
+ forced_gamemode_text = "k_attrition",
+ description_key = "k_majorleague_description",
+ })
+ end,
+ forced_gamemode = "gamemode_mp_attrition",
+ forced_lobby_options = true,
+ is_disabled = function(self)
+ return false
+ end,
+ force_lobby_options = function(self)
+ MP.LOBBY.config.timer_base_seconds = 180
+ MP.LOBBY.config.timer_forgiveness = 1
+ MP.LOBBY.config.the_order = false
+ MP.LOBBY.config.preview_disabled = true
+ return true
+ end,
+}):inject()
diff --git a/rulesets/minorleague.lua b/rulesets/minorleague.lua
new file mode 100644
index 00000000..9fb3a2a4
--- /dev/null
+++ b/rulesets/minorleague.lua
@@ -0,0 +1,32 @@
+MP.Ruleset({
+ key = "minorleague",
+ multiplayer_content = false,
+ banned_jokers = {},
+ banned_consumables = {},
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = false,
+ forced_lobby_options = true,
+ forced_gamemode_text = "k_attrition",
+ description_key = "k_minorleague_description",
+ })
+ end,
+ forced_gamemode = "gamemode_mp_attrition",
+ forced_lobby_options = true,
+ force_lobby_options = function(self)
+ MP.LOBBY.config.timer_base_seconds = 210
+ MP.LOBBY.config.timer_forgiveness = 1
+ MP.LOBBY.config.the_order = true
+ return true
+ end,
+}):inject()
diff --git a/rulesets/ranked.lua b/rulesets/ranked.lua
new file mode 100644
index 00000000..108b7dae
--- /dev/null
+++ b/rulesets/ranked.lua
@@ -0,0 +1,53 @@
+MP.Ruleset({
+ key = "standard_ranked",
+ multiplayer_content = true,
+ standard = true,
+ banned_silent = {
+ "j_hanging_chad",
+ "j_ticket",
+ "j_selzer",
+ "j_turtle_bean",
+ "j_bloodstone",
+ "c_ouija",
+ },
+ banned_jokers = {},
+ banned_consumables = {
+ "c_justice",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {
+ "j_mp_hanging_chad",
+ "j_mp_ticket",
+ "j_mp_seltzer",
+ "j_mp_turtle_bean",
+ },
+ reworked_consumables = {
+ "c_mp_ouija_standard",
+ },
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = true,
+ forced_gamemode_text = "k_attrition",
+ description_key = "k_standard_ranked_description",
+ })
+ end,
+ forced_gamemode = "gamemode_mp_attrition",
+ forced_lobby_options = true,
+ is_disabled = function(self)
+ return MP.UTILS.check_smods_version() or MP.UTILS.check_lovely_version()
+ end,
+ force_lobby_options = function(self)
+ MP.LOBBY.config.the_order = true
+ return true
+ end,
+}):inject()
diff --git a/rulesets/sandbox.lua b/rulesets/sandbox.lua
new file mode 100644
index 00000000..e51d780d
--- /dev/null
+++ b/rulesets/sandbox.lua
@@ -0,0 +1,215 @@
+MP.SANDBOX = {}
+
+-- Centralized joker mappings: defines sandbox variants, their vanilla counterparts, and rotation status
+MP.SANDBOX.joker_mappings = {
+ -- Active jokers in rotation
+ { sandbox = "j_mp_misprint_sandbox", vanilla = "j_misprint", active = true },
+ { sandbox = "j_mp_castle_sandbox", vanilla = "j_castle", active = true },
+ { sandbox = "j_mp_mail_sandbox", vanilla = "j_mail", active = true },
+ { sandbox = "j_mp_square_sandbox", vanilla = "j_square", active = true },
+ { sandbox = "j_mp_throwback_sandbox", vanilla = "j_throwback", active = true },
+ { sandbox = "j_mp_vampire_sandbox", vanilla = "j_vampire", active = true },
+ { sandbox = "j_mp_steel_joker_sandbox", vanilla = "j_steel_joker", active = true },
+ { sandbox = "j_mp_baseball_sandbox", vanilla = "j_baseball", active = true },
+ { sandbox = "j_mp_hit_the_road_sandbox", vanilla = "j_hit_the_road", active = true },
+ { sandbox = "j_mp_golden_ticket_sandbox", vanilla = "j_ticket", active = true },
+ -- Idol variants (all map to same vanilla joker)
+ { sandbox = "j_mp_idol_sandbox_zealot", vanilla = "j_idol", active = true },
+ { sandbox = "j_mp_idol_sandbox_collector", vanilla = "j_idol", active = true },
+
+ -- Out of rotation
+ { sandbox = "j_mp_bloodstone_sandbox", vanilla = "j_bloodstone", active = false },
+ { sandbox = "j_mp_cloud_9_sandbox", vanilla = "j_cloud_9", active = false },
+ { sandbox = "j_mp_constellation_sandbox", vanilla = "j_constellation", active = false },
+ { sandbox = "j_mp_faceless_sandbox", vanilla = "j_faceless", active = false },
+ { sandbox = "j_mp_juggler_sandbox", vanilla = "j_juggler", active = false },
+ { sandbox = "j_mp_loyalty_card_sandbox", vanilla = "j_loyalty_card", active = false },
+ { sandbox = "j_mp_lucky_cat_sandbox", vanilla = "j_lucky_cat", active = false },
+ { sandbox = "j_mp_magnet_sandbox", vanilla = nil, active = false },
+ { sandbox = "j_mp_order_sandbox", vanilla = "j_order", active = false },
+ { sandbox = "j_mp_photograph_sandbox", vanilla = "j_photograph", active = false },
+ { sandbox = "j_mp_ride_the_bus_sandbox", vanilla = "j_ride_the_bus", active = false },
+ { sandbox = "j_mp_runner_sandbox", vanilla = "j_runner", active = false },
+ { sandbox = "j_mp_satellite_sandbox", vanilla = "j_satellite", active = false },
+
+ -- Extra Credit jokers
+ { sandbox = "j_mp_alloy_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_ambrosia_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_bobby_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_candynecklace_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_chainlightning_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_clowncar_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_clowncollege_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_couponsheet_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_doublerainbow_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_espresso_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_farmer_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_forklift_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_gofish_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_hoarder_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_jokalisa_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_jokeroftheyear_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_lucky7_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_montehaul_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_pocketaces_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_pyromancer_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_shipoftheseus_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_starfruit_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_trafficlight_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_tuxedo_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_warlock_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+ { sandbox = "j_mp_werewolf_sandbox", vanilla = nil, active = true, group = "extra_credit" },
+}
+
+--- Returns list of active sandbox joker keys
+--- @return table List of sandbox joker keys that are active
+function MP.SANDBOX.get_active_sandbox_jokers()
+ local active = {}
+ for _, mapping in ipairs(MP.SANDBOX.joker_mappings) do
+ if mapping.active then table.insert(active, mapping.sandbox) end
+ end
+ return active
+end
+
+--- Returns list of unique vanilla joker keys to ban
+--- @return table List of vanilla joker keys to silently ban
+function MP.SANDBOX.get_vanilla_bans()
+ local bans = {}
+ local seen = {}
+ for _, mapping in ipairs(MP.SANDBOX.joker_mappings) do
+ if mapping.active and mapping.vanilla and not seen[mapping.vanilla] then
+ table.insert(bans, mapping.vanilla)
+ seen[mapping.vanilla] = true
+ end
+ end
+ return bans
+end
+
+--- Centralized allowlist check for sandbox jokers
+--- @param joker_key string The key of the joker to check (e.g., "j_mp_mail_sandbox")
+--- @return boolean true if the joker is allowed in the sandbox ruleset and in a multiplayer lobby
+function MP.SANDBOX.is_joker_allowed(joker_key)
+ if not MP.is_ruleset_active("sandbox") then return false end
+
+ for _, mapping in ipairs(MP.SANDBOX.joker_mappings) do
+ if mapping.active and mapping.sandbox == joker_key then return true end
+ end
+
+ return false
+end
+
+MP.Ruleset({
+ key = "sandbox",
+ multiplayer_content = true,
+ banned_jokers = { "j_hanging_chad" },
+ banned_silent = MP.SANDBOX.get_vanilla_bans(),
+ banned_consumables = { "c_ouija", "c_ectoplasm" },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = { "tag_rare", "tag_juggle", "tag_investment" },
+ banned_blinds = {},
+
+ -- Shuffle reworked jokers to randomize the overview panel order
+ -- Only show extra_credit jokers + idol jokers + error jokers in overview (hide other sandbox jokers)
+ reworked_jokers = (function()
+ local jokers = {}
+ local idol_jokers = {}
+
+ -- Collect extra_credit and idol jokers separately
+ for _, mapping in ipairs(MP.SANDBOX.joker_mappings) do
+ if mapping.active then
+ if mapping.group == "extra_credit" then
+ table.insert(jokers, mapping.sandbox)
+ elseif mapping.sandbox:find("idol") then
+ table.insert(idol_jokers, mapping.sandbox)
+ end
+ end
+ end
+
+ -- Add error jokers (for overview only, not in actual pool)
+ for i = 1, 14 do
+ table.insert(jokers, "j_mp_error_sandbox_" .. i)
+ end
+
+ -- final vanilla stuff
+ table.insert(jokers, "j_mp_hanging_chad")
+
+ -- Fisher-Yates shuffle
+ for i = #jokers, 2, -1 do
+ local j = math.random(1, i)
+ jokers[i], jokers[j] = jokers[j], jokers[i]
+ end
+
+ -- Insert idol jokers in the middle
+ local middle = math.floor(#jokers / 2) + 1
+ for i, idol in ipairs(idol_jokers) do
+ table.insert(jokers, middle + i - 1, idol)
+ end
+
+ return jokers
+ end)(),
+ reworked_consumables = { "c_mp_ouija_standard", "c_mp_ectoplasm_sandbox" },
+ reworked_vouchers = {},
+ reworked_enhancements = { "m_mp_sandbox_display_glass" },
+ reworked_blinds = {},
+ reworked_tags = { "tag_mp_gambling_sandbox", "tag_mp_juggle_sandbox", "tag_mp_investment_sandbox" },
+
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = true,
+ description_key = "k_sandbox_description",
+ })
+ end,
+
+ forced_lobby_options = true,
+
+ force_lobby_options = function(self)
+ MP.LOBBY.config.preview_disabled = true
+ MP.LOBBY.config.the_order = true
+ MP.LOBBY.config.starting_lives = 4
+ return true
+ end,
+}):inject()
+
+--- Randomly selects one idol variant to be available in the sandbox ruleset
+--- Bans the other two idol variants to ensure only one is available per game
+--- Uses pseudorandom selection based on the lobby seed for consistency across players
+--- @return nil
+local function select_random_idol()
+ local idol_keys = {
+ "j_mp_idol_sandbox_zealot",
+ "j_mp_idol_sandbox_collector",
+ }
+ table.sort(idol_keys)
+
+ -- Pseudorandom shuffle using the lobby seed so all players get the same idol
+ pseudoshuffle(idol_keys, pseudoseed("idol_selection_mp_sandbox"))
+
+ -- Ban all idols except the first one (which is now randomly selected)
+ for i = 2, #idol_keys do
+ G.GAME.banned_keys[idol_keys[i]] = true
+ end
+end
+
+local apply_bans_ref = MP.ApplyBans
+function MP.ApplyBans()
+ local ret = apply_bans_ref()
+
+ -- Apply sandbox-specific idol selection when in sandbox ruleset
+ if MP.is_ruleset_active("sandbox") then
+ select_random_idol()
+
+ if SMODS.Mods["extracredit"] and SMODS.Mods["extracredit"].can_load then
+ print("Banning sandbox jokers")
+ for _, mapping in ipairs(MP.SANDBOX.joker_mappings) do
+ if mapping.group == "extra_credit" then G.GAME.banned_keys[mapping.sandbox] = true end
+ end
+ end
+ end
+
+ return ret
+end
+
+-- debugging hotswitch
+MP.sandbox_no_collection = not MP.EXPERIMENTAL.show_sandbox_collection
diff --git a/rulesets/smallworld.lua b/rulesets/smallworld.lua
new file mode 100644
index 00000000..b2a26722
--- /dev/null
+++ b/rulesets/smallworld.lua
@@ -0,0 +1,202 @@
+MP.Ruleset({
+ key = "smallworld",
+ multiplayer_content = true,
+ standard = true,
+ banned_silent = {
+ "j_hanging_chad",
+ "j_ticket",
+ "j_selzer",
+ "j_turtle_bean",
+ "j_bloodstone",
+ "c_ouija",
+ },
+ banned_jokers = {},
+ banned_consumables = {
+ "c_justice",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {
+ "j_mp_hanging_chad",
+ "j_mp_ticket",
+ "j_mp_seltzer",
+ "j_mp_turtle_bean",
+ },
+ reworked_consumables = {
+ "c_mp_ouija_standard",
+ },
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = false,
+ description_key = "k_smallworld_description",
+ })
+ end,
+}):inject()
+
+local apply_bans_ref = MP.ApplyBans
+function MP.ApplyBans()
+ local ret = apply_bans_ref()
+ if MP.is_ruleset_active("smallworld") then
+ local tables = {}
+ local requires = {}
+ for k, v in pairs(G.P_CENTERS) do
+ if
+ v.set
+ and not G.GAME.banned_keys[k]
+ and not (v.requires or v.hidden)
+ and k ~= "j_cavendish"
+ and (not v.mp_include or v:mp_include())
+ then
+ local index = v.set .. (v.rarity or "")
+ tables[index] = tables[index] or {}
+ local t = tables[index]
+ t[#t + 1] = k
+ end
+ if v.set == "Voucher" and v.requires then requires[#requires + 1] = k end
+ end
+ for k, v in pairs(G.P_TAGS) do -- tag exemption
+ if not G.GAME.banned_keys[k] and (not v.mp_include or v:mp_include()) then
+ tables["Tag"] = tables["Tag"] or {}
+ local t = tables["Tag"]
+ t[#t + 1] = k
+ end
+ end
+ for k, v in pairs(tables) do
+ if k ~= "Back" and k ~= "Edition" and k ~= "Enhanced" and k ~= "Default" then
+ table.sort(v)
+ pseudoshuffle(v, pseudoseed(k .. "_mp_smallworld"))
+ local threshold = math.floor(0.5 + (#v * 0.75))
+ local ii = 1
+ if k == "Voucher" and not MP.legacy_smallworld() then ii = ii + 1 end
+ for i, vv in ipairs(v) do
+ if ii <= threshold then
+ G.GAME.banned_keys[vv] = true
+ ii = ii + 1
+ else
+ break
+ end
+ end
+ end
+ end
+ -- below bans shouldn't matter (except for blank placeholder) but whatever
+ for i, v in ipairs(requires) do
+ if G.GAME.banned_keys[G.P_CENTERS[v].requires[1]] then G.GAME.banned_keys[v] = true end
+ end
+ if G.GAME.banned_keys["j_gros_michel"] then G.GAME.banned_keys["j_cavendish"] = true end
+ end
+ return ret
+end
+
+local showman_ref = SMODS.showman
+function SMODS.showman(card_key)
+ if MP.is_ruleset_active("smallworld") then return true end
+ return showman_ref(card_key)
+end
+
+-- replace banned tags
+local tag_init_ref = Tag.init
+function Tag:init(_tag, for_collection, _blind_type)
+ local orbital = false
+ local old = G.orbital_hand -- i think this is always nil here but just to be safe
+ if MP.is_ruleset_active("smallworld") and not MP.legacy_smallworld() then
+ if G.GAME.banned_keys[_tag] and not G.OVERLAY_MENU then
+ local a = G.GAME.round_resets.ante
+
+ if MP.should_use_the_order() then G.GAME.round_resets.ante = 10 end
+
+ _tag = get_next_tag_key("replace")
+ if _tag == "tag_orbital" then orbital = true end
+
+ G.GAME.round_resets.ante = a
+ end
+ end
+ if orbital then G.orbital_hand = pseudorandom_element(MP.sorted_hand_list(), pseudoseed("orbital_replace")) end
+ tag_init_ref(self, _tag, for_collection, _blind_type)
+ G.orbital_hand = old
+end
+
+local apply_to_run_ref = Back.apply_to_run
+function Back:apply_to_run()
+ if MP.is_ruleset_active("smallworld") and not MP.legacy_smallworld() then MP.apply_fake_back_vouchers(self) end
+ return apply_to_run_ref(self)
+end
+
+function MP.apply_fake_back_vouchers(back)
+ local vouchers = {}
+ if back.effect.config.voucher then vouchers = { back.effect.config.voucher } end
+ if back.effect.config.vouchers or #vouchers > 0 then
+ vouchers = back.effect.config.vouchers or vouchers
+ local fake_back = { effect = { config = { vouchers = copy_table(vouchers) } } }
+ fake_back.effect.center = G.P_CENTERS["b_red"]
+ fake_back.name = "FAKE"
+ back.effect.config.vouchers = nil
+ back.effect.config.voucher = nil
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ for i, v in ipairs(fake_back.effect.config.vouchers) do
+ local voucher = v
+ if G.GAME.banned_keys[v] or G.GAME.used_vouchers[v] then voucher = get_next_voucher_key() end
+ G.GAME.used_vouchers[voucher] = true
+ fake_back.effect.config.vouchers[i] = voucher
+ end
+ G.GAME.current_round.voucher = SMODS.get_next_vouchers() -- the extreme jank doesn't matter as long as it's synced ig
+ apply_to_run_ref(fake_back)
+ return true
+ end,
+ }))
+ end
+end
+
+local add_joker_ref = add_joker
+function add_joker(joker, edition, silent, eternal)
+ if MP.is_ruleset_active("smallworld") and G.GAME.banned_keys[joker] then
+ local _pool = nil
+ local _pool_key = nil
+ local rarities = { [1] = 0, [2] = 0.9, [3] = 1, [4] = 1 }
+ if G.P_CENTERS[joker].set == "Joker" then
+ _pool, _pool_key = get_current_pool(
+ "Joker",
+ rarities[G.P_CENTERS[joker].rarity] or G.P_CENTERS[joker].rarity,
+ G.P_CENTERS[joker].rarity == 4 and true or false
+ )
+ else
+ _pool, _pool_key = get_current_pool(G.P_CENTERS[joker].set, nil)
+ end
+ local it = 1
+ local center = "UNAVAILABLE"
+ while center == "UNAVAILABLE" do
+ it = it + 1
+ center = pseudorandom_element(
+ _pool,
+ pseudoseed(_pool_key .. (MP.should_use_the_order() and "" or ("_resample" .. it)))
+ )
+ end
+ joker = center
+ end
+ return add_joker_ref(joker, edition, silent, eternal)
+end
+
+local card_apply_to_run_ref = Card.apply_to_run
+function Card:apply_to_run(center)
+ if MP.is_ruleset_active("smallworld") then
+ if not self and center and G.GAME.banned_keys[center.key] then
+ G.GAME.used_vouchers[center.key] = nil
+ center = G.P_CENTERS[get_next_voucher_key()]
+ G.GAME.used_vouchers[center.key] = true
+ end
+ end
+ return card_apply_to_run_ref(self, center)
+end
+
+function MP.legacy_smallworld()
+ return MP.LOBBY.code and MP.LOBBY.config and MP.LOBBY.config.legacy_smallworld
+end
diff --git a/rulesets/speedlatro.lua b/rulesets/speedlatro.lua
new file mode 100644
index 00000000..71d49237
--- /dev/null
+++ b/rulesets/speedlatro.lua
@@ -0,0 +1,143 @@
+MP.Ruleset({
+ key = "speedlatro",
+ multiplayer_content = true,
+ standard = true,
+ banned_silent = {
+ "j_hanging_chad",
+ "j_ticket",
+ "j_selzer",
+ "j_turtle_bean",
+ "j_bloodstone",
+ "c_ouija",
+ },
+ banned_jokers = {},
+ banned_consumables = {
+ "c_justice",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {
+ "j_mp_hanging_chad",
+ "j_mp_ticket",
+ "j_mp_seltzer",
+ "j_mp_turtle_bean",
+ },
+ reworked_consumables = {
+ "c_mp_ouija_standard",
+ },
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = false,
+ forced_gamemode_text = "k_attrition",
+ description_key = "k_speedlatro_description",
+ })
+ end,
+ forced_gamemode = "gamemode_mp_attrition",
+}):inject()
+
+-- speedlatro specific timer
+-- i can't be bothered to do run_start hooks and risk that being janky so it'll be initialized in gupdate
+
+local base_timer = 147
+
+local gupdate = Game.update
+function Game:update(dt)
+ if MP.is_ruleset_active("speedlatro") and G.STAGE == G.STAGES.RUN then
+ if not MP.speedlatro_timer then
+ MP.speedlatro_timer = {real = base_timer, display = base_timer}
+ MP.speedlatro_timer.text = UIBox{
+ definition = {n=G.UIT.ROOT, config = {align = 'cm', colour = G.C.CLEAR, padding = 0.2}, nodes={
+ {n=G.UIT.R, config = {align = 'cm', maxw = 1}, nodes={
+ {n=G.UIT.O, config={object = DynaText({scale = 1.1, string = {{ref_table = MP.speedlatro_timer, ref_value = "display"}}, maxw = 18, colours = {G.C.WHITE},float = true, shadow = true, silent = true, pop_in = 0, pop_in_rate = 6})}},
+ }}
+ }},
+ config = {
+ align = 'cm',
+ offset ={x=0.3,y=-2.9},
+ major = G.deck,
+ }
+ }
+ end
+ -- holy fucking conditional
+ if not (G.STATE == G.STATES.HAND_PLAYED
+ and G.GAME.current_round.hands_left < 1
+ and G.STATE_COMPLETE
+ and MP.LOBBY.connected
+ and MP.LOBBY.code
+ and MP.is_pvp_boss()) then
+ if not (G.CONTROLLER.locks.enter_pvp or MP.GAME.ready_blind) then
+ local mult = 1
+ if MP.GAME.timer_started and not MP.is_pvp_boss() then
+ mult = 2
+ end
+ MP.speedlatro_timer.real = MP.speedlatro_timer.real - dt*mult
+ end
+ end
+ if MP.speedlatro_timer.real <= 0 then
+ MP.speedlatro_timer.real = 0
+ -- weird logic flow
+ if MP.LOBBY.code then
+ if not MP.speedlatro_timer.failed then
+ MP.ACTIONS.fail_timer()
+ MP.speedlatro_timer.failed = true
+ end
+ elseif G.STATE ~= G.STATES.GAME_OVER then
+ G.STATE = G.STATES.GAME_OVER
+ G.STATE_COMPLETE = false
+ end
+ end
+
+ -- fun
+ MP.GAME.timer = 999
+
+ local suffix = string.sub(math.floor((MP.speedlatro_timer.real+100)*100), -2)
+ MP.speedlatro_timer.display = math.floor(MP.speedlatro_timer.real).."."..suffix
+
+ elseif MP.speedlatro_timer then
+ MP.speedlatro_timer.text:remove()
+ MP.speedlatro_timer = nil
+ end
+ return gupdate(self, dt)
+end
+
+-- not perfect but whatever this mode is janky anyways
+
+local new_round_ref = new_round
+function new_round()
+ if MP.is_ruleset_active("speedlatro") then
+ if MP.LOBBY.code then
+ if G.GAME.round_resets.blind == G.P_BLINDS["bl_mp_nemesis"] then
+ MP.speedlatro_timer.real = base_timer/2
+ MP.speedlatro_timer.failed = false
+ end
+ elseif G.GAME.round_resets.blind ~= G.P_BLINDS["bl_small"]
+ and G.GAME.round_resets.blind ~= G.P_BLINDS["bl_big"] then
+ MP.speedlatro_timer.real = base_timer/2
+ end
+ end
+ return new_round_ref()
+end
+
+local end_round_ref = end_round
+function end_round()
+ if MP.is_ruleset_active("speedlatro") then
+ if MP.LOBBY.code then
+ if MP.is_pvp_boss() then
+ MP.speedlatro_timer.real = base_timer
+ MP.speedlatro_timer.failed = false
+ end
+ elseif G.GAME.blind:get_type() == "Boss" then
+ MP.speedlatro_timer.real = base_timer
+ end
+ end
+ return end_round_ref()
+end
\ No newline at end of file
diff --git a/rulesets/traditional.lua b/rulesets/traditional.lua
new file mode 100644
index 00000000..22e0dede
--- /dev/null
+++ b/rulesets/traditional.lua
@@ -0,0 +1,50 @@
+MP.Ruleset({
+ key = "traditional",
+ multiplayer_content = true,
+ standard = true,
+ banned_silent = {
+ "j_hanging_chad",
+ "j_ticket",
+ "j_selzer",
+ "j_turtle_bean",
+ "j_bloodstone",
+ "c_ouija",
+ },
+ banned_jokers = {
+ "j_mp_speedrun",
+ "j_mp_conjoined_joker",
+ },
+ banned_consumables = {
+ "c_justice",
+ },
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {
+ "j_mp_hanging_chad",
+ "j_mp_ticket",
+ "j_mp_seltzer",
+ "j_mp_turtle_bean",
+ },
+ reworked_consumables = {
+ "c_mp_ouija_standard",
+ },
+ reworked_vouchers = {},
+ reworked_enhancements = {
+ "m_mp_display_glass",
+ },
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = true,
+ forced_lobby_options = false,
+ description_key = "k_traditional_description",
+ })
+ end,
+ force_lobby_options = function(self)
+ MP.LOBBY.config.timer = false
+ return false
+ end,
+}):inject()
diff --git a/rulesets/vanilla.lua b/rulesets/vanilla.lua
new file mode 100644
index 00000000..fb71e43c
--- /dev/null
+++ b/rulesets/vanilla.lua
@@ -0,0 +1,23 @@
+MP.Ruleset({
+ key = "vanilla",
+ multiplayer_content = false,
+ banned_jokers = {},
+ banned_consumables = {},
+ banned_vouchers = {},
+ banned_enhancements = {},
+ banned_tags = {},
+ banned_blinds = {},
+ reworked_jokers = {},
+ reworked_consumables = {},
+ reworked_vouchers = {},
+ reworked_enhancements = {},
+ reworked_tags = {},
+ reworked_blinds = {},
+ create_info_menu = function()
+ return MP.UI.CreateRulesetInfoMenu({
+ multiplayer_content = false,
+ forced_lobby_options = false,
+ description_key = "k_vanilla_description",
+ })
+ end,
+}):inject()
diff --git a/stylua.toml b/stylua.toml
new file mode 100644
index 00000000..492dcb68
--- /dev/null
+++ b/stylua.toml
@@ -0,0 +1,16 @@
+# StyLua configuration for Balatro Multiplayer
+
+indent_type = "Tabs"
+column_width = 120
+
+quote_style = "AutoPreferDouble"
+
+# Function call style
+call_parentheses = "Always"
+collapse_simple_statement = "ConditionalOnly"
+
+# End of line handling
+line_endings = "Unix"
+
+# Space handling around operators and assignments
+space_after_function_names = "Never"
diff --git a/ui/_common/background_grouping.lua b/ui/_common/background_grouping.lua
new file mode 100644
index 00000000..2566be91
--- /dev/null
+++ b/ui/_common/background_grouping.lua
@@ -0,0 +1,21 @@
+function MP.UI.BackgroundGrouping(text, nodes, config)
+ config = config or {}
+ config.text_scale = config.text_scale or 0.33
+ return {
+ n = config.col and G.UIT.C or G.UIT.R,
+ config = { align = "cm", padding = 0.05, r = 0.1, colour = G.C.UI.TRANSPARENT_DARK },
+ nodes = {
+ { n = G.UIT.R, config = { align = "cm" }, nodes = nodes },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = { text = text, colour = lighten(G.C.L_BLACK, 0.5), scale = config.text_scale },
+ },
+ },
+ },
+ },
+ }
+end
diff --git a/ui/_common/disableable_button.lua b/ui/_common/disableable_button.lua
new file mode 100644
index 00000000..9e423b6b
--- /dev/null
+++ b/ui/_common/disableable_button.lua
@@ -0,0 +1,17 @@
+function MP.UI.Disableable_Button(args)
+ local enabled_table = args.enabled_ref_table or {}
+ local enabled = enabled_table[args.enabled_ref_value]
+ args.colour = args.colour or G.C.RED
+ args.text_colour = args.text_colour or G.C.UI.TEXT_LIGHT
+ args.disabled_text = args.disabled_text or args.label
+ args.label = not enabled and args.disabled_text or args.label
+
+ local button_component = UIBox_button(args)
+ button_component.nodes[1].config.button = enabled and args.button or nil
+ button_component.nodes[1].config.hover = enabled
+ button_component.nodes[1].config.shadow = enabled
+ button_component.nodes[1].config.colour = enabled and args.colour or G.C.UI.BACKGROUND_INACTIVE
+ button_component.nodes[1].nodes[1].nodes[1].colour = enabled and args.text_colour or G.C.UI.TEXT_INACTIVE
+ button_component.nodes[1].nodes[1].nodes[1].shadow = enabled
+ return button_component
+end
diff --git a/ui/_common/disableable_option_cycle.lua b/ui/_common/disableable_option_cycle.lua
new file mode 100644
index 00000000..9cff9d06
--- /dev/null
+++ b/ui/_common/disableable_option_cycle.lua
@@ -0,0 +1,12 @@
+function MP.UI.Disableable_Option_Cycle(args)
+ local enabled_table = args.enabled_ref_table or {}
+ local enabled = enabled_table[args.enabled_ref_value]
+
+ if not enabled then
+ args.options = { args.options[args.current_option] }
+ args.current_option = 1
+ end
+
+ local option_component = create_option_cycle(args)
+ return option_component
+end
diff --git a/ui/_common/disableable_toggle.lua b/ui/_common/disableable_toggle.lua
new file mode 100644
index 00000000..2d3cce60
--- /dev/null
+++ b/ui/_common/disableable_toggle.lua
@@ -0,0 +1,12 @@
+function MP.UI.Disableable_Toggle(args)
+ local enabled_table = args.enabled_ref_table or {}
+ local enabled = enabled_table[args.enabled_ref_value]
+
+ local toggle_component = create_toggle(args)
+ toggle_component.nodes[2].nodes[1].nodes[1].config.id = args.id
+ toggle_component.nodes[2].nodes[1].nodes[1].config.button = enabled and "toggle_button" or nil
+ toggle_component.nodes[2].nodes[1].nodes[1].config.button_dist = enabled and 0.2 or nil
+ toggle_component.nodes[2].nodes[1].nodes[1].config.hover = enabled and true or false
+ toggle_component.nodes[2].nodes[1].nodes[1].config.toggle_callback = enabled and args.callback or nil
+ return toggle_component
+end
diff --git a/ui/_common/spacer.lua b/ui/_common/spacer.lua
new file mode 100644
index 00000000..bafe8b2b
--- /dev/null
+++ b/ui/_common/spacer.lua
@@ -0,0 +1,19 @@
+function MP.UI.create_spacer(size, row)
+ size = size or 0.2
+
+ return row and {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ minh = size,
+ },
+ nodes = {},
+ } or {
+ n = row and G.UIT.R or G.UIT.C,
+ config = {
+ align = "cm",
+ minw = size,
+ },
+ nodes = {},
+ }
+end
diff --git a/ui/game/blind_chip_sprite.lua b/ui/game/blind_chip_sprite.lua
new file mode 100644
index 00000000..8b201b0c
--- /dev/null
+++ b/ui/game/blind_chip_sprite.lua
@@ -0,0 +1,26 @@
+MP.UI.BlindChip = {}
+
+function MP.UI.BlindChip.custom(atlas, x, y)
+ local blind_chip = AnimatedSprite(0, 0, 1.4, 1.4, G.ANIMATION_ATLAS[atlas], { x = x, y = y })
+ blind_chip:define_draw_steps({
+ { shader = "dissolve", shadow_height = 0.05 },
+ { shader = "dissolve" },
+ })
+ return blind_chip
+end
+
+function MP.UI.BlindChip.small()
+ return MP.UI.BlindChip.custom("blind_chips", 0, 0)
+end
+
+function MP.UI.BlindChip.big()
+ return MP.UI.BlindChip.custom("blind_chips", 0, 1)
+end
+
+function MP.UI.BlindChip.random()
+ return MP.UI.BlindChip.custom("blind_chips", 0, 30)
+end
+
+function MP.UI.BlindChip.nemesis()
+ return MP.UI.BlindChip.custom("mp_player_blind_col", 0, 22)
+end
diff --git a/ui/game/blind_choice.lua b/ui/game/blind_choice.lua
new file mode 100644
index 00000000..1a9fe6d8
--- /dev/null
+++ b/ui/game/blind_choice.lua
@@ -0,0 +1,501 @@
+local create_UIBox_blind_choice_ref = create_UIBox_blind_choice
+---@diagnostic disable-next-line: lowercase-global
+function create_UIBox_blind_choice(type, run_info)
+ if MP.LOBBY.code then
+ if not G.GAME.blind_on_deck then G.GAME.blind_on_deck = "Small" end
+ if not run_info then G.GAME.round_resets.blind_states[G.GAME.blind_on_deck] = "Select" end
+
+ local disabled = false
+ type = type or "Small"
+ local nemesis = G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" and true or false
+ local nemesis_blind_col = nemesis and MP.UTILS.get_nemesis_key()
+
+ local blind_choice = {
+ config = G.P_BLINDS[G.GAME.round_resets.blind_choices[type]],
+ }
+
+ local blind_atlas = "blind_chips"
+ local blind_pos = blind_choice.config.pos
+ if blind_choice.config and blind_choice.config.atlas then blind_atlas = blind_choice.config.atlas end
+ if nemesis then
+ blind_atlas = "mp_player_blind_col"
+ blind_pos = G.P_BLINDS[nemesis_blind_col].pos
+ end
+
+ blind_choice.animation = AnimatedSprite(0, 0, 1.4, 1.4, G.ANIMATION_ATLAS[blind_atlas], blind_pos)
+ blind_choice.animation:define_draw_steps({
+ { shader = "dissolve", shadow_height = 0.05 },
+ { shader = "dissolve" },
+ })
+ local extras = nil
+ local stake_sprite = get_stake_sprite(G.GAME.stake or 1, 0.5)
+
+ G.GAME.orbital_choices = G.GAME.orbital_choices or {}
+ G.GAME.orbital_choices[G.GAME.round_resets.ante] = G.GAME.orbital_choices[G.GAME.round_resets.ante] or {}
+
+ if not G.GAME.orbital_choices[G.GAME.round_resets.ante][type] then
+ local _poker_hands = {}
+ if MP.should_use_the_order() then
+ _poker_hands = MP.sorted_hand_list()
+ else
+ for k, v in pairs(G.GAME.hands) do
+ if SMODS.is_poker_hand_visible(k) then _poker_hands[#_poker_hands + 1] = k end
+ end
+ end
+
+ G.GAME.orbital_choices[G.GAME.round_resets.ante][type] =
+ pseudorandom_element(_poker_hands, pseudoseed("orbital"))
+ end
+
+ if
+ G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" or G.GAME.round_resets.pvp_blind_choices[type]
+ then
+ local dt1 = DynaText({
+ string = { { string = localize("k_bl_life"), colour = G.C.FILTER } },
+ colours = { G.C.BLACK },
+ scale = 0.55,
+ silent = true,
+ pop_delay = 4.5,
+ shadow = true,
+ bump = true,
+ maxw = 3,
+ })
+ local dt2 = DynaText({
+ string = { { string = localize("k_bl_or"), colour = G.C.WHITE } },
+ colours = { G.C.CHANCE },
+ scale = 0.35,
+ silent = true,
+ pop_delay = 4.5,
+ shadow = true,
+ maxw = 3,
+ })
+ local dt3 = DynaText({
+ string = { { string = localize("k_bl_death"), colour = G.C.FILTER } },
+ colours = { G.C.BLACK },
+ scale = 0.55,
+ silent = true,
+ pop_delay = 4.5,
+ shadow = true,
+ bump = true,
+ maxw = 3,
+ })
+ extras = {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.07, r = 0.1, colour = { 0, 0, 0, 0.12 }, minw = 2.9 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = { object = dt1 },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = dt2 } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = dt3 } },
+ },
+ },
+ },
+ },
+ },
+ }
+ elseif type == "Small" then
+ extras = create_UIBox_blind_tag(type, run_info)
+ elseif type == "Big" then
+ extras = create_UIBox_blind_tag(type, run_info)
+ else
+ extras = nil
+ end
+ G.GAME.round_resets.blind_ante = G.GAME.round_resets.blind_ante or G.GAME.round_resets.ante
+
+ local loc_target = localize({
+ type = "raw_descriptions",
+ key = blind_choice.config.key,
+ set = "Blind",
+ vars = {
+ blind_choice.config.key == "bl_ox"
+ and localize(G.GAME.current_round.most_played_poker_hand, "poker_hands")
+ or "",
+ },
+ })
+ local loc_name = (
+ G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis"
+ and (MP.LOBBY.is_host and MP.LOBBY.guest.username or MP.LOBBY.host.username)
+ ) or localize({ type = "name_text", key = blind_choice.config.key, set = "Blind" })
+
+ local blind_col = get_blind_main_colour(type)
+
+ ---@type string|number
+ local blind_amt = get_blind_amount(G.GAME.round_resets.blind_ante)
+ * blind_choice.config.mult
+ * G.GAME.starting_params.ante_scaling
+
+ if
+ G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" or G.GAME.round_resets.pvp_blind_choices[type]
+ then
+ blind_amt = "????"
+ end
+
+ local text_table = loc_target
+
+ if G.GAME.round_resets.pvp_blind_choices[type] then text_table[#text_table + 1] = localize("k_bl_mostchips") end
+
+ local blind_state = G.GAME.round_resets.blind_states[type]
+ local _reward = true
+ if G.GAME.modifiers.no_blind_reward and G.GAME.modifiers.no_blind_reward[type] then
+ ---@diagnostic disable-next-line: cast-local-type
+ _reward = nil
+ end
+ if blind_state == "Select" then blind_state = "Current" end
+ local run_info_colour = run_info
+ and (
+ blind_state == "Defeated" and G.C.GREY
+ or blind_state == "Skipped" and G.C.BLUE
+ or blind_state == "Upcoming" and G.C.ORANGE
+ or blind_state == "Current" and G.C.RED
+ or G.C.GOLD
+ )
+
+ local t = {
+ n = G.UIT.R,
+ config = {
+ id = type,
+ align = "tm",
+ func = "blind_choice_handler",
+ minh = not run_info and 10 or nil,
+ ref_table = { deck = nil, run_info = run_info },
+ r = 0.1,
+ padding = 0.05,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ colour = mix_colours(G.C.BLACK, G.C.L_BLACK, 0.5),
+ r = 0.1,
+ outline = 1,
+ outline_colour = G.C.L_BLACK,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.2 },
+ nodes = {
+ not run_info and {
+ n = G.UIT.R,
+ config = {
+ id = "select_blind_button",
+ align = "cm",
+ ref_table = blind_choice.config,
+ colour = disabled and G.C.UI.BACKGROUND_INACTIVE or G.C.ORANGE,
+ minh = 0.6,
+ minw = 2.7,
+ padding = 0.07,
+ r = 0.1,
+ shadow = true,
+ hover = true,
+ one_press = true,
+ func = (
+ G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis"
+ or G.GAME.round_resets.pvp_blind_choices[type]
+ )
+ and "pvp_ready_button"
+ or nil,
+ button = "select_blind",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ ref_table = G.GAME.round_resets.loc_blind_states,
+ ref_value = type,
+ scale = 0.45,
+ colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.UI.TEXT_LIGHT,
+ shadow = not disabled,
+ },
+ },
+ },
+ } or {
+ n = G.UIT.R,
+ config = {
+ id = "select_blind_button",
+ align = "cm",
+ ref_table = blind_choice.config,
+ colour = run_info_colour,
+ minh = 0.6,
+ minw = 2.7,
+ padding = 0.07,
+ r = 0.1,
+ emboss = 0.08,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize(blind_state, "blind_states"),
+ scale = 0.45,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { id = "blind_name", align = "cm", padding = 0.07 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ r = 0.1,
+ outline = 1,
+ outline_colour = blind_col,
+ colour = darken(blind_col, 0.3),
+ minw = 2.9,
+ emboss = 0.1,
+ padding = 0.07,
+ line_emboss = 1,
+ },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = loc_name,
+ colours = { disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE },
+ shadow = not disabled,
+ float = not disabled,
+ y_offset = -4,
+ scale = 0.45,
+ maxw = 2.8,
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { id = "blind_desc", align = "cm", padding = 0.05 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", minh = 1.5 },
+ nodes = {
+ { n = G.UIT.O, config = { object = blind_choice.animation } },
+ },
+ },
+ text_table and text_table[1] and {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ minh = 0.7,
+ padding = 0.05,
+ minw = 2.9,
+ },
+ nodes = {
+ text_table[1]
+ and {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 2.8 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ id = blind_choice.config.key,
+ ref_table = { val = "" },
+ ref_value = "val",
+ scale = 0.32,
+ colour = disabled
+ and G.C.UI.TEXT_INACTIVE
+ or G.C.WHITE,
+ shadow = not disabled,
+ func = "HUD_blind_debuff_prefix",
+ },
+ },
+ {
+ n = G.UIT.T,
+ config = {
+ text = text_table[1] or "-",
+ scale = 0.32,
+ colour = disabled
+ and G.C.UI.TEXT_INACTIVE
+ or G.C.WHITE,
+ shadow = not disabled,
+ },
+ },
+ },
+ }
+ or nil,
+ text_table[2] and {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 2.8 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = text_table[2] or "-",
+ scale = 0.32,
+ colour = disabled and G.C.UI.TEXT_INACTIVE
+ or G.C.WHITE,
+ shadow = not disabled,
+ },
+ },
+ },
+ } or nil,
+ text_table[3] and {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 2.8 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = text_table[3] or "-",
+ scale = 0.32,
+ colour = disabled and G.C.UI.TEXT_INACTIVE
+ or G.C.WHITE,
+ shadow = not disabled,
+ },
+ },
+ },
+ } or nil,
+ },
+ } or nil,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ r = 0.1,
+ padding = 0.05,
+ minw = 3.1,
+ colour = G.C.BLACK,
+ emboss = 0.05,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", maxw = 3 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ph_blind_score_at_least"),
+ scale = 0.3,
+ colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.WHITE,
+ shadow = not disabled,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", minh = 0.6 },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ w = 0.5,
+ h = 0.5,
+ colour = G.C.BLUE,
+ object = stake_sprite,
+ hover = true,
+ can_collide = false,
+ },
+ },
+ { n = G.UIT.B, config = { h = 0.1, w = 0.1 } },
+ {
+ n = G.UIT.T,
+ config = {
+ text = number_format(blind_amt),
+ scale = score_number_scale(0.9, blind_amt),
+ colour = disabled and G.C.UI.TEXT_INACTIVE or G.C.RED,
+ shadow = not disabled,
+ },
+ },
+ },
+ },
+ _reward
+ and {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ph_blind_reward"),
+ scale = 0.35,
+ colour = disabled and G.C.UI.TEXT_INACTIVE
+ or G.C.WHITE,
+ shadow = not disabled,
+ },
+ },
+ {
+ n = G.UIT.T,
+ config = {
+ text = string.rep(
+ ---@diagnostic disable-next-line: param-type-mismatch
+ localize("$"),
+ blind_choice.config.dollars
+ ) .. "+",
+ scale = 0.35,
+ colour = disabled and G.C.UI.TEXT_INACTIVE
+ or G.C.MONEY,
+ shadow = not disabled,
+ },
+ },
+ },
+ }
+ or nil,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { id = "blind_extras", align = "cm" },
+ nodes = {
+ extras,
+ },
+ },
+ },
+ }
+ return t
+ else
+ return create_UIBox_blind_choice_ref(type, run_info)
+ end
+end
diff --git a/ui/game/blind_hud.lua b/ui/game/blind_hud.lua
new file mode 100644
index 00000000..4df6cfb8
--- /dev/null
+++ b/ui/game/blind_hud.lua
@@ -0,0 +1,151 @@
+function MP.UI.update_blind_HUD()
+ if MP.LOBBY.code then
+ G.HUD_blind.alignment.offset.y = -10
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.3,
+ blockable = false,
+ func = function()
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_table = MP.GAME.enemy
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_value = "score_text"
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.func = "multiplayer_blind_chip_UI_scale"
+ G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[1].children[1].config.text =
+ localize("k_enemy_score")
+ G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[3].children[1].config.text =
+ localize("k_enemy_hands")
+ G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object.config.string =
+ { { ref_table = MP.GAME.enemy, ref_value = "hands" } }
+ G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object:update_text()
+ G.HUD_blind.alignment.offset.y = 0
+ if G.GAME.blind.config.blind.key == "bl_mp_nemesis" then -- this was just the first place i thought of to implement this sprite swapping, change if inappropriate
+ G.GAME.blind.children.animatedSprite.atlas = G.ANIMATION_ATLAS["mp_player_blind_col"]
+ local nemesis_blind_col = MP.UTILS.get_nemesis_key()
+ G.GAME.blind.children.animatedSprite:set_sprite_pos(G.P_BLINDS[nemesis_blind_col].pos)
+ end
+ return true
+ end,
+ }))
+ end
+end
+
+function MP.UI.reset_blind_HUD()
+ if MP.LOBBY.code then
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string =
+ { { ref_table = G.GAME.blind, ref_value = "loc_name" } }
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_table = G.GAME.blind
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count").config.ref_value = "chip_text"
+ G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[1].children[1].config.text =
+ localize("ph_blind_score_at_least")
+ G.HUD_blind:get_UIE_by_ID("HUD_blind").children[2].children[2].children[2].children[3].children[1].config.text =
+ localize("ph_blind_reward")
+ G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object.config.string =
+ { { ref_table = G.GAME.current_round, ref_value = "dollars_to_be_earned" } }
+ G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned").config.object:update_text()
+ end
+end
+
+-- Contains function overrides (monkey-patches) for blind-related functionality
+-- Overrides functions like get_blind_main_colour, Blind:change_colour, Blind:set_blind, etc.
+
+local get_blind_main_colourref = get_blind_main_colour
+function get_blind_main_colour(type) -- handles ui colour stuff
+ local nemesis = G.GAME.round_resets.blind_choices[type] == "bl_mp_nemesis" or type == "bl_mp_nemesis"
+ if nemesis then type = MP.UTILS.get_nemesis_key() end
+ return get_blind_main_colourref(type)
+end
+
+local blind_change_colourref = Blind.change_colour
+function Blind:change_colour(blind_col) -- ensures that small/big blinds have proper colouration
+ local small = false
+ if self.config.blind.key == "bl_mp_nemesis" then
+ local blind_key = MP.UTILS.get_nemesis_key()
+ if blind_key == "bl_small" or blind_key == "bl_big" then small = true end
+ end
+ local boss = self.boss
+ if small then self.boss = false end
+ blind_change_colourref(self, blind_col)
+ self.boss = boss
+end
+
+local blind_set_blindref = Blind.set_blind
+function Blind:set_blind(blind, reset, silent) -- hacking in proper spirals, far from good but whatever
+ blind_set_blindref(self, blind, reset, silent)
+ if (blind and blind.key == "bl_mp_nemesis") or (self and self.name and self.name == "bl_mp_nemesis") then -- this shouldn't break and this fix shouldn't work
+ local boss = true
+ local showdown = false
+ local blind_key = MP.UTILS.get_nemesis_key()
+ if blind_key == "bl_small" or blind_key == "bl_big" then boss = false end
+ if blind_key == "bl_final_heart" then -- should be made generic
+ showdown = true
+ end
+ G.ARGS.spin.real = (G.SETTINGS.reduced_motion and 0 or 1) * (boss and (showdown and 0.5 or 0.25) or 0)
+ end
+end
+
+local ease_background_colour_blindref = ease_background_colour_blind
+function ease_background_colour_blind(state, blind_override) -- handles background
+ local blindname = (
+ (blind_override or (G.GAME.blind and G.GAME.blind.name ~= "" and G.GAME.blind.name)) or "Small Blind"
+ )
+ local blindname = (blindname == "" and "Small Blind" or blindname)
+ if blindname == "bl_mp_nemesis" then
+ blind_override = MP.UTILS.get_nemesis_key()
+ for k, v in pairs(G.P_BLINDS) do
+ if blind_override == k then blind_override = v.name end
+ end
+ end
+ return ease_background_colour_blindref(state, blind_override)
+end
+
+local add_round_eval_rowref = add_round_eval_row
+function add_round_eval_row(config) -- if i could post a skull emoji i would, wtf is this (cashout screen)
+ if config.name == "blind1" and G.GAME.blind.config.blind.key == "bl_mp_nemesis" then
+ G.GAME.blind.chip_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score)
+
+ G.P_BLINDS["bl_mp_nemesis"].atlas = "mp_player_blind_col"
+ G.GAME.blind.pos = G.P_BLINDS[MP.UTILS.get_nemesis_key()].pos -- this one is getting reset so no need to bother
+ add_round_eval_rowref(config)
+ G.E_MANAGER:add_event(Event({
+ trigger = "before",
+ delay = 0.0,
+ func = function()
+ G.P_BLINDS["bl_mp_nemesis"].atlas = "mp_player_blind_chip" -- lmao
+ return true
+ end,
+ }))
+ else
+ add_round_eval_rowref(config)
+ end
+end
+
+local blind_defeat_ref = Blind.defeat
+function Blind:defeat(silent)
+ blind_defeat_ref(self, silent)
+ if MP.LOBBY.code and MP.UI.reset_blind_HUD then MP.UI.reset_blind_HUD() end
+end
+
+local blind_disable_ref = Blind.disable
+function Blind:disable()
+ if MP.is_pvp_boss() and not (G.GAME.blind and G.GAME.blind.name == "Verdant Leaf") then -- hackfix to make verdant work properly
+ return
+ end
+ blind_disable_ref(self)
+end
+
+G.FUNCS.multiplayer_blind_chip_UI_scale = function(e)
+ 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
+ e.config.scale = scale_number(MP.GAME.enemy.score.coeffiocient, 0.7, 100000)
+ end
+ MP.GAME.enemy.score_text = new_score_text
+ end
+end
+
+function MP.UI.juice_up_pvp_hud()
+ if MP.is_pvp_boss() then
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_count"):juice_up()
+ G.HUD_blind:get_UIE_by_ID("dollars_to_be_earned"):juice_up()
+ end
+end
diff --git a/ui/game/confirmation.lua b/ui/game/confirmation.lua
new file mode 100644
index 00000000..409cfad6
--- /dev/null
+++ b/ui/game/confirmation.lua
@@ -0,0 +1,39 @@
+function G.UIDEF.confirmation_dialog()
+ return create_UIBox_generic_options({
+ back_func = "options",
+ contents = {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0 }, {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.5 }, {
+ MP.UI.UTILS.create_text_node(localize("k_are_you_sure"), {
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ UIBox_button({
+ label = { localize("k_yes") },
+ button = "confirmation_dialog_yes",
+ minw = 5,
+ }),
+ }),
+ },
+ })
+end
+
+do
+ local confirm_selection_callback = nil
+
+ function G.FUNCS.confirm_selection(callback)
+ confirm_selection_callback = callback
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.confirmation_dialog(),
+ })
+ end
+
+ function G.FUNCS.confirmation_dialog_yes()
+ G.FUNCS.exit_overlay_menu()
+ if confirm_selection_callback then
+ confirm_selection_callback()
+ confirm_selection_callback = nil
+ end
+ end
+end
diff --git a/ui/game/enemy_location.lua b/ui/game/enemy_location.lua
new file mode 100644
index 00000000..55bea732
--- /dev/null
+++ b/ui/game/enemy_location.lua
@@ -0,0 +1,148 @@
+function MP.UI.show_enemy_location()
+ local row_dollars_chips = G.HUD:get_UIE_by_ID("row_dollars_chips")
+ if row_dollars_chips then
+ row_dollars_chips.children[1]:remove()
+ row_dollars_chips.children[1] = nil
+ G.HUD:add_child({
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0, maxw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_enemy_loc")[1],
+ scale = 0.42,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0, maxw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_enemy_loc")[2],
+ scale = 0.42,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 3.3, minh = 0.7, r = 0.1, colour = G.C.DYN_UI.BOSS_DARK },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ ref_table = MP.GAME.enemy,
+ ref_value = "location",
+ scale = 0.35,
+ colour = G.C.WHITE,
+ id = "chip_UI_count",
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ }, row_dollars_chips)
+ end
+end
+
+function MP.UI.hide_enemy_location()
+ local row_dollars_chips = G.HUD:get_UIE_by_ID("row_dollars_chips")
+ if row_dollars_chips then
+ row_dollars_chips.children[1]:remove()
+ row_dollars_chips.children[1] = nil
+ G.HUD:add_child({
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0, maxw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = G.SETTINGS.language == "vi" and localize("k_lower_score")
+ or localize("k_round"),
+ scale = 0.42,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0, maxw = 1.3 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = G.SETTINGS.language == "vi" and localize("k_round")
+ or localize("k_lower_score"),
+ scale = 0.42,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 3.3, minh = 0.7, r = 0.1, colour = G.C.DYN_UI.BOSS_DARK },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ w = 0.5,
+ h = 0.5,
+ object = get_stake_sprite(G.GAME.stake or 1, 0.5),
+ hover = true,
+ can_collide = false,
+ },
+ },
+ { n = G.UIT.B, config = { w = 0.1, h = 0.1 } },
+ {
+ n = G.UIT.T,
+ config = {
+ ref_table = G.GAME,
+ ref_value = "chips_text",
+ lang = G.LANGUAGES["en-us"],
+ scale = 0.85,
+ colour = G.C.WHITE,
+ id = "chip_UI_count",
+ func = "chip_UI_set",
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ }, row_dollars_chips)
+ end
+end
diff --git a/ui/game/functions.lua b/ui/game/functions.lua
new file mode 100644
index 00000000..e813f7f2
--- /dev/null
+++ b/ui/game/functions.lua
@@ -0,0 +1,456 @@
+-- Contains function overrides (monkey-patches) for G.FUNCS callbacks
+-- Overrides button callback functions like can_play, can_open, select_blind, skip_blind, etc.
+
+G.FUNCS.pvp_ready_button = function(e)
+ if e.children[1].config.ref_table[e.children[1].config.ref_value] == localize("Select", "blind_states") then
+ e.config.button = "mp_toggle_ready"
+ e.config.one_press = false
+ e.children[1].config.ref_table = MP.GAME
+ e.children[1].config.ref_value = "ready_blind_text"
+ end
+ if e.config.button == "mp_toggle_ready" then e.config.colour = (MP.GAME.ready_blind and G.C.GREEN) or G.C.RED end
+end
+
+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")
+
+ if MP.GAME.ready_blind then
+ MP.ACTIONS.set_location("loc_ready")
+ MP.ACTIONS.ready_blind(e)
+ else
+ MP.ACTIONS.set_location("loc_selecting")
+ MP.ACTIONS.pause_ante_timer()
+ MP.ACTIONS.unready_blind()
+ end
+end
+
+local can_play_ref = G.FUNCS.can_play
+G.FUNCS.can_play = function(e)
+ if G.GAME.current_round.hands_left <= 0 then
+ e.config.colour = G.C.UI.BACKGROUND_INACTIVE
+ e.config.button = nil
+ else
+ can_play_ref(e)
+ end
+end
+
+local can_open_ref = G.FUNCS.can_open
+G.FUNCS.can_open = function(e)
+ if MP.GAME.ready_blind then
+ e.config.colour = G.C.UI.BACKGROUND_INACTIVE
+ e.config.button = nil
+ return
+ end
+ can_open_ref(e)
+end
+
+local select_blind_ref = G.FUNCS.select_blind
+function G.FUNCS.select_blind(e)
+ MP.GAME.end_pvp = false
+ MP.GAME.prevent_eval = false
+ select_blind_ref(e)
+ if MP.LOBBY.code then
+ MP.GAME.ante_key = tostring(math.random())
+ MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands)
+ MP.ACTIONS.new_round()
+ MP.ACTIONS.set_location("loc_playing-" .. (e.config.ref_table.key or e.config.ref_table.name))
+ if MP.UI.hide_enemy_location then MP.UI.hide_enemy_location() end
+ end
+end
+
+local skip_blind_ref = G.FUNCS.skip_blind
+G.FUNCS.skip_blind = function(e)
+ skip_blind_ref(e)
+ if MP.LOBBY.code then
+ if not MP.GAME.timer_started then MP.GAME.timer = MP.GAME.timer + MP.LOBBY.config.timer_increment_seconds end
+ MP.ACTIONS.skip(G.GAME.skips)
+
+ --Update the furthest blind
+ local temp_furthest_blind = 0
+ if G.GAME.round_resets.blind_states.Big == "Skipped" then
+ temp_furthest_blind = G.GAME.round_resets.ante * 10 + 2
+ elseif G.GAME.round_resets.blind_states.Small == "Skipped" then
+ temp_furthest_blind = G.GAME.round_resets.ante * 10 + 1
+ end
+
+ MP.GAME.pincher_index = MP.GAME.pincher_index + 1
+
+ MP.GAME.furthest_blind = (temp_furthest_blind > MP.GAME.furthest_blind) and temp_furthest_blind
+ or MP.GAME.furthest_blind
+
+ MP.ACTIONS.set_furthest_blind(MP.GAME.furthest_blind)
+ end
+end
+
+function G.FUNCS.toggle_players_jokers()
+ if not G.jokers or not MP.end_game_jokers then return end
+
+ -- Avoid Jokers being removed from activating removal abilities (e.g. Negatives)
+ if MP.end_game_jokers.cards then
+ for _, card in pairs(MP.end_game_jokers.cards) do
+ card.added_to_deck = false
+ end
+ end
+
+ if MP.end_game_jokers_text == localize("k_enemy_jokers") then
+ local your_jokers_save = copy_table(G.jokers:save())
+ MP.end_game_jokers:load(your_jokers_save)
+ MP.end_game_jokers_text = localize("k_your_jokers")
+ else
+ if MP.end_game_jokers_received then
+ G.FUNCS.load_end_game_jokers()
+ else
+ if MP.end_game_jokers.cards then remove_all(MP.end_game_jokers.cards) end
+ MP.end_game_jokers.cards = {}
+ end
+ MP.end_game_jokers_text = localize("k_enemy_jokers")
+ end
+end
+
+function G.FUNCS.view_nemesis_deck()
+ G.SETTINGS.paused = true
+ if G.deck_preview then
+ G.deck_preview:remove()
+ G.deck_preview = nil
+ end
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_view_nemesis_deck(),
+ })
+end
+
+function G.FUNCS.open_kofi(e)
+ love.system.openURL("https://ko-fi.com/virtualized")
+end
+
+function G.FUNCS:continue_in_singleplayer(e)
+ -- Leave multiplayer lobby and update UI
+ MP.LOBBY.code = nil
+ MP.ACTIONS.leave_lobby()
+ MP.UI.update_connection_status()
+
+ -- Allow saving, save the run, and set up for continuation
+ G.F_NO_SAVING = false
+ G.SETTINGS.current_setup = "Continue"
+ G.FUNCS.wipe_on()
+ save_run()
+ G:delete_run()
+
+ -- Load the saved game and start a new run in singleplayer
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ no_delete = true,
+ func = function()
+ local profile = G.SETTINGS.profile
+ local save_path = profile .. "/save.jkr"
+ G.SAVED_GAME = get_compressed(save_path)
+ if G.SAVED_GAME ~= nil then G.SAVED_GAME = STR_UNPACK(G.SAVED_GAME) end
+ G:start_run({ savetext = G.SAVED_GAME })
+ return true
+ end,
+ }))
+ G.FUNCS.wipe_off()
+end
+
+function G.FUNCS.attention_text_realtime(args)
+ args = args or {}
+ args.text = args.text or "test"
+ args.scale = args.scale or 1
+ args.colour = copy_table(args.colour or G.C.WHITE)
+ args.hold = (args.hold or 0)
+ args.pos = args.pos or { x = 0, y = 0 }
+ args.align = args.align or "cm"
+ args.emboss = args.emboss or nil
+
+ args.fade = 1
+
+ if args.cover then
+ args.cover_colour = copy_table(args.cover_colour or G.C.RED)
+ args.cover_colour_l = copy_table(lighten(args.cover_colour, 0.2))
+ args.cover_colour_d = copy_table(darken(args.cover_colour, 0.2))
+ else
+ args.cover_colour = copy_table(G.C.CLEAR)
+ end
+
+ args.uibox_config = {
+ align = args.align or "cm",
+ offset = args.offset or { x = 0, y = 0 },
+ major = args.cover or args.major or nil,
+ }
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ delay = 0,
+ blockable = false,
+ blocking = false,
+ func = function()
+ args.AT = UIBox({
+ T = { args.pos.x, args.pos.y, 0, 0 },
+ definition = {
+ n = G.UIT.ROOT,
+ config = {
+ align = args.cover_align or "cm",
+ minw = (args.cover and args.cover.T.w or 0.001) + (args.cover_padding or 0),
+ minh = (args.cover and args.cover.T.h or 0.001) + (args.cover_padding or 0),
+ padding = 0.03,
+ r = 0.1,
+ emboss = args.emboss,
+ colour = args.cover_colour,
+ },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ draw_layer = 1,
+ object = DynaText({
+ scale = args.scale,
+ string = args.text,
+ maxw = args.maxw,
+ colours = { args.colour },
+ float = true,
+ shadow = true,
+ silent = not args.noisy,
+ args.scale,
+ pop_in = 0,
+ pop_in_rate = 6,
+ rotate = args.rotate or nil,
+ }),
+ },
+ },
+ },
+ },
+ config = args.uibox_config,
+ })
+ args.AT.attention_text = true
+
+ args.text = args.AT.UIRoot.children[1].config.object
+ args.text:pulse(0.5)
+
+ if args.cover then
+ Particles(args.pos.x, args.pos.y, 0, 0, {
+ timer_type = "TOTAL",
+ timer = 0.01,
+ pulse_max = 15,
+ max = 0,
+ scale = 0.3,
+ vel_variation = 0.2,
+ padding = 0.1,
+ fill = true,
+ lifespan = 0.5,
+ speed = 2.5,
+ attach = args.AT.UIRoot,
+ colours = { args.cover_colour, args.cover_colour_l, args.cover_colour_d },
+ })
+ end
+ if args.backdrop_colour then
+ args.backdrop_colour = copy_table(args.backdrop_colour)
+ Particles(args.pos.x, args.pos.y, 0, 0, {
+ timer_type = "TOTAL",
+ timer = 5,
+ scale = 2.4 * (args.backdrop_scale or 1),
+ lifespan = 5,
+ speed = 0,
+ attach = args.AT,
+ colours = { args.backdrop_colour },
+ })
+ end
+ return true
+ end,
+ }))
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ delay = args.hold,
+ blockable = false,
+ blocking = false,
+ func = function()
+ if not args.start_time then
+ args.start_time = G.TIMERS.TOTAL
+ args.text:pop_out(3)
+ else
+ args.fade = math.max(0, 1 - 3 * (G.TIMERS.TOTAL - args.start_time))
+ if args.cover_colour then args.cover_colour[4] = math.min(args.cover_colour[4], 2 * args.fade) end
+ if args.cover_colour_l then args.cover_colour_l[4] = math.min(args.cover_colour_l[4], args.fade) end
+ if args.cover_colour_d then args.cover_colour_d[4] = math.min(args.cover_colour_d[4], args.fade) end
+ if args.backdrop_colour then args.backdrop_colour[4] = math.min(args.backdrop_colour[4], args.fade) end
+ args.colour[4] = math.min(args.colour[4], args.fade)
+ if args.fade <= 0 then
+ args.AT:remove()
+ return true
+ end
+ end
+ end,
+ }))
+end
+
+function G.FUNCS.overlay_endgame_menu()
+ G.FUNCS.overlay_menu({
+ definition = MP.GAME.won and create_UIBox_win() or create_UIBox_game_over(),
+ config = { no_esc = true },
+ })
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 2.5,
+ blocking = false,
+ func = function()
+ if G.OVERLAY_MENU and G.OVERLAY_MENU:get_UIE_by_ID("jimbo_spot") then
+ local Jimbo = Card_Character({ x = 0, y = 5 })
+ local spot = G.OVERLAY_MENU:get_UIE_by_ID("jimbo_spot")
+ spot.config.object:remove()
+ spot.config.object = Jimbo
+ Jimbo.ui_object_updated = true
+ local jimbo_words = MP.GAME.won and "wq_" .. math.random(1, 7) or "lq_" .. math.random(1, 10)
+ Jimbo:add_speech_bubble(jimbo_words, nil, { quip = true })
+ Jimbo:say_stuff(5)
+ end
+ return true
+ end,
+ }))
+end
+
+function MP.UI.ease_lives(mod)
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ if not G.hand_text_area then return end
+
+ if MP.LOBBY.config.disable_live_and_timer_hud then
+ return true -- Returning nothing hangs the game because it's a part of an event
+ end
+
+ local lives_UI = G.hand_text_area.ante
+ if not lives_UI then return true end
+
+ mod = mod or 0
+ local text = "+"
+ local col = G.C.IMPORTANT
+ if mod < 0 then
+ text = "-"
+ col = G.C.RED
+ end
+ lives_UI.config.object:update()
+ G.HUD:recalculate()
+ attention_text({
+ text = text .. tostring(math.abs(mod)),
+ scale = 1,
+ hold = 0.7,
+ cover = lives_UI.parent,
+ cover_colour = col,
+ align = "cm",
+ })
+ play_sound("highlight2", 0.685, 0.2)
+ play_sound("generic1")
+ return true
+ end,
+ }))
+end
+
+function MP.UI.show_asteroid_hand_level_up()
+ local hand_priority = {
+ ["Flush Five"] = 1,
+ ["Flush House"] = 2,
+ ["Five of a Kind"] = 3,
+ ["Straight Flush"] = 4,
+ ["Four of a Kind"] = 5,
+ ["Full House"] = 6,
+ ["Flush"] = 7,
+ ["Straight"] = 8,
+ ["Three of a Kind"] = 9,
+ ["Two Pair"] = 11,
+ ["Pair"] = 12,
+ ["High Card"] = 13,
+ }
+ local hand_type = "High Card"
+ local max_level = 0
+
+ for k, v in pairs(G.GAME.hands) do
+ if SMODS.is_poker_hand_visible(k) then
+ if
+ to_big(v.level) > to_big(max_level)
+ or (to_big(v.level) == to_big(max_level) and hand_priority[k] < hand_priority[hand_type])
+ then
+ hand_type = k
+ max_level = v.level
+ end
+ end
+ end
+ SMODS.upgrade_poker_hands({ hands = hand_type, level_up = -1 })
+end
+
+--[[
+function MP.UI.create_UIBox_Misprint_Display()
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", padding = 0.03, colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05, colour = G.C.UI.TRANSPARENT_DARK, r = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ id = "misprint_display",
+ func = "misprint_display_set",
+ object = DynaText({
+ string = { { ref_table = MP.GAME, ref_value = "misprint_display" } },
+ colours = { G.C.UI.TEXT_LIGHT },
+ shadow = true,
+ float = true,
+ scale = 0.5,
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function G.FUNCS.misprint_display_set(e)
+ local misprint_raw = (G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.id or 11)
+ .. (G.deck and G.deck.cards[1] and G.deck.cards[#G.deck.cards].base.suit:sub(1, 1) or "D")
+ if misprint_raw == e.config.last_misprint then
+ return
+ end
+ e.config.last_misprint = misprint_raw
+
+ local value = tonumber(misprint_raw:sub(1, -2))
+ local suit = misprint_raw:sub(-1)
+
+ local suit_full = { H = "Hearts", D = "Diamonds", C = "Clubs", S = "Spades" }
+
+ local value_key = tostring(value)
+ if value == 14 then
+ value_key = "Ace"
+ elseif value == 11 then
+ value_key = "Jack"
+ elseif value == 12 then
+ value_key = "Queen"
+ elseif value == 13 then
+ value_key = "King"
+ end
+
+ local localized_card = {}
+
+ localize({
+ type = "other",
+ key = "playing_card",
+ set = "Other",
+ nodes = localized_card,
+ vars = {
+ localize(value_key, "ranks"),
+ localize(suit_full[suit], "suits_plural"),
+ colours = { G.C.UI.TEXT_LIGHT },
+ },
+ })
+
+ -- Yes I know this is stupid
+ MP.GAME.misprint_display = localized_card[1][2].config.text .. localized_card[1][3].config.text
+ e.config.object.colours = { G.C.SUITS[suit_full[suit]]
+--}
+--end
+--]]
diff --git a/ui/game/game_end.lua b/ui/game/game_end.lua
new file mode 100644
index 00000000..8b4ba6de
--- /dev/null
+++ b/ui/game/game_end.lua
@@ -0,0 +1,449 @@
+function MP.UI.create_UIBox_mp_game_end(has_won)
+ MP.end_game_jokers = CardArea(
+ 0,
+ 0,
+ 5 * G.CARD_W,
+ G.CARD_H,
+ { card_limit = G.GAME.starting_params.joker_slots, type = "joker", highlight_limit = 1 }
+ )
+ if not MP.end_game_jokers_received then
+ MP.ACTIONS.get_end_game_jokers()
+ else
+ G.FUNCS.load_end_game_jokers()
+ end
+ MP.end_game_jokers_text = localize("k_enemy_jokers")
+
+ MP.ACTIONS.request_nemesis_stats()
+
+ MP.nemesis_deck = CardArea(-100, -100, G.CARD_W, G.CARD_H, { type = "deck" })
+ MP.nemesis_cards = {}
+ if not MP.nemesis_deck_received then
+ MP.ACTIONS.get_nemesis_deck()
+ else
+ G.FUNCS.load_nemesis_deck()
+ end
+
+ G.SETTINGS.paused = false
+
+ local eased_bg_colour
+ if has_won then
+ eased_bg_colour = copy_table(G.C.GREEN)
+ eased_bg_colour[4] = 0
+ ease_value(eased_bg_colour, 4, 0.5, nil, nil, true)
+ else
+ eased_bg_colour = copy_table(G.C.RED)
+ eased_bg_colour[4] = 0
+ ease_value(eased_bg_colour, 4, 0.8, nil, nil, true)
+ end
+
+ local t = create_UIBox_generic_options({
+ padding = 0,
+ bg_colour = eased_bg_colour,
+ colour = has_won and G.C.BLACK or nil,
+ outline_colour = has_won and G.C.EDITION or nil,
+ no_back = true,
+ no_esc = has_won,
+ contents = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = { has_won and localize("ph_you_win") or localize("ph_game_over") },
+ colours = { has_won and G.C.EDITION or G.C.RED },
+ shadow = true,
+ float = true,
+ spacing = has_won and 10 or nil,
+ rotate = has_won,
+ scale = 1.5,
+ pop_in = 0.4,
+ maxw = 6.5,
+ }),
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.15 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ ref_table = MP,
+ ref_value = "end_game_jokers_text",
+ scale = 0.8,
+ maxw = 5,
+ shadow = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08 },
+ nodes = {
+ { n = G.UIT.O, config = { object = MP.end_game_jokers } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ maxw = has_won and 0.8 or 1,
+ minw = has_won and 0.8 or 1,
+ minh = 0.7,
+ colour = G.C.CLEAR,
+ no_fill = false,
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ button = "toggle_players_jokers",
+ align = "cm",
+ padding = 0.12,
+ colour = G.C.BLUE,
+ emboss = 0.05,
+ minh = 0.7,
+ minw = 2,
+ maxw = 2,
+ r = 0.1,
+ shadow = true,
+ hover = true,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("b_toggle_jokers"),
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.65,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ id = "view_nemesis_deck_button",
+ button = "view_nemesis_deck",
+ align = "cm",
+ padding = 0.12,
+ colour = G.C.BLUE,
+ emboss = 0.05,
+ minh = 0.7,
+ minw = 2,
+ maxw = 2,
+ r = 0.1,
+ shadow = true,
+ hover = true,
+ focus_args = has_won and { nav = "wide" } or nil,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("b_view_nemesis_deck"),
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.65,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = {
+ maxw = has_won and 0.8 or 1,
+ minw = has_won and 0.8 or 1,
+ minh = 0.7,
+ colour = G.C.CLEAR,
+ no_fill = false,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.08 },
+ nodes = {
+ create_UIBox_round_scores_row("hand"),
+ create_UIBox_round_scores_row("poker_hand"),
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08, minw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_mp_kofi_message")[1],
+ scale = 0.35,
+ colour = G.C.UI.TEXT_LIGHT,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08, minw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_mp_kofi_message")[2],
+ scale = 0.35,
+ colour = G.C.UI.TEXT_LIGHT,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08, minw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_mp_kofi_message")[3],
+ scale = 0.35,
+ colour = G.C.UI.TEXT_LIGHT,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.08, minw = 2 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("ml_mp_kofi_message")[4],
+ scale = 0.35,
+ colour = G.C.UI.TEXT_LIGHT,
+ col = true,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ id = "ko-fi_button",
+ align = "cm",
+ padding = 0.1,
+ r = 0.1,
+ hover = true,
+ colour = HEX("72A5F2"),
+ button = "open_kofi",
+ shadow = true,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0,
+ no_fill = true,
+ maxw = 3,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("b_mp_kofi_button"),
+ scale = 0.35,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ },
+ },
+ --[[ Removed until it is fixed in a future update
+ UIBox_button({
+ id = "continue_singpleplayer_button",
+ align = "lm",
+ button = "continue_in_singleplayer",
+ label = { localize("b_continue_singleplayer") },
+ colour = G.C.GREEN,
+ toolTip = {title = "", text = localize("k_continue_singleplayer_tooltip")},
+ minw = 6,
+ minh = 1,
+ func = 'set_button_pip',
+ focus_args = { nav = "wide", button = "y" },
+ })]]
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "tr", padding = 0.08 },
+ nodes = {
+ create_UIBox_round_scores_row("furthest_ante", G.C.FILTER),
+ create_UIBox_round_scores_row("furthest_round", G.C.FILTER),
+ create_UIBox_round_scores_row("seed", G.C.WHITE),
+ UIBox_button({
+ id = "copy_seed_button",
+ button = "copy_seed",
+ label = { localize("b_copy") },
+ colour = G.C.BLUE,
+ scale = 0.3,
+ minw = 2.3,
+ minh = 0.4,
+ }),
+ {
+ n = G.UIT.R,
+ config = { align = "cm", minh = 0.4, minw = 0.1 },
+ nodes = {},
+ },
+ UIBox_button({
+ id = "from_game_won",
+ button = "mp_return_to_lobby",
+ label = { localize("b_return_lobby") },
+ minw = 2.5,
+ maxw = 2.5,
+ minh = 1,
+ focus_args = { nav = "wide", snap_to = true },
+ }),
+ UIBox_button({
+ button = "lobby_leave",
+ label = { localize("b_leave_lobby") },
+ minw = 2.5,
+ maxw = 2.5,
+ minh = 1,
+ focus_args = { nav = "wide" },
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ t.nodes[1] = {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 2 },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ padding = 0,
+ id = "jimbo_spot",
+ object = Moveable(0, 0, G.CARD_W * 1.1, G.CARD_H * 1.1),
+ },
+ },
+ },
+ },
+ { n = G.UIT.C, config = { align = "cm", padding = 0.1 }, nodes = { t.nodes[1] } },
+ },
+ }
+
+ if has_won then t.config.id = "you_win_UI" end
+
+ return t
+end
+
+function G.UIDEF.view_nemesis_deck()
+ local playing_cards_ref = G.playing_cards
+ G.playing_cards = MP.nemesis_cards
+ local t = G.UIDEF.view_deck()
+ G.playing_cards = playing_cards_ref
+ return t
+end
+
+function G.UIDEF.create_UIBox_view_nemesis_deck()
+ return create_UIBox_generic_options({
+ back_func = "overlay_endgame_menu",
+ contents = {
+ create_tabs({
+ tabs = {
+ {
+ label = localize("k_nemesis_deck"),
+ chosen = true,
+ tab_definition_function = G.UIDEF.view_nemesis_deck,
+ },
+ {
+ label = localize("k_your_deck"),
+ tab_definition_function = G.UIDEF.view_deck,
+ },
+ },
+ tab_h = 8,
+ snap_to_nav = true,
+ }),
+ },
+ })
+end
+
+-- Contains function overrides (monkey-patches) for UI-related functionality
+-- Overrides UI creation functions like create_UIBox_game_over, create_UIBox_win, etc.
+
+local create_UIBox_game_over_ref = create_UIBox_game_over
+function create_UIBox_game_over()
+ if not MP.LOBBY.code then return create_UIBox_game_over_ref() end
+ return MP.UI.create_UIBox_mp_game_end(false)
+end
+
+local create_UIBox_win_ref = create_UIBox_win
+function create_UIBox_win()
+ if not MP.LOBBY.code then return create_UIBox_win_ref() end
+ return MP.UI.create_UIBox_mp_game_end(true)
+end
+
+local exit_overlay_menu_ref = G.FUNCS.exit_overlay_menu
+---@diagnostic disable-next-line: duplicate-set-field
+function G.FUNCS:exit_overlay_menu()
+ -- Saves username if user presses ESC instead of Enter
+ if G.OVERLAY_MENU and G.OVERLAY_MENU:get_UIE_by_ID("username_input_box") ~= nil then
+ MP.UTILS.save_username(MP.LOBBY.username)
+ end
+
+ exit_overlay_menu_ref(self)
+end
+
+local mods_button_ref = G.FUNCS.mods_button
+function G.FUNCS.mods_button(arg_736_0)
+ if G.OVERLAY_MENU and G.OVERLAY_MENU:get_UIE_by_ID("username_input_box") ~= nil then
+ MP.UTILS.save_username(MP.LOBBY.username)
+ end
+
+ mods_button_ref(arg_736_0)
+end
+
+function G.UIDEF.multiplayer_deck()
+ return G.UIDEF.challenge_description(
+ get_challenge_int_from_id(MP.Rulesets[MP.LOBBY.config.ruleset].challenge_deck),
+ nil,
+ false
+ )
+end
diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua
new file mode 100644
index 00000000..bc03dbd1
--- /dev/null
+++ b/ui/game/game_state.lua
@@ -0,0 +1,574 @@
+-- Contains function overrides (monkey-patches) for game state management
+-- Overrides Game methods like update_draw_to_hand, update_hand_played, update_new_round, etc.
+
+local update_draw_to_hand_ref = Game.update_draw_to_hand
+function Game:update_draw_to_hand(dt)
+ if MP.LOBBY.code then
+ if
+ not G.STATE_COMPLETE
+ and G.GAME.current_round.hands_played == 0
+ and G.GAME.current_round.discards_used == 0
+ and G.GAME.facing_blind
+ then
+ if G.GAME.round_resets.pvp_blind_choices[G.GAME.blind_on_deck] then
+ G.GAME.blind.pvp = true
+ else
+ G.GAME.blind.pvp = false
+ end
+ if MP.is_pvp_boss() then
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 1,
+ blockable = false,
+ func = function()
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_out(0)
+ MP.UI.update_blind_HUD()
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.45,
+ blockable = false,
+ func = function()
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object.config.string = {
+ {
+ ref_table = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host,
+ ref_value = "username",
+ },
+ }
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:update_text()
+ G.HUD_blind:get_UIE_by_ID("HUD_blind_name").config.object:pop_in(0)
+ return true
+ end,
+ }))
+ return true
+ end,
+ }))
+
+ MP.GAME.pincher_unlock = true
+ G.after_pvp = true -- i can't find a reasonable way to detect end of pvp (for pizza) so i'm doing something strange instead
+
+ if MP.GAME.asteroids > 0 then -- launch asteroids, messy event garbage
+ delay(0.8)
+ update_hand_text({ sound = "button", volume = 0.7, pitch = 0.8, delay = 0.3 }, {
+ handname = localize("k_asteroids"),
+ chips = localize("k_amount_short"),
+ mult = MP.GAME.asteroids,
+ })
+ delay(0.6)
+ local send = 0
+ for i = 1, MP.GAME.asteroids do
+ local perc = MP.GAME.asteroids - send
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ play_sound("tarot1", 0.9 + (perc / 10), 1)
+ return true
+ end,
+ }))
+ send = send + 1
+ update_hand_text({ delay = 0 }, { mult = MP.GAME.asteroids - send })
+ delay(0.2)
+ end
+ G.E_MANAGER:add_event(Event({
+ func = function()
+ for i = 1, MP.GAME.asteroids do
+ MP.ACTIONS.asteroid()
+ end
+ MP.GAME.asteroids = 0
+ return true
+ end,
+ }))
+ delay(0.7)
+ update_hand_text(
+ { sound = "button", volume = 0.7, pitch = 1.1, delay = 0 },
+ { mult = 0, chips = 0, handname = "", level = "" }
+ )
+ end
+ end
+ end
+ end
+ update_draw_to_hand_ref(self, dt)
+end
+
+local function eval_hand_and_jokers()
+ for i = 1, #G.hand.cards do
+ --Check for hand doubling
+ local reps = { 1 }
+ local j = 1
+ while j <= #reps do
+ local percent = (i - 0.999) / (#G.hand.cards - 0.998) + (j - 1) * 0.1
+ if reps[j] ~= 1 then
+ card_eval_status_text(
+ (reps[j].jokers or reps[j].seals).card,
+ "jokers",
+ nil,
+ nil,
+ nil,
+ (reps[j].jokers or reps[j].seals)
+ )
+ end
+
+ --calculate the hand effects
+ local effects = { G.hand.cards[i]:get_end_of_round_effect() }
+ for k = 1, #G.jokers.cards do
+ --calculate the joker individual card effects
+ local eval = G.jokers.cards[k]:calculate_joker({
+ cardarea = G.hand,
+ other_card = G.hand.cards[i],
+ individual = true,
+ end_of_round = true,
+ })
+ if eval then table.insert(effects, eval) end
+ end
+
+ if reps[j] == 1 then
+ --Check for hand doubling
+ --From Red seal
+ local eval = eval_card(
+ G.hand.cards[i],
+ { end_of_round = true, cardarea = G.hand, repetition = true, repetition_only = true }
+ )
+ if next(eval) and (next(effects[1]) or #effects > 1) then
+ for h = 1, eval.seals.repetitions do
+ reps[#reps + 1] = eval
+ end
+ end
+
+ --from Jokers
+ for j = 1, #G.jokers.cards do
+ --calculate the joker effects
+ local eval = eval_card(G.jokers.cards[j], {
+ cardarea = G.hand,
+ other_card = G.hand.cards[i],
+ repetition = true,
+ end_of_round = true,
+ card_effects = effects,
+ })
+ if next(eval) then
+ for h = 1, eval.jokers.repetitions do
+ reps[#reps + 1] = eval
+ end
+ end
+ end
+ end
+
+ for ii = 1, #effects do
+ --if this effect came from a joker
+ if effects[ii].card then
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ effects[ii].card:juice_up(0.7)
+ return true
+ end,
+ }))
+ end
+
+ --If dollars
+ if effects[ii].h_dollars then
+ ease_dollars(effects[ii].h_dollars)
+ card_eval_status_text(G.hand.cards[i], "dollars", effects[ii].h_dollars, percent)
+ end
+
+ --Any extras
+ if effects[ii].extra then
+ card_eval_status_text(G.hand.cards[i], "extra", nil, percent, nil, effects[ii].extra)
+ end
+ end
+ j = j + 1
+ end
+ end
+end
+
+local update_hand_played_ref = Game.update_hand_played
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:update_hand_played(dt)
+ -- Ignore for singleplayer or regular blinds
+ if not MP.LOBBY.connected or not MP.LOBBY.code or not MP.is_pvp_boss() then
+ update_hand_played_ref(self, dt)
+ return
+ end
+
+ if self.buttons then
+ self.buttons:remove()
+ self.buttons = nil
+ end
+ if self.shop then
+ self.shop:remove()
+ self.shop = nil
+ end
+
+ if not G.STATE_COMPLETE then
+ G.STATE_COMPLETE = true
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left)
+ -- For now, never advance to next round
+ if G.GAME.current_round.hands_left < 1 then
+ attention_text({
+ scale = 0.8,
+ text = localize("k_wait_enemy"),
+ hold = 5,
+ align = "cm",
+ offset = { x = 0, y = -1.5 },
+ major = G.play,
+ })
+ if G.hand.cards[1] and G.STATE == G.STATES.HAND_PLAYED then
+ eval_hand_and_jokers()
+ G.FUNCS.draw_from_hand_to_discard()
+ end
+ elseif not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.DRAW_TO_HAND
+ end
+ return true
+ end,
+ }))
+ end
+
+ if MP.GAME.end_pvp and MP.is_pvp_boss() and not (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) then
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.NEW_ROUND
+ MP.GAME.end_pvp = false
+ end
+end
+
+local update_new_round_ref = Game.update_new_round
+function Game:update_new_round(dt)
+ if MP.GAME.end_pvp then
+ if G.STATE ~= G.STATES.NEW_ROUND then
+ G.FUNCS.draw_from_hand_to_deck()
+ G.FUNCS.draw_from_discard_to_deck()
+ end
+ G.STATE = G.STATES.NEW_ROUND
+ MP.GAME.end_pvp = false
+ end
+ if MP.LOBBY.code and not G.STATE_COMPLETE then
+ -- Prevent player from losing
+ if to_big(G.GAME.chips) < to_big(G.GAME.blind.chips) and not MP.is_pvp_boss() then
+ G.GAME.blind.chips = -1
+ MP.GAME.wait_for_enemys_furthest_blind = (MP.LOBBY.config.gamemode == "gamemode_mp_survival")
+ and (tonumber(MP.GAME.lives) == 1) -- In Survival Mode, if this is the last live, wait for the enemy.
+ MP.ACTIONS.fail_round(G.GAME.current_round.hands_played)
+ end
+
+ -- Prevent player from winning
+ G.GAME.win_ante = 999
+
+ if MP.LOBBY.config.gamemode == "gamemode_mp_survival" and MP.GAME.wait_for_enemys_furthest_blind then
+ G.STATE_COMPLETE = true
+ G.FUNCS.draw_from_hand_to_discard()
+ attention_text({
+ scale = 0.8,
+ text = localize("k_wait_enemy_reach_this_blind"),
+ hold = 5,
+ align = "cm",
+ offset = { x = 0, y = -1.5 },
+ major = G.play,
+ })
+ else
+ update_new_round_ref(self, dt)
+ end
+
+ -- Reset ante number
+ G.GAME.win_ante = 8
+ return
+ end
+ update_new_round_ref(self, dt)
+end
+
+local update_selecting_hand_ref = Game.update_selecting_hand
+function Game:update_selecting_hand(dt)
+ if
+ G.GAME.current_round.hands_left < G.GAME.round_resets.hands
+ and #G.hand.cards < 1
+ and #G.deck.cards < 1
+ and #G.play.cards < 1
+ and MP.LOBBY.code
+ then
+ G.GAME.current_round.hands_left = 0
+ if not MP.is_pvp_boss() then
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.NEW_ROUND
+ else
+ MP.ACTIONS.play_hand(G.GAME.chips, 0)
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.HAND_PLAYED
+ end
+ return
+ end
+ update_selecting_hand_ref(self, dt)
+
+ if MP.GAME.end_pvp and MP.is_pvp_boss() then
+ G.hand:unhighlight_all()
+ G.STATE_COMPLETE = false
+ G.STATE = G.STATES.NEW_ROUND
+ MP.GAME.end_pvp = false
+ end
+end
+
+-- Consolidate both update_shop overrides
+local update_shop_ref = Game.update_shop
+function Game:update_shop(dt)
+ if not G.STATE_COMPLETE then
+ MP.GAME.ready_blind = false
+ MP.GAME.ready_blind_text = localize("b_ready")
+ MP.GAME.end_pvp = false
+ end
+
+ local updated_location = false
+ if MP.LOBBY.code and not G.STATE_COMPLETE and not updated_location and not G.GAME.USING_RUN then
+ updated_location = true
+ MP.ACTIONS.set_location("loc_shop")
+ MP.GAME.spent_before_shop = to_big(MP.GAME.spent_total) + to_big(0)
+ if MP.UI.show_enemy_location then MP.UI.show_enemy_location() end
+ end
+ if G.STATE_COMPLETE and updated_location then updated_location = false end
+ update_shop_ref(self, dt)
+end
+
+local update_blind_select_ref = Game.update_blind_select
+function Game:update_blind_select(dt)
+ local updated_location = false
+ if MP.LOBBY.code and not G.STATE_COMPLETE and not updated_location then
+ updated_location = true
+ MP.ACTIONS.set_location("loc_selecting")
+ if MP.UI.show_enemy_location then MP.UI.show_enemy_location() end
+ end
+ if G.STATE_COMPLETE and updated_location then updated_location = false end
+ update_blind_select_ref(self, dt)
+end
+
+local start_run_ref = Game.start_run
+function Game:start_run(args)
+ -- Use MP ruleset if in lobby, otherwise use SP ruleset (if selected)
+ MP.LoadReworks(MP.LOBBY.config.ruleset or MP.SP.ruleset)
+
+ start_run_ref(self, args)
+
+ if not MP.LOBBY.connected or not MP.LOBBY.code or MP.LOBBY.config.disable_live_and_timer_hud then return end
+
+ local scale = 0.4
+ local hud_ante = G.HUD:get_UIE_by_ID("hud_ante")
+ hud_ante.children[1].children[1].config.text = localize("k_lives")
+
+ -- Set lives number
+ hud_ante.children[2].children[1].config.object = DynaText({
+ string = { { ref_table = MP.GAME, ref_value = "lives" } },
+ colours = { G.C.IMPORTANT },
+ shadow = true,
+ font = G.LANGUAGES["en-us"].font,
+ scale = 2 * scale,
+ })
+
+ -- Remove unnecessary HUD elements from ante counter
+ hud_ante.children[2].children[2] = nil
+ hud_ante.children[2].children[3] = nil
+ hud_ante.children[2].children[4] = nil
+
+ G.HUD:recalculate()
+end
+
+-- This prevents duplicate execution during certain cases. e.g. Full deck discard before playing any hands.
+function MP.handle_duplicate_end()
+ if MP.LOBBY.code then
+ if MP.GAME.round_ended then
+ if not MP.GAME.duplicate_end then
+ MP.GAME.duplicate_end = true
+ sendDebugMessage("Duplicate end_round calls prevented.", "MULTIPLAYER")
+ end
+ return true
+ end
+ end
+ return false
+end
+
+-- This handles an edge case where a player plays no hands, and discards the only cards in their deck.
+-- Allows opponent to advance after playing anything, and eases a life from the person who discarded their deck.
+function MP.handle_deck_out()
+ if MP.LOBBY.code then
+ if
+ G.GAME.current_round.hands_played == 0
+ and G.GAME.current_round.discards_used > 0
+ and MP.LOBBY.config.gamemode ~= "gamemode_mp_survival"
+ then
+ if MP.is_pvp_boss() then MP.ACTIONS.play_hand(0, 0) end
+
+ MP.ACTIONS.fail_round(1)
+ end
+ end
+end
+
+local mp_jimbo = nil
+local mp_jimbo_pos = nil
+
+local JIMBO_POSITIONS = {
+ [1] = { align = "cri", offset = { x = 1, y = 0 }, bubble_align = "cl", bubble_offset = { x = 0, y = 0 } },
+ [2] = { align = "tli", offset = { x = -0.75, y = -0.75 }, bubble_align = "cr", bubble_offset = { x = 0, y = -0.5 } },
+ [3] = { align = "tri", offset = { x = 1.8, y = -0.1 }, bubble_align = "bl", bubble_offset = { x = 2.1, y = 0 } },
+ [4] = { align = "cmi", offset = { x = 0, y = -1.5 }, bubble_align = "cr", bubble_offset = { x = 0, y = 0 } },
+}
+
+function MP.UI.create_jimbo(pos, text)
+ if mp_jimbo then MP.UI.remove_jimbo() end
+ local p = JIMBO_POSITIONS[pos] or JIMBO_POSITIONS[1]
+ mp_jimbo_pos = pos or 1
+ mp_jimbo = Card_Character({
+ x = 0,
+ y = G.ROOM.T.h + 5,
+ center = "j_perkeo",
+ particle_colours = { HEX("4e997b"), HEX("e9564e"), HEX("ebecee") },
+ })
+ mp_jimbo.children.particles:remove()
+ mp_jimbo.children.particles = nil
+ mp_jimbo.children.card:set_edition({ negative = true }, true, true)
+ mp_jimbo.say_stuff = function(self, n, not_first)
+ self.talking = true
+ if not not_first then
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ delay = 0.1,
+ func = function()
+ if self.children.speech_bubble then self.children.speech_bubble.states.visible = true end
+ self:say_stuff(n, true)
+ return true
+ end,
+ }))
+ else
+ if n <= 0 then
+ self.talking = false
+ return
+ end
+ play_sound("voice" .. math.random(1, 11), math.random() * 0.2 + 1, 0.5)
+ self.children.card:juice_up()
+ G.E_MANAGER:add_event(
+ Event({
+ trigger = "after",
+ timer = "REAL",
+ blockable = false,
+ blocking = false,
+ delay = 0.13,
+ func = function()
+ self:say_stuff(n - 1, true)
+ return true
+ end,
+ }),
+ "tutorial"
+ )
+ end
+ end
+ mp_jimbo:set_alignment({
+ major = G.ROOM_ATTACH,
+ type = p.align,
+ offset = p.offset,
+ })
+ if text then MP.UI.jimbo_say(text) end
+ return mp_jimbo
+end
+
+function MP.UI.move_jimbo(pos)
+ if not mp_jimbo then return end
+ local p = JIMBO_POSITIONS[pos] or JIMBO_POSITIONS[1]
+ mp_jimbo_pos = pos or 1
+ mp_jimbo:set_alignment({
+ major = G.ROOM_ATTACH,
+ type = p.align,
+ offset = p.offset,
+ })
+ if mp_jimbo.children.speech_bubble then
+ mp_jimbo.children.speech_bubble.alignment.type = p.bubble_align
+ mp_jimbo.children.speech_bubble.alignment.offset = p.bubble_offset
+ mp_jimbo.children.speech_bubble:align_to_major()
+ end
+end
+
+function MP.UI.jimbo_say(text)
+ if not mp_jimbo then return end
+ if mp_jimbo.children.speech_bubble then mp_jimbo.children.speech_bubble:remove() end
+ local lines = {}
+ for line in MP.UTILS.wrapText(text, 30):gmatch("[^\n]+") do
+ lines[#lines + 1] = line:match("^%s*(.-)%s*$")
+ end
+ local rows = {}
+ for _, line in ipairs(lines) do
+ rows[#rows + 1] = {
+ n = G.UIT.R,
+ config = { align = "cl" },
+ nodes = {
+ { n = G.UIT.T, config = { text = line, scale = 0.4, colour = G.C.UI.TEXT_DARK } },
+ },
+ }
+ end
+ local definition = {
+ n = G.UIT.ROOT,
+ config = { align = "cm", minh = 1, r = 0.3, padding = 0.07, minw = 1, colour = G.C.JOKER_GREY, shadow = true },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minh = 1, r = 0.2, padding = 0.1, minw = 1, colour = G.C.WHITE },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minh = 1, r = 0.2, padding = 0.03, minw = 1, colour = G.C.WHITE },
+ nodes = rows,
+ },
+ },
+ },
+ },
+ }
+ local p = JIMBO_POSITIONS[mp_jimbo_pos] or JIMBO_POSITIONS[1]
+ mp_jimbo.children.speech_bubble = UIBox({
+ definition = definition,
+ config = { align = p.bubble_align, offset = p.bubble_offset, parent = mp_jimbo },
+ })
+ mp_jimbo.children.speech_bubble:set_role({
+ role_type = "Minor",
+ xy_bond = "Weak",
+ r_bond = "Strong",
+ major = mp_jimbo,
+ })
+ mp_jimbo.children.speech_bubble.states.visible = true
+ local word_count = select(2, text:gsub("%S+", ""))
+ local read_time = math.max(5, word_count * 0.3 + 1) + 5
+ mp_jimbo:say_stuff(math.ceil(word_count / 2))
+ local bubble_ref = mp_jimbo.children.speech_bubble
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ blockable = false,
+ blocking = false,
+ delay = read_time,
+ func = function()
+ if mp_jimbo and mp_jimbo.children.speech_bubble == bubble_ref then
+ mp_jimbo.children.speech_bubble:remove()
+ mp_jimbo.children.speech_bubble = nil
+ end
+ return true
+ end,
+ }))
+end
+
+function MP.UI.remove_jimbo()
+ if not mp_jimbo then return end
+ local jimbo = mp_jimbo
+ mp_jimbo = nil
+ if jimbo.children.speech_bubble then
+ jimbo.children.speech_bubble:remove()
+ jimbo.children.speech_bubble = nil
+ end
+ if jimbo.children.button then
+ jimbo.children.button:remove()
+ jimbo.children.button = nil
+ end
+ jimbo.children.card:start_dissolve({ HEX("4e997b") }, nil, nil, true)
+ if jimbo.children.particles then jimbo.children.particles:fade(0.5) end
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ blockable = false,
+ delay = 0.8,
+ func = function()
+ jimbo:remove()
+ return true
+ end,
+ }))
+end
diff --git a/ui/game/lobby_info.lua b/ui/game/lobby_info.lua
new file mode 100644
index 00000000..cd915cef
--- /dev/null
+++ b/ui/game/lobby_info.lua
@@ -0,0 +1,275 @@
+local Disableable_Toggle = MP.UI.Disableable_Toggle
+
+function G.FUNCS.lobby_info(e)
+ G.SETTINGS.paused = true
+ G.FUNCS.overlay_menu({
+ definition = MP.UI.lobby_info(),
+ })
+end
+
+function MP.UI.lobby_info()
+ return create_UIBox_generic_options({
+ contents = {
+ create_tabs({
+ tabs = {
+ {
+ label = localize("b_players"),
+ chosen = true,
+ tab_definition_function = MP.UI.create_UIBox_players,
+ },
+ {
+ label = localize("b_lobby_info"),
+ chosen = false,
+ tab_definition_function = MP.UI.create_UIBox_settings, -- saying settings because _options is used in lobby
+ },
+ },
+ tab_h = 8,
+ snap_to_nav = true,
+ }),
+ },
+ })
+end
+
+function MP.UI.create_UIBox_settings() -- optimize this please
+ local ruleset = string.sub(MP.LOBBY.config.ruleset, 12, -1)
+ local gamemode = string.sub(MP.LOBBY.config.gamemode, 13, -1)
+ local seed = MP.LOBBY.config.custom_seed == "random" and localize("k_random") or MP.LOBBY.config.custom_seed
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 6,
+ r = 0.1,
+ minw = 10,
+ align = "tm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.05 }, {
+ MP.UI.UTILS.create_text_node((localize("k_" .. ruleset) .. " " .. localize("k_" .. gamemode)), {
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.6,
+ }),
+ }),
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.05 }, {
+ MP.UI.UTILS.create_text_node((localize("k_current_seed") .. seed), {
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.6,
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_cb_money"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "gold_on_life_loss",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_no_gold_on_loss"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "no_gold_on_round_loss",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_death_on_loss"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "death_on_round_loss",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_diff_seeds"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "different_seeds",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_player_diff_deck"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "different_decks",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_multiplayer_jokers"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "multiplayer_jokers",
+ }),
+ }),
+ MP.UI.UTILS.create_row({ padding = 0, align = "cr" }, {
+ Disableable_Toggle({
+ enabled_ref_table = MP.LOBBY,
+ label = localize("b_opts_normal_bosses"),
+ ref_table = MP.LOBBY.config,
+ ref_value = "normal_bosses",
+ }),
+ }),
+ },
+ }
+end
+
+function MP.UI.create_UIBox_players()
+ local players = {
+ MP.UI.create_UIBox_player_row("host"),
+ MP.UI.create_UIBox_player_row("guest"),
+ }
+
+ local t = {
+ n = G.UIT.ROOT,
+ config = { align = "cm", minw = 3, padding = 0.1, r = 0.1, colour = G.C.CLEAR },
+ nodes = {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.04 }, players),
+ },
+ }
+
+ return t
+end
+
+function MP.UI.create_UIBox_mods_list(type)
+ return {
+ n = G.UIT.R,
+ config = { align = "cm", colour = G.C.WHITE, r = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = MP.UI.modlist_to_view(
+ type == "host" and MP.LOBBY.host.config.Mods or MP.LOBBY.guest.config.Mods,
+ G.C.UI.TEXT_DARK
+ ),
+ },
+ },
+ }
+end
+
+function MP.UI.create_UIBox_player_row(type)
+ local player_name = type == "host" and MP.LOBBY.host.username or MP.LOBBY.guest.username
+ local lives = MP.GAME.enemy.lives
+ local highest_score = MP.GAME.enemy.highest_score
+ if (type == "host" and MP.LOBBY.is_host) or (type == "guest" and not MP.LOBBY.is_host) then
+ lives = MP.GAME.lives
+ highest_score = MP.GAME.highest_score
+ end
+ return {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.05,
+ r = 0.1,
+ colour = darken(G.C.JOKER_GREY, 0.1),
+ emboss = 0.05,
+ hover = true,
+ force_focus = true,
+ on_demand_tooltip = {
+ text = { localize("k_mods_list") },
+ filler = { func = MP.UI.create_UIBox_mods_list, args = type },
+ },
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cl", padding = 0, minw = 5 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ padding = 0.02,
+ r = 0.1,
+ colour = G.C.RED,
+ minw = 2,
+ outline = 0.8,
+ outline_colour = G.C.RED,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = tostring(lives) .. " " .. localize("k_lives"),
+ scale = 0.4,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 4.5, maxw = 4.5 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = " " .. player_name,
+ scale = 0.45,
+ colour = G.C.UI.TEXT_LIGHT,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.05, colour = G.C.BLACK, r = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cr", padding = 0.01, r = 0.1, colour = G.C.CHIPS, minw = 1.1 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "???", -- Will be hands in the future
+ scale = 0.45,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ { n = G.UIT.B, config = { w = 0.08, h = 0.01 } },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cl", padding = 0.01, r = 0.1, colour = G.C.MULT, minw = 1.1 },
+ nodes = {
+ { n = G.UIT.B, config = { w = 0.08, h = 0.01 } },
+ {
+ n = G.UIT.T,
+ config = {
+ text = "???", -- Will be discards in the future
+ scale = 0.45,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.05, colour = G.C.L_BLACK, r = 0.1, minw = 1.5 },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = MP.INSANE_INT.to_string(highest_score),
+ scale = 0.45,
+ colour = G.C.FILTER,
+ shadow = true,
+ },
+ },
+ },
+ },
+ },
+ }
+end
diff --git a/ui/game/round.lua b/ui/game/round.lua
new file mode 100644
index 00000000..dbd5f5bf
--- /dev/null
+++ b/ui/game/round.lua
@@ -0,0 +1,68 @@
+-- Contains function overrides (monkey-patches) for round-related functionality
+-- Overrides functions like ease_ante, ease_round, reset_blinds, EventManager:add_event
+
+local ease_ante_ref = ease_ante
+function ease_ante(mod)
+ if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud then
+ -- Prevents easing multiple times at once
+ if MP.GAME.antes_keyed[MP.GAME.ante_key] then return end
+
+ -- pizza: remove discards
+ if MP.GAME.pizza_discards > 0 then
+ G.GAME.round_resets.discards = G.GAME.round_resets.discards - MP.GAME.pizza_discards
+ ease_discard(-MP.GAME.pizza_discards)
+ MP.GAME.pizza_discards = 0
+ end
+
+ MP.GAME.antes_keyed[MP.GAME.ante_key] = true
+ MP.ACTIONS.set_ante(G.GAME.round_resets.ante + mod)
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ func = function()
+ G.GAME.round_resets.ante = G.GAME.round_resets.ante + mod
+ check_and_set_high_score("furthest_ante", G.GAME.round_resets.ante)
+ return true
+ end,
+ }))
+ end
+ return ease_ante_ref(mod)
+end
+
+local ease_round_ref = ease_round
+function ease_round(mod)
+ if MP.LOBBY.code and not MP.LOBBY.config.disable_live_and_timer_hud and MP.LOBBY.config.timer then return end
+ ease_round_ref(mod)
+end
+
+local reset_blinds_ref = reset_blinds
+function reset_blinds()
+ reset_blinds_ref()
+ G.GAME.round_resets.pvp_blind_choices = {}
+ if MP.LOBBY.code then
+ local mp_small_choice, mp_big_choice, mp_boss_choice =
+ MP.Gamemodes[MP.LOBBY.config.gamemode]:get_blinds_by_ante(G.GAME.round_resets.ante)
+ G.GAME.round_resets.blind_choices.Small = mp_small_choice or G.GAME.round_resets.blind_choices.Small
+ G.GAME.round_resets.blind_choices.Big = mp_big_choice or G.GAME.round_resets.blind_choices.Big
+ G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss
+ end
+end
+
+-- necessary for showdown mode to ensure rounds progress properly, only affects nemesis blind to avoid possible incompatibilities (though i know many mods like to do this exact hook)
+local blind_get_type = Blind.get_type
+function Blind:get_type()
+ if self.name == "bl_mp_nemesis" then
+ return G.GAME.blind_on_deck
+ else
+ return blind_get_type(self)
+ end
+end
+
+-- added event suppression for a lovely patch for ease_ante
+local add_event_ref = EventManager.add_event
+function EventManager:add_event(event, queue, front)
+ if MP.suppress_next_event then
+ MP.suppress_next_event = false
+ return
+ end
+ return add_event_ref(self, event, queue, front)
+end
diff --git a/ui/game/ruleset_info_menu.lua b/ui/game/ruleset_info_menu.lua
new file mode 100644
index 00000000..2de5114a
--- /dev/null
+++ b/ui/game/ruleset_info_menu.lua
@@ -0,0 +1,87 @@
+function MP.UI.CreateRulesetInfoMenu(config)
+ local has_mp_content = config.multiplayer_content and "k_yes" or "k_no"
+ local has_mp_color = config.multiplayer_content and G.C.GREEN or G.C.RED
+ local forces_lobby = config.forced_lobby_options and "k_yes" or "k_no"
+ local forces_lobby_color = config.forced_lobby_options and G.C.GREEN or G.C.RED
+ local forces_gamemode_text = config.forced_gamemode_text or "k_no"
+ local forces_gamemode_color = config.forced_gamemode_text and G.C.GREEN or G.C.RED
+
+ return {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "tm",
+ },
+ nodes = {
+ MP.UI.BackgroundGrouping(localize("k_has_multiplayer_content"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize(has_mp_content),
+ scale = 0.8,
+ colour = has_mp_color,
+ },
+ },
+ }, { col = true, text_scale = 0.6 }),
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.1,
+ minh = 0.1,
+ },
+ },
+ MP.UI.BackgroundGrouping(localize("k_forces_lobby_options"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize(forces_lobby),
+ scale = 0.8,
+ colour = forces_lobby_color,
+ },
+ },
+ }, { col = true, text_scale = 0.6 }),
+ {
+ n = G.UIT.C,
+ config = {
+ minw = 0.1,
+ minh = 0.1,
+ },
+ },
+ MP.UI.BackgroundGrouping(localize("k_forces_gamemode"), {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize(forces_gamemode_text),
+ scale = 0.8,
+ colour = forces_gamemode_color,
+ },
+ },
+ }, { col = true, text_scale = 0.6 }),
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ minw = 0.05,
+ minh = 0.05,
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cl",
+ padding = 0.1,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize(config.description_key),
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ }
+end
diff --git a/ui/game/timer.lua b/ui/game/timer.lua
new file mode 100644
index 00000000..eab51676
--- /dev/null
+++ b/ui/game/timer.lua
@@ -0,0 +1,179 @@
+-- ease_round override moved to game/round.lua
+
+function G.FUNCS.mp_timer_button(e)
+ if MP.LOBBY.config.timer then
+ if MP.GAME.ready_blind then
+ if MP.GAME.timer <= 0 then
+ return
+ elseif not MP.GAME.timer_started then
+ MP.ACTIONS.start_ante_timer()
+ else
+ MP.ACTIONS.pause_ante_timer()
+ end
+ end
+ end
+end
+
+function MP.UI.timer_hud()
+ if MP.LOBBY.config.timer then
+ return {
+ 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_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",
+ func = "set_timer_box",
+ button = "mp_timer_button",
+ },
+ nodes = {
+ {
+ n = G.UIT.O,
+ config = {
+ object = DynaText({
+ string = MP.is_ruleset_active("speedlatro") and ">>" or { { ref_table = MP.GAME, ref_value = "timer" } }, -- sorry
+ colours = { G.C.UI.TEXT_DARK },
+ shadow = true,
+ scale = 0.8,
+ }),
+ id = "timer_UI_count",
+ },
+ },
+ },
+ },
+ },
+ }
+ end
+end
+
+function MP.UI.start_pvp_countdown(callback)
+ local seconds = countdown_seconds
+ local tick_delay = 1
+ 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 = seconds
+
+ G.CONTROLLER.locks.enter_pvp = true
+
+ local function show_next()
+ if MP.GAME.pvp_countdown <= 0 then
+ if callback then callback() end
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ trigger = "after",
+ blocking = false,
+ blockable = false,
+ delay = 1,
+ timer = "TOTAL",
+ func = function()
+ G.CONTROLLER.locks.enter_pvp = nil
+ return true
+ end,
+ }))
+ return true
+ end
+
+ G.FUNCS.attention_text_realtime({
+ text = tostring(MP.GAME.pvp_countdown),
+ scale = 5,
+ hold = 0.85,
+ align = "cm",
+ major = G.play,
+ backdrop_colour = G.C.MULT,
+ })
+
+ play_sound("tarot2", 1, 0.4)
+
+ MP.GAME.pvp_countdown = MP.GAME.pvp_countdown - 1
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ delay = tick_delay,
+ blockable = false,
+ func = show_next,
+ }))
+ return true
+ end
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ timer = "REAL",
+ delay = 0,
+ blockable = false,
+ func = show_next,
+ }))
+end
+
+function G.FUNCS.set_timer_box(e)
+ if MP.LOBBY.config.timer then
+ if MP.GAME.timer_started then
+ e.config.colour = G.C.DYN_UI.BOSS_DARK
+ e.children[1].config.object.colours = { G.C.IMPORTANT }
+ return
+ end
+ if not MP.GAME.timer_started and MP.GAME.ready_blind 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 = { G.C.UI.TEXT_DARK }
+ end
+end
+
+MP.timer_event = Event({
+ blockable = false,
+ blocking = false,
+ pause_force = true,
+ no_delete = true,
+ trigger = "after",
+ delay = 1,
+ timer = "UPTIME",
+ func = function()
+ if not MP.GAME.timer_started then return true end
+ MP.GAME.timer = MP.GAME.timer - 1
+ if MP.GAME.timer <= 0 then
+ MP.GAME.timer = 0
+ if not MP.GAME.ready_blind and not MP.is_pvp_boss() then
+ if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then
+ MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1
+ return true
+ end
+ MP.ACTIONS.fail_timer()
+ end
+ return true
+ end
+ MP.timer_event.start_timer = false
+ end,
+})
diff --git a/ui/lobby/_lobby_options/advanced_tab.lua b/ui/lobby/_lobby_options/advanced_tab.lua
new file mode 100644
index 00000000..626e9f6c
--- /dev/null
+++ b/ui/lobby/_lobby_options/advanced_tab.lua
@@ -0,0 +1,207 @@
+local function send_lobby_options(value)
+ MP.ACTIONS.lobby_options()
+end
+
+function G.FUNCS.custom_seed_overlay(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_custom_seed_overlay(),
+ })
+end
+
+function G.FUNCS.custom_seed_reset(e)
+ MP.LOBBY.config.custom_seed = "random"
+ send_lobby_options()
+end
+
+function toggle_different_seeds()
+ G.FUNCS.lobby_options()
+ send_lobby_options()
+end
+
+function G.FUNCS.display_custom_seed(e)
+ local display = MP.LOBBY.config.custom_seed == "random" and localize("k_random") or MP.LOBBY.config.custom_seed
+ if display ~= e.children[1].config.text then
+ e.children[2].config.text = display
+ e.UIBox:recalculate(true)
+ end
+end
+
+function G.UIDEF.create_UIBox_custom_seed_overlay()
+ return create_UIBox_generic_options({
+ back_func = "lobby_options",
+ contents = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm", colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minw = 0.1 },
+ nodes = {
+ create_text_input({
+ max_length = 8,
+ all_caps = true,
+ ref_table = MP.LOBBY,
+ ref_value = "temp_seed",
+ prompt_text = localize("k_enter_seed"),
+ keyboard_offset = 4,
+ callback = function(val)
+ MP.LOBBY.config.custom_seed = MP.LOBBY.temp_seed
+ send_lobby_options()
+ end,
+ }),
+ {
+ n = G.UIT.B,
+ config = { w = 0.1, h = 0.1 },
+ },
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.3,
+ text = localize("k_enter_to_save"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+end
+
+local function create_custom_seed_section()
+ if MP.LOBBY.config.different_seeds then return { n = G.UIT.B, config = { w = 0.1, h = 0.1 } } end
+
+ return {
+ n = G.UIT.R,
+ config = { padding = 0, align = "cr" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cr",
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ padding = 0,
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.2,
+ align = "cr",
+ func = "display_custom_seed",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.45,
+ text = localize("k_current_seed"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.45,
+ text = MP.LOBBY.config.custom_seed,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.2,
+ align = "cr",
+ },
+ nodes = {
+ MP.UI.Disableable_Button({
+ id = "custom_seed_overlay",
+ button = "custom_seed_overlay",
+ colour = G.C.BLUE,
+ minw = 3.65,
+ minh = 0.6,
+ label = {
+ localize("b_set_custom_seed"),
+ },
+ disabled_text = {
+ localize("b_set_custom_seed"),
+ },
+ scale = 0.45,
+ col = true,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "is_host",
+ }),
+ {
+ n = G.UIT.B,
+ config = {
+ w = 0.1,
+ h = 0.1,
+ },
+ },
+ MP.UI.Disableable_Button({
+ id = "custom_seed_reset",
+ button = "custom_seed_reset",
+ colour = G.C.RED,
+ minw = 1.65,
+ minh = 0.6,
+ label = {
+ localize("b_reset"),
+ },
+ disabled_text = {
+ localize("b_reset"),
+ },
+ scale = 0.45,
+ col = true,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "is_host",
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function MP.UI.create_advanced_options_tab()
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 4,
+ r = 0.1,
+ minw = 10,
+ align = "tm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ create_lobby_option_toggle("preview_disabled_toggle", "b_opts_disable_preview", "preview_disabled"),
+ create_lobby_option_toggle("order_toggle", "b_opts_the_order", "the_order"),
+ MP.LOBBY.config.ruleset == "ruleset_mp_smallworld" and create_lobby_option_toggle(
+ "legacy_smallworld_toggle",
+ "b_opts_legacy_smallworld",
+ "legacy_smallworld"
+ ) or nil,
+ create_lobby_option_toggle(
+ "different_seeds_toggle",
+ "b_opts_diff_seeds",
+ "different_seeds",
+ toggle_different_seeds
+ ),
+ create_custom_seed_section(),
+ },
+ }
+end
diff --git a/ui/lobby/_lobby_options/gameplay_tab.lua b/ui/lobby/_lobby_options/gameplay_tab.lua
new file mode 100644
index 00000000..ec8b215a
--- /dev/null
+++ b/ui/lobby/_lobby_options/gameplay_tab.lua
@@ -0,0 +1,24 @@
+function MP.UI.create_gameplay_options_tab()
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 3,
+ r = 0.1,
+ minw = 10,
+ align = "tm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ create_lobby_option_toggle("gold_on_life_loss_toggle", "b_opts_cb_money", "gold_on_life_loss"),
+ create_lobby_option_toggle(
+ "no_gold_on_round_loss_toggle",
+ "b_opts_no_gold_on_loss",
+ "no_gold_on_round_loss"
+ ),
+ create_lobby_option_toggle("death_on_round_loss_toggle", "b_opts_death_on_loss", "death_on_round_loss"),
+ create_lobby_option_toggle("timer_toggle", "b_opts_timer", "timer"),
+ },
+ }
+end
diff --git a/ui/lobby/_lobby_options/main_options.lua b/ui/lobby/_lobby_options/main_options.lua
new file mode 100644
index 00000000..07b63885
--- /dev/null
+++ b/ui/lobby/_lobby_options/main_options.lua
@@ -0,0 +1,118 @@
+local function create_main_lobby_options_title(info_area_id)
+ local title_colour = mix_colours(G.C.RED, G.C.BLACK, 0.6)
+ local title = "ERROR"
+
+ if info_area_id == "ruleset_area" then
+ title_colour = mix_colours(G.C.BLUE, G.C.BLACK, 0.6)
+ title = localize("k_rulesets") or "ERROR"
+ end
+
+ if info_area_id == "gamemode_area" then
+ title_colour = mix_colours(G.C.ORANGE, G.C.BLACK, 0.6)
+ title = localize("k_gamemodes") or "ERROR"
+ end
+
+ if title == "ERROR" then return nil end
+
+ return MP.UI.UTILS.create_row({ id = "ruleset_name", align = "cm", padding = 0.07 }, {
+ MP.UI.UTILS.create_row({
+ align = "cm",
+ r = 0.1,
+ outline = 1,
+ outline_colour = title_colour,
+ colour = darken(title_colour, 0.3),
+ minw = 2.9,
+ emboss = 0.1,
+ padding = 0.07,
+ line_emboss = 1,
+ }, {
+ MP.UI.UTILS.create_object_node(MP.UI.UTILS.create_dynatext(title, {
+ colours = { G.C.WHITE },
+ float = true,
+ y_offset = -4,
+ scale = 0.45,
+ maxw = 2.8,
+ })),
+ }),
+ })
+end
+
+function MP.UI.Main_Lobby_Options(info_area_id, default_info_area, button_func, buttons_data)
+ local categories = {
+ create_main_lobby_options_title(info_area_id),
+ }
+ for cat_idx, category in ipairs(buttons_data) do
+ local buttons = {}
+ for btn_idx, data in ipairs(category.buttons) do
+ local col = data.button_col or G.C.RED
+ if data.button_id == "weekly_ruleset_button" then -- putting the logic here because whatever
+ if (not MP.LOBBY.config.weekly) or (MP.LOBBY.config.weekly ~= MP.LOBBY.fetched_weekly) then
+ col = G.C.DARK_EDITION
+ end
+ end
+ local button = UIBox_button({
+ id = data.button_id,
+ col = true,
+ chosen = (cat_idx == 1 and btn_idx == 1 and "vert" or false),
+ label = { localize(data.button_localize_key) },
+ button = button_func,
+ colour = col,
+ minw = 4,
+ scale = 0.4,
+ minh = 0.6,
+ })
+ buttons[#buttons + 1] = MP.UI.UTILS.create_row({ align = "cm", padding = 0.05 }, { button })
+ end
+ categories[#categories + 1] = MP.UI.BackgroundGrouping(localize(category.name), buttons)
+ end
+
+ return create_UIBox_generic_options({
+ back_func = "play_options",
+ contents = {
+ { n = G.UIT.C, config = { align = "tm", minh = 8, minw = 4, padding = 0.1 }, nodes = categories },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", minh = 8, maxh = 8, minw = 11 },
+ nodes = {
+ { n = G.UIT.O, config = { id = info_area_id, object = default_info_area } },
+ },
+ },
+ },
+ })
+end
+
+function MP.UI.Change_Main_Lobby_Options(e, info_area_id, info_area_func, default_button_id, update_lobby_config_func)
+ if not G.OVERLAY_MENU then return end
+
+ local info_area = G.OVERLAY_MENU:get_UIE_by_ID(info_area_id)
+ if not info_area then return end
+
+ -- Switch 'chosen' status from the previously-chosen button to this one:
+ if info_area.config.prev_chosen then
+ info_area.config.prev_chosen.config.chosen = nil
+ else -- The previously-chosen button should be the default one here:
+ local default_button = G.OVERLAY_MENU:get_UIE_by_ID(default_button_id)
+ if default_button then default_button.config.chosen = nil end
+ end
+ e.config.chosen = "vert" -- Special setting to show 'chosen' indicator on the side
+
+ local info_obj_name = string.match(e.config.id, "(.+)_%w+_button")
+ update_lobby_config_func(info_obj_name)
+
+ if info_area.config.object then info_area.config.object:remove() end
+ info_area.config.object = UIBox({
+ definition = info_area_func(info_obj_name),
+ config = { align = "cm", parent = info_area },
+ })
+
+ info_area.config.object:recalculate()
+
+ info_area.config.prev_chosen = e
+end
+
+function MP.UI.update_lobby_option_toggle(option_key)
+ if G.OVERLAY_MENU then
+ local config_uie = G.OVERLAY_MENU:get_UIE_by_ID(option_key .. "_toggle")
+ if config_uie then G.FUNCS.toggle(config_uie) end
+ end
+end
diff --git a/ui/lobby/_lobby_options/modifiers_tab.lua b/ui/lobby/_lobby_options/modifiers_tab.lua
new file mode 100644
index 00000000..5722a3ad
--- /dev/null
+++ b/ui/lobby/_lobby_options/modifiers_tab.lua
@@ -0,0 +1,91 @@
+-- Component for gamemode modifiers tab containing option cycles
+G.FUNCS.change_starting_pvp_round = function(args)
+ MP.LOBBY.config.pvp_start_round = args.to_val
+ send_lobby_options()
+end
+
+G.FUNCS.change_timer_base_seconds = function(args)
+ MP.LOBBY.config.timer_base_seconds = tonumber(args.to_val:sub(1, -2))
+ send_lobby_options()
+end
+
+G.FUNCS.change_timer_increment_seconds = function(args)
+ MP.LOBBY.config.timer_increment_seconds = tonumber(args.to_val:sub(1, -2))
+ send_lobby_options()
+end
+
+G.FUNCS.change_showdown_starting_antes = function(args)
+ MP.LOBBY.config.showdown_starting_antes = args.to_val
+ send_lobby_options()
+end
+
+G.FUNCS.change_pvp_countdown_seconds = function(args)
+ MP.LOBBY.config.pvp_countdown_seconds = args.to_val
+ send_lobby_options()
+end
+
+function MP.UI.create_gamemode_modifiers_tab()
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 6,
+ r = 0.1,
+ minw = 10,
+ align = "tm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { padding = 0, align = "cm" },
+ nodes = {
+ create_lobby_option_cycle(
+ "pvp_timer_seconds_option",
+ "k_opts_pvp_timer",
+ 0.85,
+ { "30s", "60s", "90s", "120s", "150s", "180s", "210s", "240s" },
+ MP.LOBBY.config.timer_base_seconds / 30,
+ "change_timer_base_seconds"
+ ),
+ create_lobby_option_cycle(
+ "pvp_timer_increment_seconds_option",
+ "k_opts_pvp_timer_increment",
+ 0.85,
+ { "0s", "30s", "60s", "90s", "120s", "150s", "180s" },
+ MP.UTILS.get_array_index_by_value(
+ { 0, 30, 60, 90, 120, 150, 180 },
+ MP.LOBBY.config.timer_increment_seconds
+ ),
+ "change_timer_increment_seconds"
+ ),
+ create_lobby_option_cycle(
+ "pvp_round_start_option",
+ "k_opts_pvp_start_round",
+ 0.85,
+ { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 },
+ MP.LOBBY.config.pvp_start_round,
+ "change_starting_pvp_round"
+ ),
+ create_lobby_option_cycle(
+ "showdown_starting_antes_option",
+ "k_opts_showdown_starting_antes",
+ 0.85,
+ { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 },
+ MP.LOBBY.config.showdown_starting_antes,
+ "change_showdown_starting_antes"
+ ),
+ create_lobby_option_cycle(
+ "pvp_countdown_seconds_option",
+ "k_opts_pvp_countdown_seconds",
+ 0.85,
+ { 0, 3, 5, 10 },
+ MP.UTILS.get_array_index_by_value({ 0, 3, 5, 10 }, MP.LOBBY.config.pvp_countdown_seconds),
+ "change_pvp_countdown_seconds"
+ ),
+ },
+ },
+ },
+ }
+end
diff --git a/ui/lobby/_lobby_options/options_tab.lua b/ui/lobby/_lobby_options/options_tab.lua
new file mode 100644
index 00000000..95559c40
--- /dev/null
+++ b/ui/lobby/_lobby_options/options_tab.lua
@@ -0,0 +1,81 @@
+local Disableable_Toggle = MP.UI.Disableable_Toggle
+
+-- TODO repetition but w/e...
+function send_lobby_options(value)
+ MP.ACTIONS.lobby_options()
+end
+
+function create_lobby_option_cycle(id, label_key, scale, options, current_option, callback)
+ return MP.UI.Disableable_Option_Cycle({
+ id = id,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "is_host",
+ label = localize(label_key),
+ scale = scale,
+ options = options,
+ current_option = current_option,
+ opt_callback = callback,
+ })
+end
+
+-- Component for lobby options tab containing toggles and custom seed section
+function create_lobby_option_toggle(id, label_key, ref_value, callback)
+ return {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cr",
+ },
+ nodes = {
+ Disableable_Toggle({
+ id = id,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "is_host",
+ label = localize(label_key),
+ ref_table = MP.LOBBY.config,
+ ref_value = ref_value,
+ callback = callback or send_lobby_options,
+ }),
+ },
+ }
+end
+
+G.FUNCS.change_starting_lives = function(args)
+ MP.LOBBY.config.starting_lives = args.to_val
+ send_lobby_options()
+end
+
+-- This needs to have a parameter because its a callback for inputs
+local function send_lobby_options(value)
+ MP.ACTIONS.lobby_options()
+end
+
+-- Creates the lobby options tab UI containing toggles for various multiplayer settings
+-- Returns a UI table with lobby configuration options like gold on life loss, different seeds, etc.
+function MP.UI.create_lobby_options_tab()
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 4,
+ r = 0.1,
+ minw = 10,
+ align = "tm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ create_lobby_option_cycle(
+ "starting_lives_option",
+ "b_opts_lives",
+ 0.85,
+ { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 },
+ MP.LOBBY.config.starting_lives,
+ "change_starting_lives"
+ ),
+ create_lobby_option_toggle("multiplayer_jokers_toggle", "b_opts_multiplayer_jokers", "multiplayer_jokers"),
+ create_lobby_option_toggle("different_decks_toggle", "b_opts_player_diff_deck", "different_decks"),
+ create_lobby_option_toggle("normal_bosses_toggle", "b_opts_normal_bosses", "normal_bosses"),
+ },
+ }
+end
diff --git a/ui/lobby/deck_stake_button.lua b/ui/lobby/deck_stake_button.lua
new file mode 100644
index 00000000..340b8323
--- /dev/null
+++ b/ui/lobby/deck_stake_button.lua
@@ -0,0 +1,45 @@
+local Disableable_Button = MP.UI.Disableable_Button
+
+-- Component for deck selection button in lobby
+function MP.UI.create_lobby_deck_button(text_scale, back, stake)
+ local deck_labels = {
+ localize({
+ type = "name_text",
+ key = MP.UTILS.get_deck_key_from_name(back),
+ set = "Back",
+ }),
+ localize({
+ type = "name_text",
+ key = SMODS.stake_from_index(type(stake) == "string" and tonumber(stake) or stake),
+ set = "Stake",
+ }),
+ }
+
+ if MP.LOBBY.is_host then
+ return Disableable_Button({
+ id = "lobby_choose_deck",
+ button = "lobby_choose_deck",
+ colour = G.C.PURPLE,
+ minw = 2.15,
+ minh = 1.35,
+ label = deck_labels,
+ scale = text_scale * 1.2,
+ col = true,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "is_host",
+ })
+ else
+ return Disableable_Button({
+ id = "lobby_choose_deck",
+ button = "lobby_choose_deck",
+ colour = G.C.PURPLE,
+ minw = 2.15,
+ minh = 1.35,
+ label = deck_labels,
+ scale = text_scale * 1.2,
+ col = true,
+ enabled_ref_table = MP.LOBBY.config,
+ enabled_ref_value = "different_decks",
+ })
+ end
+end
diff --git a/ui/lobby/leave_button.lua b/ui/lobby/leave_button.lua
new file mode 100644
index 00000000..a9754895
--- /dev/null
+++ b/ui/lobby/leave_button.lua
@@ -0,0 +1,13 @@
+-- Component for leave button in lobby
+function MP.UI.create_lobby_leave_button(text_scale)
+ return UIBox_button({
+ id = "lobby_menu_leave",
+ button = "lobby_leave",
+ colour = G.C.RED,
+ minw = 3.65,
+ minh = 1.55,
+ label = { localize("b_leave") },
+ scale = text_scale * 1.5,
+ col = true,
+ })
+end
diff --git a/ui/lobby/lobby.lua b/ui/lobby/lobby.lua
new file mode 100644
index 00000000..8f899aa9
--- /dev/null
+++ b/ui/lobby/lobby.lua
@@ -0,0 +1,522 @@
+-- This needs to have a parameter because its a callback for inputs
+local function send_lobby_options(value)
+ MP.ACTIONS.lobby_options()
+end
+
+G.HUD_connection_status = nil
+
+function G.UIDEF.get_connection_status_ui()
+ return UIBox({
+ definition = {
+ n = G.UIT.ROOT,
+ config = {
+ align = "cm",
+ colour = G.C.UI.TRANSPARENT_DARK,
+ },
+ nodes = {
+ MP.UI.UTILS.create_text_node(
+ (MP.LOBBY.code and localize("k_in_lobby"))
+ or (MP.LOBBY.connected and localize("k_connected"))
+ or localize("k_warn_service"),
+ {
+ scale = 0.3,
+ colour = G.C.UI.TEXT_LIGHT,
+ }
+ ),
+ },
+ },
+ config = {
+ align = "tri",
+ bond = "Weak",
+ offset = {
+ x = 0,
+ y = 0.9,
+ },
+ major = G.ROOM_ATTACH,
+ },
+ })
+end
+
+function G.UIDEF.create_UIBox_lobby_menu()
+ local text_scale = 0.45
+ local back = MP.LOBBY.config.different_decks and MP.LOBBY.deck.back or MP.LOBBY.config.back
+ local stake = MP.LOBBY.config.different_decks and MP.LOBBY.deck.stake or MP.LOBBY.config.stake
+
+ local t = {
+ n = G.UIT.ROOT,
+ config = {
+ align = "cm",
+ colour = G.C.CLEAR,
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = {
+ align = "bm",
+ },
+ nodes = {
+ MP.UI.lobby_status_display(),
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.2,
+ r = 0.1,
+ emboss = 0.1,
+ colour = G.C.L_BLACK,
+ mid = true,
+ },
+ nodes = {
+ MP.UI.create_lobby_main_button(text_scale),
+ {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ not MP.LOBBY.config.forced_config and UIBox_button({
+ button = "lobby_options",
+ colour = G.C.ORANGE,
+ minw = 3.15,
+ minh = 1.35,
+ label = {
+ localize("b_lobby_options"),
+ },
+ scale = text_scale * 1.2,
+ col = true,
+ }) or nil,
+ MP.UI.create_spacer(),
+ MP.UI.create_lobby_deck_button(text_scale, back, stake),
+ MP.UI.create_spacer(),
+ MP.UI.create_players_section(text_scale),
+ MP.UI.create_spacer(),
+ MP.UI.create_lobby_code_buttons(text_scale),
+ },
+ },
+ MP.UI.create_lobby_leave_button(text_scale),
+ },
+ },
+ },
+ },
+ },
+ }
+ return t
+end
+
+function G.UIDEF.create_UIBox_lobby_options()
+ return create_UIBox_generic_options({
+ contents = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ },
+ nodes = {
+ not MP.LOBBY.is_host and MP.UI.UTILS.create_row({ align = "cm", padding = 0.3 }, {
+ MP.UI.UTILS.create_text_node(localize("k_opts_only_host"), {
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }) or nil,
+ create_tabs({
+ snap_to_nav = true,
+ colour = G.C.BOOSTER,
+ tabs = {
+ {
+ label = localize("k_lobby_general"),
+ chosen = true,
+ tab_definition_function = function()
+ return MP.UI.create_lobby_options_tab()
+ end,
+ },
+ {
+ label = localize("k_lobby_gameplay"),
+ tab_definition_function = function()
+ return MP.UI.create_gameplay_options_tab()
+ end,
+ },
+ {
+ label = localize("k_lobby_modifiers"),
+ tab_definition_function = function()
+ return MP.UI.create_gamemode_modifiers_tab()
+ end,
+ },
+ {
+ label = localize("k_lobby_advanced"),
+ tab_definition_function = function()
+ return MP.UI.create_advanced_options_tab()
+ end,
+ },
+ },
+ }),
+ },
+ },
+ },
+ })
+end
+
+function G.UIDEF.create_UIBox_custom_seed_overlay()
+ return create_UIBox_generic_options({
+ back_func = "lobby_options",
+ contents = {
+ MP.UI.UTILS.create_row({ align = "cm", colour = G.C.CLEAR }, {
+ MP.UI.UTILS.create_column({ align = "cm", minw = 0.1 }, {
+ create_text_input({
+ max_length = 8,
+ all_caps = true,
+ ref_table = MP.LOBBY,
+ ref_value = "temp_seed",
+ prompt_text = localize("k_enter_seed"),
+ keyboard_offset = 4,
+ callback = function(val)
+ MP.LOBBY.config.custom_seed = MP.LOBBY.temp_seed
+ send_lobby_options()
+ end,
+ }),
+ MP.UI.UTILS.create_blank(0.1, 0.1),
+ MP.UI.UTILS.create_text_node(localize("k_enter_to_save"), {
+ scale = 0.3,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ }),
+ },
+ })
+end
+
+function G.UIDEF.create_UIBox_view_hash(type)
+ return (
+ create_UIBox_generic_options({
+ contents = {
+ MP.UI.UTILS.create_column(
+ { padding = 0.07, align = "cm" },
+ MP.UI.modlist_to_view(
+ type == "host" and MP.LOBBY.host.config.Mods or MP.LOBBY.guest.config.Mods,
+ G.C.UI.TEXT_LIGHT
+ )
+ ),
+ },
+ })
+ )
+end
+
+function MP.UI.modlist_to_view(mods, text_colour)
+ local t = {}
+
+ if not mods then return t end
+
+ for mod_name, mod_version in pairs(mods) do
+ local display_text = mod_version and (mod_name .. "-" .. mod_version) or mod_name
+ local color = MP.BANNED_MODS[mod_name] and G.C.RED or text_colour
+ table.insert(t, {
+ n = G.UIT.R,
+ config = {
+ padding = 0.02,
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = display_text,
+ shadow = true,
+ scale = 0.4,
+ colour = color,
+ },
+ },
+ },
+ })
+ end
+ return t
+end
+
+G.FUNCS.view_host_hash = function(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_view_hash("host"),
+ })
+end
+
+G.FUNCS.view_guest_hash = function(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_view_hash("guest"),
+ })
+end
+
+function G.FUNCS.get_lobby_main_menu_UI(e)
+ return UIBox({
+ definition = G.UIDEF.create_UIBox_lobby_menu(),
+ config = {
+ align = "bmi",
+ offset = {
+ x = 0,
+ y = 10,
+ },
+ major = G.ROOM_ATTACH,
+ bond = "Weak",
+ },
+ })
+end
+
+---@type fun(e: table | nil, args: { deck: string, stake: number | nil, seed: string | nil })
+function G.FUNCS.lobby_start_run(e, args)
+ if MP.LOBBY.config.different_decks == false then G.FUNCS.copy_host_deck() end
+
+ local challenge = nil
+ if MP.LOBBY.deck.back == "Challenge Deck" then
+ challenge = G.CHALLENGES[get_challenge_int_from_id(MP.LOBBY.deck.challenge)]
+ else
+ G.GAME.viewed_back = G.P_CENTERS[MP.UTILS.get_deck_key_from_name(MP.LOBBY.deck.back)]
+ end
+
+ G.FUNCS.start_run(e, {
+ mp_start = true,
+ challenge = challenge,
+ stake = tonumber(MP.LOBBY.deck.stake),
+ seed = args.seed,
+ })
+end
+
+local back_generate_ui_ref = Back.generate_UI
+function Back:generate_UI(other, ui_scale, min_dims, challenge)
+ local name = other and other.name or self.name
+ if not challenge and name == "Challenge Deck" and MP.LOBBY.code then
+ challenge = MP.LOBBY.deck.challenge -- very generous assumption
+ local ret = back_generate_ui_ref(self, other, ui_scale, min_dims, challenge)
+
+ -- essentially the button opens the correct challenge menu
+ -- exiting this challenge menu results in a crash that's difficult to figure out
+ -- (some sort of jank when removing the ui elements)
+ -- hacky fallback to ensure that doesn't happen, but ideally one day this gets fixed
+
+ ret.nodes[1].nodes[1].config.button = "exit_overlay_menu"
+
+ return ret
+ end
+ return back_generate_ui_ref(self, other, ui_scale, min_dims, challenge)
+end
+
+function G.FUNCS.copy_host_deck()
+ MP.LOBBY.deck.back = MP.LOBBY.config.back
+ MP.LOBBY.deck.cocktail = MP.LOBBY.config.cocktail
+ MP.LOBBY.deck.sleeve = MP.LOBBY.config.sleeve
+ MP.LOBBY.deck.stake = MP.LOBBY.config.stake
+ MP.LOBBY.deck.challenge = MP.LOBBY.config.challenge
+end
+
+function G.FUNCS.lobby_start_game(e)
+ MP.ACTIONS.start_game()
+end
+
+function G.FUNCS.lobby_ready_up(e)
+ MP.LOBBY.ready_to_start = not MP.LOBBY.ready_to_start
+
+ e.config.colour = MP.LOBBY.ready_to_start and G.C.GREEN or G.C.RED
+ e.children[1].children[1].config.text = MP.LOBBY.ready_to_start and localize("b_unready") or localize("b_ready")
+ e.UIBox:recalculate()
+
+ if MP.LOBBY.ready_to_start then
+ MP.ACTIONS.ready_lobby()
+ else
+ MP.ACTIONS.unready_lobby()
+ end
+end
+
+function G.FUNCS.lobby_options(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_lobby_options(),
+ })
+end
+
+function G.FUNCS.view_code(e)
+ local text_config = e.children[1].children[1].config
+ if text_config.text ~= MP.LOBBY.code then
+ e.config.colour = G.C.ETERNAL
+ text_config.text = MP.LOBBY.code
+ else
+ e.config.colour = G.C.GREEN
+ text_config.text = localize("b_view_code")
+ end
+ e.UIBox:recalculate()
+end
+
+function G.FUNCS.lobby_leave(e)
+ if G.STAGE ~= G.STAGES.MAIN_MENU then
+ G.FUNCS.confirm_selection(function()
+ MP.LOBBY.code = nil
+ MP.ACTIONS.leave_lobby()
+ MP.UI.update_connection_status()
+ G.STATE = G.STATES.MENU
+ end)
+ else
+ MP.LOBBY.code = nil
+ MP.ACTIONS.leave_lobby()
+ MP.UI.update_connection_status()
+ G.STATE = G.STATES.MENU
+ end
+end
+
+function G.FUNCS.lobby_choose_deck(e)
+ G.FUNCS.setup_run(e)
+ if G.OVERLAY_MENU then G.OVERLAY_MENU:get_UIE_by_ID("run_setup_seed"):remove() end
+end
+
+local start_run_ref = G.FUNCS.start_run
+G.FUNCS.start_run = function(e, args)
+ if MP.LOBBY.code then
+ if not args.mp_start then
+ G.FUNCS.exit_overlay_menu()
+ local chosen_stake = args.stake
+ if MP.DECK.MAX_STAKE > 0 and chosen_stake > MP.DECK.MAX_STAKE then
+ MP.UI.UTILS.overlay_message(
+ "Selected stake is incompatible with Multiplayer, stake set to "
+ .. SMODS.stake_from_index(MP.DECK.MAX_STAKE)
+ )
+ chosen_stake = MP.DECK.MAX_STAKE
+ end
+ if MP.LOBBY.is_host then
+ MP.LOBBY.config.back = args.challenge and "Challenge Deck"
+ or (args.deck and args.deck.name)
+ or G.GAME.viewed_back.name
+ MP.LOBBY.config.stake = chosen_stake
+ MP.LOBBY.config.sleeve = G.viewed_sleeve
+ MP.LOBBY.config.challenge = args.challenge and args.challenge.id or ""
+ send_lobby_options()
+ end
+ MP.LOBBY.deck.back = args.challenge and "Challenge Deck"
+ or (args.deck and args.deck.name)
+ or G.GAME.viewed_back.name
+ MP.LOBBY.deck.stake = chosen_stake
+ MP.LOBBY.deck.sleeve = G.viewed_sleeve
+ MP.LOBBY.deck.challenge = args.challenge and args.challenge.id or ""
+ MP.ACTIONS.update_player_usernames()
+ else
+ start_run_ref(e, {
+ challenge = args.challenge,
+ stake = tonumber(MP.LOBBY.deck.stake),
+ seed = args.seed,
+ })
+ end
+ else
+ start_run_ref(e, args)
+ end
+end
+
+function G.FUNCS.display_lobby_main_menu_UI(e)
+ G.MAIN_MENU_UI = G.FUNCS.get_lobby_main_menu_UI(e)
+ G.MAIN_MENU_UI.alignment.offset.y = 0
+ G.MAIN_MENU_UI:align_to_major()
+
+ G.CONTROLLER:snap_to({ node = G.MAIN_MENU_UI:get_UIE_by_ID("lobby_menu_start") })
+end
+
+function G.FUNCS.mp_return_to_lobby()
+ G.FUNCS.confirm_selection(function()
+ MP.ACTIONS.stop_game()
+ end)
+end
+
+function G.FUNCS.custom_seed_overlay(e)
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_custom_seed_overlay(),
+ })
+end
+
+function G.FUNCS.custom_seed_reset(e)
+ MP.LOBBY.config.custom_seed = "random"
+ send_lobby_options()
+end
+
+local set_main_menu_UI_ref = set_main_menu_UI
+---@diagnostic disable-next-line: lowercase-global
+function set_main_menu_UI()
+ if MP.LOBBY.code then
+ if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end
+ if G.STAGE == G.STAGES.MAIN_MENU then G.FUNCS.display_lobby_main_menu_UI() end
+ else
+ set_main_menu_UI_ref()
+ end
+end
+
+local in_lobby = false
+local gameUpdateRef = Game.update
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:update(dt)
+ -- Track lobby state transitions
+ if (MP.LOBBY.code and not in_lobby) or (not MP.LOBBY.code and in_lobby) then
+ in_lobby = not in_lobby
+ G.F_NO_SAVING = in_lobby
+ if true then -- G.STATE == G.STATES.MENU, revert if something breaks, but this causes disconnects to not exit the game
+ self.FUNCS.go_to_menu()
+ MP.reset_game_states()
+ end
+ end
+ gameUpdateRef(self, dt)
+end
+
+function G.UIDEF.create_UIBox_unstuck()
+ return (
+ create_UIBox_generic_options({
+ back_func = "options",
+ contents = {
+ {
+ n = G.UIT.C,
+ config = {
+ padding = 0.2,
+ align = "cm",
+ },
+ nodes = {
+ UIBox_button({ label = { localize("b_unstuck_blind") }, button = "mp_unstuck_blind", minw = 5 }),
+ },
+ },
+ },
+ })
+ )
+end
+
+function G.FUNCS.mp_unstuck()
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_unstuck(),
+ })
+end
+
+function G.FUNCS.mp_unstuck_arcana()
+ G.FUNCS.skip_booster()
+end
+
+function G.FUNCS.mp_unstuck_blind()
+ MP.GAME.ready_blind = false
+ if MP.GAME.next_blind_context then
+ G.FUNCS.select_blind(MP.GAME.next_blind_context)
+ else
+ sendErrorMessage("No next blind context", "MULTIPLAYER")
+ end
+end
+
+function MP.UI.update_connection_status()
+ if G.HUD_connection_status then G.HUD_connection_status:remove() end
+ if G.STAGE == G.STAGES.MAIN_MENU then G.HUD_connection_status = G.UIDEF.get_connection_status_ui() end
+end
+
+local gameMainMenuRef = Game.main_menu
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:main_menu(change_context)
+ gameMainMenuRef(self, change_context)
+ MP.UI.update_connection_status()
+end
+
+function G.FUNCS.copy_to_clipboard(e)
+ MP.UTILS.copy_to_clipboard(MP.LOBBY.code)
+end
+
+function G.FUNCS.reconnect(e)
+ MP.ACTIONS.connect()
+ G.FUNCS.exit_overlay_menu()
+end
+
+function MP.update_player_usernames()
+ if MP.LOBBY.code then
+ if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end
+ if G.STAGE == G.STAGES.MAIN_MENU then G.FUNCS.display_lobby_main_menu_UI() end
+ end
+end
diff --git a/ui/lobby/lobby_code_buttons.lua b/ui/lobby/lobby_code_buttons.lua
new file mode 100644
index 00000000..2c17b9df
--- /dev/null
+++ b/ui/lobby/lobby_code_buttons.lua
@@ -0,0 +1,28 @@
+-- Component for view/copy code buttons in lobby
+function MP.UI.create_lobby_code_buttons(text_scale)
+ return {
+ n = G.UIT.C,
+ config = {
+ align = "cm",
+ },
+ nodes = {
+ UIBox_button({
+ button = "view_code",
+ colour = G.C.PALE_GREEN,
+ minw = 2.15,
+ minh = 0.65,
+ label = { localize("b_view_code") },
+ scale = text_scale * 1.2,
+ }),
+ MP.UI.create_spacer(0.1, true),
+ UIBox_button({
+ button = "copy_to_clipboard",
+ colour = G.C.PERISHABLE,
+ minw = 2.15,
+ minh = 0.65,
+ label = { localize("b_copy_code") },
+ scale = text_scale,
+ }),
+ },
+ }
+end
diff --git a/ui/lobby/players_display.lua b/ui/lobby/players_display.lua
new file mode 100644
index 00000000..dda85de2
--- /dev/null
+++ b/ui/lobby/players_display.lua
@@ -0,0 +1,37 @@
+local function create_player_info_row(player, player_type, text_scale)
+ if not player or not player.username then return nil end
+
+ return MP.UI.UTILS.create_row({ padding = 0.1, align = "cm" }, {
+ MP.UI.UTILS.create_text_node(nil, {
+ ref_table = player,
+ ref_value = "username",
+ scale = text_scale * 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ MP.UI.UTILS.create_blank(0.1, 0.1),
+ player.hash and UIBox_button({
+ id = player_type .. "_hash",
+ button = "view_" .. player_type .. "_hash",
+ label = { player.hash },
+ minw = 0.75,
+ minh = 0.3,
+ scale = 0.25,
+ shadow = false,
+ colour = G.C.PURPLE,
+ col = true,
+ }) or nil,
+ })
+end
+
+function MP.UI.create_players_section(text_scale)
+ return MP.UI.UTILS.create_column({ align = "tm", minw = 2.65 }, {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.15 }, {
+ MP.UI.UTILS.create_text_node(localize("k_connect_player"), {
+ scale = text_scale * 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ create_player_info_row(MP.LOBBY.host, "host", text_scale),
+ create_player_info_row(MP.LOBBY.guest, "guest", text_scale),
+ })
+end
diff --git a/ui/lobby/start_ready_button.lua b/ui/lobby/start_ready_button.lua
new file mode 100644
index 00000000..79c3b361
--- /dev/null
+++ b/ui/lobby/start_ready_button.lua
@@ -0,0 +1,151 @@
+local Disableable_Button = MP.UI.Disableable_Button
+
+local function get_warnings()
+ local warnings = {}
+
+ -- Check the other player (guest if we're host, host if we're guest)
+ local other_player = MP.LOBBY.is_host and MP.LOBBY.guest or MP.LOBBY.host
+
+ if other_player and other_player.cached == false then
+ table.insert(warnings, { localize("k_warning_cheating1"), SMODS.Gradients.warning_text, 0.4 })
+ table.insert(
+ warnings,
+ { string.format(localize("k_warning_cheating2"), MP.UTILS.random_message()), SMODS.Gradients.warning_text }
+ )
+ end
+
+ if other_player and other_player.config and other_player.config.unlocked == false then
+ table.insert(warnings, {
+ localize("k_warning_nemesis_unlock"),
+ SMODS.Gradients.warning_text,
+ 0.25,
+ })
+ end
+
+ local bothPlayersInLobby = MP.LOBBY.guest and MP.LOBBY.guest.config ~= nil
+
+ -- Content mod compatibility warnings (currently Extra Credit only)
+ if bothPlayersInLobby then
+ local hostExtraCreditVersion = MP.LOBBY.host
+ and MP.LOBBY.host.config
+ and MP.LOBBY.host.config.Mods["extracredit"]
+ local guestExtraCreditVersion = MP.LOBBY.guest
+ and MP.LOBBY.guest.config
+ and MP.LOBBY.guest.config.Mods["extracredit"]
+
+ if hostExtraCreditVersion ~= guestExtraCreditVersion then
+ table.insert(warnings, {
+ "Extra Credit mismatch - players may see different jokers",
+ SMODS.Gradients.warning_text,
+ })
+ elseif hostExtraCreditVersion ~= nil and hostExtraCreditVersion == guestExtraCreditVersion then
+ table.insert(warnings, {
+ "Extra Credit active - curated jokers replaced with full pool",
+ G.C.GREEN,
+ 0.25,
+ })
+ end
+ end
+
+ if MP.LOBBY.ready_to_start or not MP.LOBBY.is_host then
+ local hostSteamoddedVersion = MP.LOBBY.host and MP.LOBBY.host.config and MP.LOBBY.host.config.Mods["Steamodded"]
+ local guestSteamoddedVersion = MP.LOBBY.guest
+ and MP.LOBBY.guest.config
+ and MP.LOBBY.guest.config.Mods["Steamodded"]
+
+ if hostSteamoddedVersion ~= guestSteamoddedVersion then
+ table.insert(warnings, {
+ localize("k_steamodded_warning"),
+ SMODS.Gradients.warning_text,
+ })
+ end
+ end
+
+ local host_banned_mods = MP.LOBBY.host
+ and MP.LOBBY.host.config
+ and MP.UTILS.get_banned_mods(MP.LOBBY.host.config.Mods)
+ or {}
+ local guest_banned_mods = MP.LOBBY.guest
+ and MP.LOBBY.guest.config
+ and MP.UTILS.get_banned_mods(MP.LOBBY.guest.config.Mods)
+ or {}
+
+ if #host_banned_mods > 0 or #guest_banned_mods > 0 then
+ table.insert(warnings, {
+ localize("k_warning_banned_mods"),
+ G.C.RED,
+ 0.4,
+ })
+ end
+
+ SMODS.Mods["Multiplayer"].config.unlocked = MP.UTILS.unlock_check()
+ if not SMODS.Mods["Multiplayer"].config.unlocked then
+ table.insert(warnings, {
+ localize("k_warning_unlock_profile"),
+ SMODS.Gradients.warning_text,
+ 0.25,
+ })
+ end
+
+ if MP.LOBBY.username == "Guest" then table.insert(warnings, {
+ localize("k_set_name"),
+ G.C.UI.TEXT_LIGHT,
+ }) end
+
+ if #warnings == 0 then table.insert(warnings, {
+ " ",
+ G.C.UI.TEXT_LIGHT,
+ }) end
+
+ return warnings
+end
+
+function MP.UI.lobby_status_display()
+ local warnings = get_warnings()
+
+ local warning_texts = {}
+ for k, v in pairs(warnings) do
+ table.insert(
+ warning_texts,
+ MP.UI.UTILS.create_row({ align = "cm", padding = -0.25 }, {
+ MP.UI.UTILS.create_text_node(v[1], {
+ colour = v[2],
+ scale = v[3] or 0.25,
+ }),
+ })
+ )
+ end
+
+ return MP.UI.UTILS.create_row({ padding = 0.35, align = "cm" }, warning_texts)
+end
+
+-- Component for main start/ready button in lobby
+function MP.UI.create_lobby_main_button(text_scale)
+ if MP.LOBBY.is_host then
+ return Disableable_Button({
+ id = "lobby_menu_start",
+ button = "lobby_start_game",
+ colour = G.C.BLUE,
+ minw = 3.65,
+ minh = 1.55,
+ label = { localize("b_start") },
+ disabled_text = MP.LOBBY.guest.username and localize("b_wait_for_guest_ready")
+ or localize("b_wait_for_players"),
+ scale = text_scale * 2,
+ col = true,
+ enabled_ref_table = MP.LOBBY,
+ enabled_ref_value = "ready_to_start",
+ })
+ else
+ return UIBox_button({
+ id = "lobby_menu_start",
+ button = "lobby_ready_up",
+ colour = MP.LOBBY.ready_to_start and G.C.GREEN or G.C.RED,
+ minw = 3.65,
+ minh = 1.55,
+ label = { MP.LOBBY.ready_to_start and localize("b_unready") or localize("b_ready") },
+ scale = text_scale * 2,
+ col = true,
+ })
+ end
+end
diff --git a/ui/main_menu/main_menu.lua b/ui/main_menu/main_menu.lua
new file mode 100644
index 00000000..f44017bf
--- /dev/null
+++ b/ui/main_menu/main_menu.lua
@@ -0,0 +1,109 @@
+local game_main_menu_ref = Game.main_menu
+---@diagnostic disable-next-line: duplicate-set-field
+function Game:main_menu(change_context)
+ local ret = game_main_menu_ref(self, change_context)
+
+ Add_custom_multiplayer_cards(change_context)
+ Add_version_display()
+
+ return ret
+end
+
+-- Modify play button to take you to mode select first
+local create_UIBox_main_menu_buttonsRef = create_UIBox_main_menu_buttons
+---@diagnostic disable-next-line: lowercase-global
+function create_UIBox_main_menu_buttons()
+ local menu = create_UIBox_main_menu_buttonsRef()
+ menu.nodes[1].nodes[1].nodes[1].nodes[1].config.button = "play_options"
+ return menu
+end
+
+G.FUNCS.wipe_off = function()
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ func = function()
+ delay(0.3)
+ if not G.screenwipe then return true end
+ G.screenwipe.children.particles.max = 0
+ G.E_MANAGER:add_event(Event({
+ trigger = "ease",
+ no_delete = true,
+ blockable = false,
+ blocking = false,
+ timer = "REAL",
+ ref_table = G.screenwipe.colours.black,
+ ref_value = 4,
+ ease_to = 0,
+ delay = 0.3,
+ func = function(t)
+ return t
+ end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ trigger = "ease",
+ no_delete = true,
+ blockable = false,
+ blocking = false,
+ timer = "REAL",
+ ref_table = G.screenwipe.colours.white,
+ ref_value = 4,
+ ease_to = 0,
+ delay = 0.3,
+ func = function(t)
+ return t
+ end,
+ }))
+ return true
+ end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.55,
+ no_delete = true,
+ blocking = false,
+ timer = "REAL",
+ func = function()
+ if not G.screenwipe then return true end
+ if G.screenwipecard then G.screenwipecard:start_dissolve({ G.C.BLACK, G.C.ORANGE, G.C.GOLD, G.C.RED }) end
+ if G.screenwipe:get_UIE_by_ID("text") then
+ for k, v in ipairs(G.screenwipe:get_UIE_by_ID("text").children) do
+ v.children[1].config.object:pop_out(4)
+ end
+ end
+ return true
+ end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 1.1,
+ no_delete = true,
+ blocking = false,
+ timer = "REAL",
+ func = function()
+ if not G.screenwipe then return true end
+ G.screenwipe.children.particles:remove()
+ G.screenwipe:remove()
+ G.screenwipe.children.particles = nil
+ G.screenwipe = nil
+ G.screenwipecard = nil
+ return true
+ end,
+ }))
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 1.2,
+ no_delete = true,
+ blocking = true,
+ timer = "REAL",
+ func = function()
+ return true
+ end,
+ }))
+end
+
+function MP.UI.update_player_usernames()
+ if MP.LOBBY.code then
+ if G.MAIN_MENU_UI then G.MAIN_MENU_UI:remove() end
+ set_main_menu_UI()
+ end
+end
diff --git a/ui/main_menu/play_button/gamemode_selection.lua b/ui/main_menu/play_button/gamemode_selection.lua
new file mode 100644
index 00000000..502a265f
--- /dev/null
+++ b/ui/main_menu/play_button/gamemode_selection.lua
@@ -0,0 +1,171 @@
+function G.UIDEF.gamemode_selection_options()
+ MP.LOBBY.config.gamemode = "gamemode_mp_attrition"
+
+ local default_gamemode_area = UIBox({
+ definition = G.UIDEF.gamemode_info("attrition"),
+ config = { align = "cm" },
+ })
+
+ local gamemode_buttons_data = {
+ {
+ name = "k_battle",
+ buttons = {
+ { button_id = "attrition_gamemode_button", button_localize_key = "k_attrition" },
+ { button_id = "showdown_gamemode_button", button_localize_key = "k_showdown" },
+ },
+ },
+ {
+ name = "k_challenge",
+ buttons = {
+ { button_id = "survival_gamemode_button", button_localize_key = "k_survival" },
+ },
+ },
+ }
+
+ return MP.UI.Main_Lobby_Options(
+ "gamemode_area",
+ default_gamemode_area,
+ "change_gamemode_selection",
+ gamemode_buttons_data
+ )
+end
+
+function G.FUNCS.change_gamemode_selection(e)
+ MP.UI.Change_Main_Lobby_Options(
+ e,
+ "gamemode_area",
+ G.UIDEF.gamemode_info,
+ "attrition_gamemode_button",
+ function(gamemode_name)
+ MP.LOBBY.config.gamemode = "gamemode_mp_" .. gamemode_name
+ end
+ )
+end
+
+function G.UIDEF.gamemode_info(gamemode_name)
+ local gamemode = MP.Gamemodes["gamemode_mp_" .. gamemode_name]
+
+ local gamemode_info_tabs = UIBox({
+ definition = G.UIDEF.gamemode_tabs(gamemode),
+ config = { align = "cm" },
+ })
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "tm", minh = 8, maxh = 8, minw = 11, maxw = 11, colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = gamemode_info_tabs } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ id = "start_lobby_button",
+ button = "start_lobby",
+ align = "cm",
+ padding = 0.05,
+ r = 0.1,
+ minw = 8,
+ minh = 0.8,
+ colour = G.C.BLUE,
+ hover = true,
+ shadow = true,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("b_create_lobby"),
+ scale = 0.5,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function G.UIDEF.gamemode_tabs(gamemode)
+ local default_tabs = UIBox({
+ definition = G.UIDEF.lobby_setup_tabs_definition(gamemode, "info", 1, false),
+ config = { align = "cm", tab_type = "info", chosen_tab = 1 },
+ })
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", colour = G.C.L_BLACK, r = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "tm", colour = G.C.GREY, r = 0.1 },
+ nodes = {
+ { n = G.UIT.O, config = { object = default_tabs } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "bm", padding = 0.05 },
+ nodes = {
+ create_option_cycle({
+ options = { localize("k_info"), localize("k_bans"), localize("k_reworks") },
+ current_option = 1,
+ opt_callback = "gamemode_switch_tabs",
+ opt_args = { ui = default_tabs, gamemode = gamemode },
+ w = 5,
+ colour = G.C.ORANGE,
+ cycle_shoulders = false,
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function G.FUNCS.gamemode_switch_tabs(args)
+ if not args or not args.cycle_config then return end
+ local callback_args = args.cycle_config.opt_args
+
+ local tabs_object = callback_args.ui
+ local tabs_wrap = tabs_object.parent
+
+ local active_tab = tabs_wrap.UIBox:get_UIE_by_ID("gamemode_active_tab")
+ local active_tab_idx = active_tab and active_tab.config.tab_idx or 1
+
+ local tab_type = (args.to_key == 2 and "banned") or (args.to_key == 3 and "rework") or "info"
+ local def = G.UIDEF.lobby_setup_tabs_definition(callback_args.gamemode, tab_type, active_tab_idx, false)
+
+ tabs_object.config.tab_type = tab_type
+ MP.LOBBY.config.gamemode = callback_args.gamemode.key
+ MP.LOBBY.gamemode_preview = (tab_type == "rework")
+
+ tabs_wrap.config.object:remove()
+ tabs_wrap.config.object = UIBox({
+ definition = def,
+ config = { align = "cm", parent = tabs_wrap },
+ })
+
+ tabs_wrap.UIBox:recalculate()
+end
diff --git a/ui/main_menu/play_button/join_lobby_button.lua b/ui/main_menu/play_button/join_lobby_button.lua
new file mode 100644
index 00000000..417eaa0d
--- /dev/null
+++ b/ui/main_menu/play_button/join_lobby_button.lua
@@ -0,0 +1,42 @@
+function G.UIDEF.create_UIBox_join_lobby_button()
+ return (
+ create_UIBox_generic_options({
+ back_func = "play_options",
+ contents = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.5,
+ align = "cm",
+ },
+ nodes = {
+ create_text_input({
+ w = 4,
+ h = 1,
+ max_length = 5,
+ all_caps = true,
+ prompt_text = localize("k_enter_lobby_code"),
+ ref_table = MP.LOBBY,
+ ref_value = "temp_code",
+ extended_corpus = false,
+ keyboard_offset = 4,
+ minw = 5,
+ callback = function(val)
+ MP.ACTIONS.join_lobby(MP.LOBBY.temp_code)
+ end,
+ }),
+ },
+ },
+ },
+ },
+ },
+ })
+ )
+end
diff --git a/ui/main_menu/play_button/play_button.lua b/ui/main_menu/play_button/play_button.lua
new file mode 100644
index 00000000..71de6c20
--- /dev/null
+++ b/ui/main_menu/play_button/play_button.lua
@@ -0,0 +1,84 @@
+function G.UIDEF.override_main_menu_play_button()
+ if not G.SETTINGS.tutorial_complete or G.SETTINGS.tutorial_progress ~= nil then
+ return (
+ create_UIBox_generic_options({
+ contents = {
+ UIBox_button({
+ label = { localize("b_singleplayer") },
+ colour = G.C.BLUE,
+ button = "setup_run_singleplayer",
+ minw = 5,
+ }),
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.5,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_tutorial_not_complete"),
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.45,
+ },
+ },
+ },
+ },
+ UIBox_button({
+ label = { localize("b_skip_tutorial") },
+ colour = G.C.RED,
+ button = "skip_tutorial",
+ minw = 5,
+ }),
+ },
+ })
+ )
+ end
+
+ return (
+ create_UIBox_generic_options({
+ contents = {
+ UIBox_button({
+ label = { localize("b_singleplayer") },
+ colour = G.C.BLUE,
+ button = "start_vanilla_sp",
+ minw = 5,
+ }),
+ UIBox_button({
+ label = { localize("b_sp_with_ruleset") },
+ colour = G.C.ORANGE,
+ button = "setup_run_singleplayer",
+ minw = 5,
+ }),
+ MP.LOBBY.connected and UIBox_button({
+ label = { localize("b_create_lobby") },
+ colour = G.C.GREEN,
+ button = "create_lobby",
+ minw = 5,
+ }) or nil,
+ MP.LOBBY.connected and UIBox_button({
+ label = { localize("b_join_lobby") },
+ colour = G.C.RED,
+ button = "join_lobby",
+ minw = 5,
+ minh = 0.7,
+ }) or nil,
+ MP.LOBBY.connected and UIBox_button({
+ label = { localize("b_join_lobby_clipboard") },
+ colour = G.C.PURPLE,
+ button = "join_from_clipboard",
+ minw = 5,
+ minh = 0.7,
+ }) or nil,
+ not MP.LOBBY.connected and UIBox_button({
+ label = { localize("b_reconnect") },
+ colour = G.C.RED,
+ button = "reconnect",
+ minw = 5,
+ }) or nil,
+ },
+ })
+ )
+end
diff --git a/ui/main_menu/play_button/play_button_callbacks.lua b/ui/main_menu/play_button/play_button_callbacks.lua
new file mode 100644
index 00000000..6e1a7baf
--- /dev/null
+++ b/ui/main_menu/play_button/play_button_callbacks.lua
@@ -0,0 +1,138 @@
+-- Singleplayer ruleset state (parallels MP.LOBBY.config.ruleset for multiplayer)
+MP.SP = { ruleset = nil }
+
+function G.FUNCS.setup_run_singleplayer(e)
+ G.SETTINGS.paused = true
+ MP.LOBBY.config.ruleset = nil
+ MP.LOBBY.config.gamemode = nil
+ MP.SP.ruleset = nil
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ruleset_selection_options("sp"),
+ })
+end
+
+function G.FUNCS.start_sp_run(e)
+ G.FUNCS.exit_overlay_menu()
+ G.FUNCS.setup_run(e)
+end
+
+function G.FUNCS.start_vanilla_sp(e)
+ MP.LOBBY.config.ruleset = nil
+ MP.LOBBY.config.gamemode = nil
+ MP.SP.ruleset = nil
+ G.FUNCS.setup_run(e)
+end
+
+function G.FUNCS.play_options(e)
+ G.SETTINGS.paused = true
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.override_main_menu_play_button(),
+ })
+end
+
+function G.FUNCS.create_lobby(e)
+ G.SETTINGS.paused = true
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.ruleset_selection_options("mp"),
+ })
+end
+
+function G.FUNCS.select_gamemode(e)
+ G.SETTINGS.paused = true
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.gamemode_selection_options(),
+ })
+end
+
+function G.FUNCS.join_lobby(e)
+ G.SETTINGS.paused = true
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.create_UIBox_join_lobby_button(),
+ })
+ local text_input = G.OVERLAY_MENU:get_UIE_by_ID("text_input")
+ G.FUNCS.select_text_input(text_input)
+end
+
+function G.FUNCS.weekly_interrupt(e)
+ if (not MP.LOBBY.config.weekly) or (MP.LOBBY.config.weekly ~= MP.LOBBY.fetched_weekly) then
+ G.SETTINGS.paused = true
+
+ G.FUNCS.overlay_menu({
+ definition = G.UIDEF.weekly_interrupt(not not MP.LOBBY.config.weekly),
+ })
+ return true
+ end
+ return false
+end
+
+function G.FUNCS.set_weekly(e)
+ SMODS.Mods["Multiplayer"].config.weekly = MP.LOBBY.fetched_weekly
+ SMODS.save_mod_config(SMODS.Mods["Multiplayer"])
+ SMODS.restart_game() -- idk if this works well...
+end
+
+function G.FUNCS.skip_tutorial(e)
+ G.SETTINGS.tutorial_complete = true
+ G.SETTINGS.tutorial_progress = nil
+ G.FUNCS.play_options(e)
+end
+
+function G.FUNCS.join_from_clipboard(e)
+ local paste = MP.UTILS.get_from_clipboard()
+ if not paste then return end
+ MP.LOBBY.temp_code = string.sub(string.upper(paste:gsub("[^%a]", "")), 1, 5) -- cursed
+ MP.ACTIONS.join_lobby(MP.LOBBY.temp_code)
+end
+
+function G.FUNCS.start_lobby(e)
+ G.SETTINGS.paused = false
+
+ MP.reset_lobby_config(true)
+
+ MP.LOBBY.config.multiplayer_jokers = MP.Rulesets[MP.LOBBY.config.ruleset].multiplayer_content
+
+ MP.LOBBY.config.forced_config = MP.Rulesets[MP.LOBBY.config.ruleset].force_lobby_options()
+
+ if MP.LOBBY.config.gamemode == "gamemode_mp_survival" then
+ MP.LOBBY.config.starting_lives = 1
+ MP.LOBBY.config.disable_live_and_timer_hud = true
+ else
+ MP.LOBBY.config.disable_live_and_timer_hud = false
+ end
+
+ -- Check if the current gamemode is valid. If it's not, default to attrition.
+ local gamemode_check = false
+ for k, _ in pairs(MP.Gamemodes) do
+ if k == MP.LOBBY.config.gamemode then gamemode_check = true end
+ end
+ MP.LOBBY.config.gamemode = gamemode_check and MP.LOBBY.config.gamemode or "gamemode_mp_attrition"
+
+ MP.LOBBY.config.cocktail = SMODS.Mods["Multiplayer"].config.cocktail
+
+ MP.ACTIONS.create_lobby(string.sub(MP.LOBBY.config.gamemode, 13))
+ G.FUNCS.exit_overlay_menu()
+end
+
+function G.FUNCS.join_game_submit(e)
+ G.FUNCS.exit_overlay_menu()
+ MP.ACTIONS.join_lobby(MP.LOBBY.temp_code)
+end
+
+function G.FUNCS.join_game_paste(e)
+ MP.LOBBY.temp_code = MP.UTILS.get_from_clipboard()
+ MP.ACTIONS.join_lobby(MP.LOBBY.temp_code)
+ G.FUNCS.exit_overlay_menu()
+end
+
+-- Creating forced gamemode buttons for each gamemode, since I am not sure how to pass variables through button presses
+for gamemode, _ in pairs(MP.Gamemodes) do
+ G.FUNCS["force_" .. gamemode] = function(e)
+ MP.LOBBY.config.gamemode = gamemode
+ G.FUNCS.start_lobby(e)
+ end
+end
diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua
new file mode 100644
index 00000000..d6146eb0
--- /dev/null
+++ b/ui/main_menu/play_button/ruleset_selection.lua
@@ -0,0 +1,560 @@
+function G.UIDEF.ruleset_selection_options(mode)
+ mode = mode or "mp"
+ MP.LOBBY.fetched_weekly = "smallworld" -- temp
+
+ -- SP defaults to vanilla, MP defaults to ranked
+ local default_ruleset = "standard_ranked"
+ local default_button = default_ruleset .. "_ruleset_button"
+
+ if mode == "sp" then
+ MP.SP.ruleset = "ruleset_mp_" .. default_ruleset
+ else
+ MP.LOBBY.config.ruleset = "ruleset_mp_" .. default_ruleset
+ end
+ MP.LoadReworks(default_ruleset)
+
+ local default_ruleset_area = UIBox({
+ definition = G.UIDEF.ruleset_info(default_ruleset, mode),
+ config = { align = "cm" },
+ })
+
+ 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" },
+ },
+ },
+ {
+ name = "k_tournament",
+ buttons = {
+ { button_id = "majorleague_ruleset_button", button_localize_key = "k_majorleague" },
+ { button_id = "minorleague_ruleset_button", button_localize_key = "k_minorleague" },
+ },
+ },
+ }
+
+ MP.UI.ruleset_selection_mode = mode
+
+ return MP.UI.Main_Lobby_Options(
+ "ruleset_area",
+ default_ruleset_area,
+ "change_ruleset_selection",
+ ruleset_buttons_data
+ )
+end
+
+function G.FUNCS.change_ruleset_selection(e)
+ local mode = MP.UI.ruleset_selection_mode or "mp"
+
+ if e.config.id == "weekly_ruleset_button" then
+ if G.FUNCS.weekly_interrupt(e) then return end
+ end
+
+ -- this currently doesn't work properly
+ -- local default_button = mode == "sp" and "vanilla_ruleset_button" or "standard_ranked_ruleset_button"
+ local default_button = "standard_ranked_ruleset_button"
+
+ MP.UI.Change_Main_Lobby_Options(
+ e,
+ "ruleset_area",
+ function(ruleset_name)
+ return G.UIDEF.ruleset_info(ruleset_name, mode)
+ end,
+ default_button,
+ function(ruleset_name)
+ if mode == "sp" then
+ MP.SP.ruleset = "ruleset_mp_" .. ruleset_name
+ else
+ MP.LOBBY.config.ruleset = "ruleset_mp_" .. ruleset_name
+ end
+ MP.LoadReworks(ruleset_name)
+ end
+ )
+
+ MP.LOBBY.ruleset_preview = false
+end
+
+function G.UIDEF.ruleset_info(ruleset_name, mode)
+ mode = mode or "mp"
+ local ruleset = MP.Rulesets["ruleset_mp_" .. ruleset_name]
+
+ local ruleset_info_banned_rework_tabs = UIBox({
+ definition = G.UIDEF.ruleset_tabs(ruleset),
+ config = { align = "cm" },
+ })
+
+ local ruleset_disabled = ruleset.is_disabled()
+
+ -- Different button config for SP vs MP
+ local button_config
+ if mode == "sp" then
+ button_config = {
+ id = "start_sp_button",
+ button = "start_sp_run",
+ label = { localize("b_play_cap") },
+ colour = G.C.GREEN,
+ }
+ else
+ button_config = {
+ id = "select_gamemode_button",
+ button = ruleset.forced_gamemode and "force_" .. ruleset.forced_gamemode or "select_gamemode",
+ label = { ruleset.forced_gamemode and localize("b_create_lobby") or localize("b_next") },
+ colour = G.C.BLUE,
+ }
+ end
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "tm", minh = 8, maxh = 8, minw = 11, maxw = 11, colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.2, r = 0.1, colour = G.C.BLACK },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = ruleset_info_banned_rework_tabs } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ MP.UI.Disableable_Button({
+ id = button_config.id,
+ button = button_config.button,
+ align = "cm",
+ padding = 0.05,
+ r = 0.1,
+ minw = 8,
+ minh = 0.8,
+ colour = button_config.colour,
+ hover = true,
+ shadow = true,
+ label = button_config.label,
+ scale = 0.5,
+ enabled_ref_table = { val = not ruleset_disabled },
+ enabled_ref_value = "val",
+ disabled_text = { ruleset_disabled },
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function G.UIDEF.ruleset_tabs(ruleset)
+ local default_tabs = UIBox({
+ definition = G.UIDEF.lobby_setup_tabs_definition(ruleset, "info", 1, true),
+ config = { align = "cm", tab_type = "info", chosen_tab = 1 },
+ })
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", colour = G.C.L_BLACK, r = 0.1 },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = { align = "tm", colour = G.C.GREY, r = 0.1 },
+ nodes = {
+ { n = G.UIT.O, config = { object = default_tabs } },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = { align = "bm", padding = 0.05 },
+ nodes = {
+ create_option_cycle({
+ options = { localize("k_info"), localize("k_bans"), localize("k_reworks") },
+ current_option = 1,
+ opt_callback = "ruleset_switch_tabs",
+ opt_args = { ui = default_tabs, ruleset = ruleset },
+ w = 5,
+ colour = G.C.RED,
+ cycle_shoulders = false,
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+end
+
+function G.FUNCS.ruleset_switch_tabs(args)
+ if not args or not args.cycle_config then return end
+ local callback_args = args.cycle_config.opt_args
+
+ local tabs_object = callback_args.ui
+ local tabs_wrap = tabs_object.parent
+
+ local active_tab = tabs_wrap.UIBox:get_UIE_by_ID("ruleset_active_tab")
+ local active_tab_idx = active_tab and active_tab.config.tab_idx or 1
+
+ local tab_type = (args.to_key == 2 and "banned") or (args.to_key == 3 and "rework") or "info"
+ local def = nil
+
+ if tab_type == "banned" then
+ def = G.UIDEF.lobby_setup_tabs_definition(callback_args.ruleset, "banned", active_tab_idx, true)
+ tabs_object.config.tab_type = "banned"
+ MP.LOBBY.config.ruleset = callback_args.ruleset.key
+ MP.LOBBY.ruleset_preview = false
+ elseif tab_type == "rework" then
+ def = G.UIDEF.lobby_setup_tabs_definition(callback_args.ruleset, "rework", active_tab_idx, true)
+ tabs_object.config.tab_type = "rework"
+ MP.LOBBY.config.ruleset = callback_args.ruleset.key
+ MP.LOBBY.ruleset_preview = true
+ else
+ def = G.UIDEF.lobby_setup_tabs_definition(callback_args.ruleset, "info", active_tab_idx, true)
+ tabs_object.config.tab_type = "info"
+ MP.LOBBY.config.ruleset = callback_args.ruleset.key
+ MP.LOBBY.ruleset_preview = false
+ end
+
+ tabs_wrap.config.object:remove()
+ tabs_wrap.config.object = UIBox({
+ definition = def,
+ config = { align = "cm", parent = tabs_wrap },
+ })
+
+ tabs_wrap.UIBox:recalculate()
+end
+
+local function create_bans_and_reworks_tabs(ruleset_or_gamemode, is_banned_tab, chosen_tab_idx)
+ local banned_cards_tabs = {}
+
+ local function merge_lists(lists)
+ local seen = {}
+ local ret = {}
+ for _, tbl in pairs(lists) do
+ tbl = tbl or {}
+ for _, v in ipairs(tbl) do
+ if not seen[v] then
+ seen[v] = true
+ table.insert(ret, v)
+ end
+ end
+ end
+ return ret
+ end
+
+ local forced_gamemode = {}
+ if ruleset_or_gamemode.forced_gamemode then forced_gamemode = MP.Gamemodes[ruleset_or_gamemode.forced_gamemode] end
+
+ local tabs = {}
+ local loc_keys = {
+ jokers = "b_jokers",
+ consumables = "b_stat_consumables",
+ vouchers = "b_vouchers",
+ enhancements = "b_enhanced_cards",
+ other = "k_other",
+ }
+ local function copy_list(key)
+ if is_banned_tab then
+ return merge_lists({
+ MP.DECK["BANNED_" .. string.upper(key)],
+ ruleset_or_gamemode["banned_" .. key],
+ forced_gamemode["banned_" .. key],
+ })
+ else
+ return merge_lists({ ruleset_or_gamemode["reworked_" .. key], forced_gamemode["reworked_" .. key] })
+ end
+ end
+ for _, v in ipairs({ "jokers", "consumables", "vouchers", "enhancements", "other" }) do
+ local entry = { type = localize(loc_keys[v]) }
+ if v ~= "other" then
+ entry.obj_ids = copy_list(v)
+ else
+ entry.obj_ids = {
+ blinds = copy_list("blinds"),
+ tags = copy_list("tags"),
+ }
+ end
+
+ tabs[#tabs + 1] = entry
+ end
+
+ for k, v in ipairs(tabs) do
+ v.idx = k
+ v.is_banned_tab = is_banned_tab
+ local tab_def = {
+ label = v.type,
+ chosen = (k == chosen_tab_idx),
+ tab_definition_function = G.UIDEF.ruleset_cardarea_definition,
+ tab_definition_function_args = v,
+ }
+ table.insert(banned_cards_tabs, tab_def)
+ end
+
+ return {
+ n = G.UIT.ROOT,
+ config = { align = "cm", colour = G.C.CLEAR },
+ nodes = {
+ create_tabs({
+ tab_h = 4.2,
+ padding = 0,
+ scale = 0.8,
+ text_scale = 0.36,
+ no_shoulders = true,
+ no_loop = true,
+ tabs = banned_cards_tabs,
+ }),
+ },
+ }
+end
+
+function G.UIDEF.lobby_setup_tabs_definition(ruleset_or_gamemode, tab_type, chosen_tab_idx, is_ruleset)
+ if tab_type == "banned" or tab_type == "rework" then
+ return create_bans_and_reworks_tabs(ruleset_or_gamemode, tab_type == "banned", chosen_tab_idx)
+ end
+
+ local tab_id = ruleset_or_gamemode.key:find("ruleset") and "ruleset_active_tab" or "gamemode_active_tab"
+
+ return {
+ n = G.UIT.ROOT,
+ config = { id = tab_id, align = "cm", colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "tm", padding = 0.2, r = 0.1, minw = 10.7, maxw = 10.7, minh = 5.75, maxh = 5.75 },
+ nodes = ruleset_or_gamemode.create_info_menu(),
+ },
+ },
+ }
+end
+
+function G.UIDEF.ruleset_cardarea_definition(args)
+ local function get_ruleset_cardarea(obj_ids, width, height)
+ local ret = {}
+
+ if #obj_ids > 0 then
+ local card_rows = {}
+ local n_rows = math.max(1, 1 + math.floor(#obj_ids / 10) - math.floor(math.log(6, #obj_ids)))
+ local max_width = 1
+ for k, v in ipairs(obj_ids) do
+ local _row = math.ceil(n_rows * (k / #obj_ids))
+ card_rows[_row] = card_rows[_row] or {}
+ card_rows[_row][#card_rows[_row] + 1] = v
+ if #card_rows[_row] > max_width then max_width = #card_rows[_row] end
+ end
+
+ local card_size = math.max(0.3, 0.8 - 0.01 * (max_width * n_rows))
+
+ for _, card_row in ipairs(card_rows) do
+ local card_area = CardArea(0, 0, width, height / n_rows, {
+ card_limit = nil,
+ type = "title_2",
+ view_deck = true,
+ highlight_limit = 0,
+ card_w = G.CARD_W * card_size,
+ })
+
+ for k, v in ipairs(card_row) do -- Each card_row consists of Card IDs
+ local card = Card(
+ 0,
+ 0,
+ G.CARD_W * card_size,
+ G.CARD_H * card_size,
+ nil,
+ G.P_CENTERS[v],
+ { bypass_discovery_center = true, bypass_discovery_ui = true }
+ )
+ card_area:emplace(card)
+ end
+
+ table.insert(ret, {
+ n = G.UIT.R,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { object = card_area } },
+ },
+ })
+ end
+ end
+
+ return ret
+ end
+
+ local function get_ruleset_obj_grid(obj_ids, obj_ref_table, objs_per_row, obj_constructor, wrap_as_object)
+ local objs = {}
+ for _, v in ipairs(obj_ids) do
+ objs[#objs + 1] = obj_ref_table[v]
+ end
+ -- table.sort(objs, function (a, b) return a.order < b.order end)
+
+ local obj_grid = {}
+ local obj_rows = {}
+ for k, v in ipairs(objs) do
+ local obj = obj_constructor(v)
+
+ local row_idx = math.ceil(k / objs_per_row)
+ if not obj_rows[row_idx] then obj_rows[row_idx] = {} end
+ table.insert(obj_rows[row_idx], {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.1 },
+ nodes = {
+ wrap_as_object and { n = G.UIT.O, config = { object = obj } } or obj,
+ },
+ })
+ end
+ for _, v in ipairs(obj_rows) do
+ table.insert(obj_grid, { n = G.UIT.R, config = { align = "cm" }, nodes = v })
+ end
+
+ return obj_grid
+ end
+
+ local function get_localised_label(objs, obj_type)
+ return (#objs > 0)
+ and {
+ n = G.UIT.T,
+ config = {
+ text = localize({
+ type = "variable",
+ key = args.is_banned_tab and "k_banned_objs" or "k_reworked_objs",
+ vars = { obj_type },
+ }),
+ colour = lighten(G.C.L_BLACK, 0.5),
+ scale = 0.33,
+ },
+ }
+ or {
+ n = G.UIT.T,
+ config = {
+ text = localize({
+ type = "variable",
+ key = args.is_banned_tab and "k_no_banned_objs" or "k_no_reworked_objs",
+ vars = { obj_type },
+ }),
+ colour = lighten(G.C.L_BLACK, 0.5),
+ scale = 0.33,
+ },
+ }
+ end
+
+ if args.type == localize("k_other") then
+ local function tag_constructor(tag_spec)
+ return Tag(tag_spec.key):generate_UI(1 - 0.1 * (math.sqrt(#args.obj_ids.tags)))
+ end
+
+ local function blind_constructor(blind_spec)
+ local temp_blind = AnimatedSprite(
+ 0,
+ 0,
+ 1.1,
+ 1.1,
+ G.ANIMATION_ATLAS[blind_spec.atlas] or G.ANIMATION_ATLAS["blind_chips"],
+ blind_spec.pos
+ )
+ temp_blind:define_draw_steps({
+ { shader = "dissolve", shadow_height = 0.05 },
+ { shader = "dissolve" },
+ })
+ temp_blind.float = true
+ temp_blind.states.hover.can = true
+ temp_blind.states.drag.can = false
+ temp_blind.states.collide.can = true
+ temp_blind.config = { blind = blind_spec, force_focus = true }
+ temp_blind.hover = function()
+ if not G.CONTROLLER.dragging.target or G.CONTROLLER.using_touch then
+ if not temp_blind.hovering and temp_blind.states.visible then
+ temp_blind.hovering = true
+ temp_blind.hover_tilt = 3
+ Juice_up(temp_blind, 0.05, 0.02)
+ temp_blind.config.h_popup = create_UIBox_blind_popup(blind_spec, true)
+ temp_blind.config.h_popup_config = {
+ align = "cl",
+ offset = { x = -0.1, y = 0 },
+ parent = temp_blind,
+ }
+ Node.hover(temp_blind)
+ end
+ end
+ end
+ temp_blind.stop_hover = function()
+ temp_blind.hovering = false
+ Node.stop_hover(temp_blind)
+ temp_blind.hover_tilt = 0
+ end
+
+ return temp_blind
+ end
+
+ local tag_grid = get_ruleset_obj_grid(args.obj_ids.tags, G.P_TAGS, 4, tag_constructor)
+ local blind_grid = get_ruleset_obj_grid(args.obj_ids.blinds, G.P_BLINDS, 3, blind_constructor, true)
+
+ return {
+ n = G.UIT.ROOT,
+ config = { id = "ruleset_active_tab", tab_idx = args.idx, align = "cm", colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.05, r = 0.1, minw = 5.4, minh = 4.8, maxh = 4.8 },
+ nodes = {
+ { n = G.UIT.R, config = { align = "cm", minh = 4 }, nodes = tag_grid },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = { get_localised_label(args.obj_ids.tags, localize("b_tags")) },
+ },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.05, r = 0.1, minw = 5.4, minh = 4.8, maxh = 4.8 },
+ nodes = {
+ { n = G.UIT.R, config = { align = "cm", minh = 4 }, nodes = blind_grid },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = { get_localised_label(args.obj_ids.blinds, localize("b_blinds")) },
+ },
+ },
+ },
+ },
+ }
+ else
+ local cards_grid = get_ruleset_cardarea(args.obj_ids, 10, 4)
+
+ return {
+ n = G.UIT.ROOT,
+ config = { id = "ruleset_active_tab", tab_idx = args.idx, align = "cm", colour = G.C.CLEAR },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm", padding = 0.05, r = 0.1, minw = 10.8, minh = 4.8, maxh = 4.8 },
+ nodes = {
+ { n = G.UIT.R, config = { align = "cm" }, nodes = cards_grid },
+ {
+ n = G.UIT.R,
+ config = { align = "cm", padding = 0.05 },
+ nodes = { get_localised_label(args.obj_ids, args.type) },
+ },
+ },
+ },
+ },
+ }
+ end
+end
diff --git a/ui/main_menu/play_button/weekly_interrupt.lua b/ui/main_menu/play_button/weekly_interrupt.lua
new file mode 100644
index 00000000..f15aea6c
--- /dev/null
+++ b/ui/main_menu/play_button/weekly_interrupt.lua
@@ -0,0 +1,50 @@
+function G.UIDEF.weekly_interrupt(loaded)
+ return (
+ create_UIBox_generic_options({
+ back_func = "create_lobby",
+ contents = {
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.1,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = "A new weekly ruleset is available!",
+ colour = G.C.UI.TEXT_LIGHT,
+ scale = 0.45,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ align = "cm",
+ padding = 0.2,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_currently_colon")
+ .. localize("k_weekly_" .. MP.LOBBY.fetched_weekly), -- bad loc but ok
+ colour = darken(G.C.UI.TEXT_LIGHT, 0.2),
+ scale = 0.35,
+ },
+ },
+ },
+ },
+ UIBox_button({
+ label = { localize("k_sync_locally") },
+ colour = G.C.DARK_EDITION,
+ button = "set_weekly",
+ minw = 5,
+ }),
+ },
+ })
+ )
+end
diff --git a/ui/main_menu/title_card.lua b/ui/main_menu/title_card.lua
new file mode 100644
index 00000000..5a94ff7e
--- /dev/null
+++ b/ui/main_menu/title_card.lua
@@ -0,0 +1,137 @@
+local function nope_a_joker(card)
+ attention_text({
+ text = localize("k_nope_ex"),
+ scale = 0.8,
+ hold = 0.8,
+ major = card,
+ backdrop_colour = G.C.SECONDARY_SET.Tarot,
+ align = (G.STATE == G.STATES.TAROT_PACK or G.STATE == G.STATES.SPECTRAL_PACK) and "tm" or "cm",
+ offset = {
+ x = 0,
+ y = (G.STATE == G.STATES.TAROT_PACK or G.STATE == G.STATES.SPECTRAL_PACK) and -0.2 or 0,
+ },
+ silent = true,
+ })
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = 0.06 * G.SETTINGS.GAMESPEED,
+ blockable = false,
+ blocking = false,
+ func = function()
+ play_sound("tarot2", 0.76, 0.4)
+ return true
+ end,
+ }))
+ play_sound("tarot2", 1, 0.4)
+end
+
+function Juice_up(thing, a, b)
+ if SMODS.Mods["Talisman"] and SMODS.Mods["Talisman"].can_load then
+ local disable_anims = Talisman.config_file.disable_anims
+ Talisman.config_file.disable_anims = false
+ thing:juice_up(a, b)
+ Talisman.config_file.disable_anims = disable_anims
+ else
+ thing:juice_up(a, b)
+ end
+end
+
+local function wheel_of_fortune_the_card(card)
+ math.randomseed(os.time())
+ local chance = math.random(4)
+ if chance == 1 then
+ local editions = {
+ { name = "e_foil", weight = 499 },
+ { name = "e_holo", weight = 350 },
+ { name = "e_polychrome", weight = 150 },
+ { name = "e_negative", weight = 1 },
+ }
+ local edition = poll_edition("main_menu" .. os.time(), nil, nil, true, editions)
+ card:set_edition(edition, true)
+ Juice_up(card, 0.3, 0.5)
+ G.CONTROLLER.locks.edition = false -- if this isn't done, set_edition will block inputs for 0.1s
+ else
+ nope_a_joker(card)
+ Juice_up(card, 0.3, 0.5)
+ end
+end
+
+local function has_mod_manipulating_title_card()
+ -- maintain a list of all mods that affect the title card here
+ -- (must use the mod's id, not its name)
+ local modlist = { "BUMod", "Cryptid", "Talisman", "Pokermon" }
+ for _, modname in ipairs(modlist) do
+ if SMODS.Mods[modname] and SMODS.Mods[modname].can_load then return true end
+ end
+ return false
+end
+
+local function make_wheel_of_fortune_a_card_func(card)
+ return function()
+ if card then wheel_of_fortune_the_card(card) end
+ return true
+ end
+end
+
+MP.title_card = nil
+
+function Add_custom_multiplayer_cards(change_context)
+ local only_mod_affecting_title_card = not has_mod_manipulating_title_card()
+
+ if only_mod_affecting_title_card then G.title_top.cards[1]:set_base(G.P_CARDS["S_A"], true) end
+
+ -- Credit to the Cryptid mod for the original code to add a card to the main menu
+ local title_card = create_card("Base", G.title_top, nil, nil, nil, nil)
+ title_card:set_base(G.P_CARDS["H_A"], true)
+ G.title_top.T.w = G.title_top.T.w * 1.7675
+ G.title_top.T.x = G.title_top.T.x - 0.8
+ G.title_top:emplace(title_card)
+ title_card.T.w = title_card.T.w * 1.1 * 1.2
+ title_card.T.h = title_card.T.h * 1.1 * 1.2
+ title_card.no_ui = true
+ title_card.states.visible = false
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = change_context == "game" and 1.5 or 0,
+ blockable = false,
+ blocking = false,
+ func = function()
+ if change_context == "splash" then
+ title_card.states.visible = true
+ title_card:start_materialize({ G.C.WHITE, G.C.WHITE }, true, 2.5)
+ play_sound("whoosh1", math.random() * 0.1 + 0.3, 0.3)
+ play_sound("crumple" .. math.random(1, 5), math.random() * 0.2 + 0.6, 0.65)
+ else
+ title_card.states.visible = true
+ title_card:start_materialize({ G.C.WHITE, G.C.WHITE }, nil, 1.2)
+ end
+ G.VIBRATION = G.VIBRATION + 1
+ return true
+ end,
+ }))
+
+ MP.title_card = title_card
+
+ -- base delay in seconds, increases as needed
+ local wheel_delay = 2
+
+ if only_mod_affecting_title_card then
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = wheel_delay,
+ blockable = false,
+ blocking = false,
+ func = make_wheel_of_fortune_a_card_func(G.title_top.cards[1]),
+ }))
+ wheel_delay = wheel_delay + 1
+ end
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "after",
+ delay = wheel_delay,
+ blockable = false,
+ blocking = false,
+ func = make_wheel_of_fortune_a_card_func(MP.title_card),
+ }))
+end
diff --git a/ui/main_menu/version_display.lua b/ui/main_menu/version_display.lua
new file mode 100644
index 00000000..bd7d40b3
--- /dev/null
+++ b/ui/main_menu/version_display.lua
@@ -0,0 +1,33 @@
+MULTIPLAYER_VERSION = SMODS.Mods["Multiplayer"].version .. "-MULTIPLAYER"
+
+function Add_version_display()
+ -- Add version to main menu
+ UIBox({
+ definition = {
+ n = G.UIT.ROOT,
+ config = {
+ align = "cm",
+ colour = G.C.UI.TRANSPARENT_DARK,
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.3,
+ text = MULTIPLAYER_VERSION,
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ config = {
+ align = "tri",
+ bond = "Weak",
+ offset = {
+ x = 0,
+ y = 0.6,
+ },
+ major = G.ROOM_ATTACH,
+ },
+ })
+end
diff --git a/ui/smods_menu/config_tab.lua b/ui/smods_menu/config_tab.lua
new file mode 100644
index 00000000..578cc017
--- /dev/null
+++ b/ui/smods_menu/config_tab.lua
@@ -0,0 +1,113 @@
+function MP.UI.create_config_tab()
+ local ret = {
+ n = G.UIT.ROOT,
+ config = {
+ r = 0.1,
+ minw = 5,
+ align = "cm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ on_demand_tooltip = {
+ text = {
+ localize("k_preview_integration_desc"),
+ localize("k_preview_credit"),
+ },
+ },
+ },
+ nodes = {
+ create_toggle({
+ id = "fantoms_preview_integration_toggle",
+ label = localize("b_preview_integration"),
+ ref_table = SMODS.Mods["Multiplayer"].config.integrations,
+ ref_value = "Preview",
+ }),
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_preview_credit"),
+ shadow = true,
+ scale = 0.375,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ {
+ n = G.UIT.B,
+ config = {
+ w = 0.1,
+ h = 0.1,
+ },
+ },
+ {
+ n = G.UIT.T,
+ config = {
+ text = localize("k_requires_restart"),
+ shadow = true,
+ scale = 0.375,
+ colour = G.C.UI.TEXT_INACTIVE,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ on_demand_tooltip = {
+ text = {
+ localize("k_applies_singleplayer_vanilla_rulesets"),
+ },
+ },
+ },
+ nodes = {
+ create_toggle({
+ id = "singleplayer_hide_content_toggle",
+ label = localize("k_hide_mp_content"),
+ ref_table = SMODS.Mods["Multiplayer"].config,
+ ref_value = "hide_mp_content",
+ }),
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.1,
+ align = "cm",
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ create_option_cycle({
+ label = localize("k_timer_sfx"),
+ w = 4,
+ scale = 0.8,
+ options = localize("ml_mp_timersfx_opt"),
+ opt_callback = "mp_change_timersfx",
+ current_option = SMODS.Mods["Multiplayer"].config.timersfx or 1,
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+ return ret
+end
diff --git a/ui/smods_menu/credits_tab.lua b/ui/smods_menu/credits_tab.lua
new file mode 100644
index 00000000..261f60eb
--- /dev/null
+++ b/ui/smods_menu/credits_tab.lua
@@ -0,0 +1,60 @@
+function MP.UI.create_credits_tab()
+ local scale = 0.75
+ return {
+ n = G.UIT.ROOT,
+ config = {
+ emboss = 0.05,
+ minh = 6,
+ r = 0.1,
+ minw = 6,
+ align = "cm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ MP.UI.UTILS.create_row({ padding = 0.2, align = "cm" }, {
+ MP.UI.UTILS.create_text_node(localize("k_created_by"), {
+ scale = scale * 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ MP.UI.UTILS.create_text_node("Virtualized", {
+ scale = scale * 0.8,
+ colour = G.C.DARK_EDITION,
+ }),
+ }),
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0 }, {
+ MP.UI.UTILS.create_text_node(localize("k_major_contributors"), {
+ scale = scale * 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.2 }, {
+ MP.UI.UTILS.create_text_node(
+ localize({
+ type = "variable",
+ key = "k_credits_list",
+ vars = { "TGMM, Senfinbrare, CUexter, Brawmario, Divvy, Andy, Steph," },
+ }),
+ {
+ scale = scale * 0.8,
+ colour = G.C.RED,
+ }
+ ),
+ }),
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0 }, {
+ UIBox_button({
+ minw = 3.85,
+ button = "bmp_github",
+ label = { localize("b_github_project") },
+ }),
+ }),
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0 }, {
+ UIBox_button({
+ minw = 3.85 * 2,
+ button = "bmp_discord",
+ label = { localize("b_mp_discord") },
+ }),
+ }),
+ },
+ }
+end
diff --git a/ui/smods_menu/customization_tab.lua b/ui/smods_menu/customization_tab.lua
new file mode 100644
index 00000000..aa5360b7
--- /dev/null
+++ b/ui/smods_menu/customization_tab.lua
@@ -0,0 +1,216 @@
+function MP.UI.create_customization_tab()
+ local blind_anim = AnimatedSprite(
+ 0,
+ 0,
+ 1.4,
+ 1.4,
+ G.ANIMATION_ATLAS["mp_player_blind_col"],
+ G.P_BLINDS[MP.UTILS.blind_col_numtokey(MP.LOBBY.blind_col)].pos
+ )
+ blind_anim:define_draw_steps({
+ { shader = "dissolve", shadow_height = 0.05 },
+ { shader = "dissolve" },
+ })
+ MP.PREVIEW.text = SMODS.Mods["Multiplayer"].config.preview.text or ""
+ MP.PREVIEW.button = SMODS.Mods["Multiplayer"].config.preview.button or ""
+ local ret = {
+ n = G.UIT.ROOT,
+ config = {
+ r = 0.1,
+ minw = 5,
+ align = "cm",
+ padding = 0.2,
+ colour = G.C.BLACK,
+ },
+ nodes = {
+ MP.INTEGRATIONS.Preview and {
+ n = G.UIT.R,
+ config = {
+ padding = 0.10,
+ align = "cm",
+ id = "preview_text_input",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.5,
+ text = localize("k_customize_preview"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ } or nil,
+ MP.INTEGRATIONS.Preview and {
+ n = G.UIT.R,
+ config = {
+ padding = 0,
+ align = "cm",
+ id = "preview_text_input",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.3,
+ text = localize("k_enter_to_save"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ } or nil,
+ MP.INTEGRATIONS.Preview
+ and {
+ n = G.UIT.R,
+ config = {
+ padding = 0.15,
+ align = "cm",
+ id = "preview_text_input",
+ },
+ nodes = {
+ create_text_input({
+ id = "preview_text",
+ w = 4,
+ max_length = 25,
+ prompt_text = "CALCULATING", -- raw string but this doesn't need localization
+ colour = copy_table(G.C.BLACK),
+ hooked_colour = darken(copy_table(G.C.BLACK), 0.3),
+ ref_table = MP.PREVIEW,
+ ref_value = "text",
+ extended_corpus = true,
+ keyboard_offset = -3,
+ callback = function(val)
+ MP.UTILS.save_preview(MP.PREVIEW)
+ end,
+ }),
+ create_text_input({
+ id = "preview_button",
+ w = 4,
+ max_length = 25,
+ prompt_text = "Calculate Score",
+ colour = copy_table(G.C.RED),
+ hooked_colour = darken(copy_table(G.C.RED), 0.3),
+ ref_table = MP.PREVIEW,
+ ref_value = "button",
+ extended_corpus = true,
+ keyboard_offset = -3,
+ callback = function(val)
+ MP.UTILS.save_preview(MP.PREVIEW)
+ end,
+ }),
+ },
+ }
+ or nil,
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.5,
+ align = "cm",
+ id = "username_input_box",
+ },
+ nodes = {
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.6,
+ text = localize("k_username"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ create_text_input({
+ id = "enter_username",
+ w = 4,
+ max_length = 25,
+ prompt_text = localize("k_enter_username"),
+ ref_table = MP.LOBBY,
+ ref_value = "username",
+ extended_corpus = true,
+ keyboard_offset = -3,
+ callback = function(val)
+ MP.UTILS.save_username(MP.LOBBY.username)
+ end,
+ }),
+ {
+ n = G.UIT.T,
+ config = {
+ scale = 0.3,
+ text = localize("k_enter_to_save"),
+ colour = G.C.UI.TEXT_LIGHT,
+ },
+ },
+ },
+ },
+ {
+ n = G.UIT.R,
+ config = {
+ padding = 0.1,
+ align = "cm",
+ id = "blind_col_changer",
+ },
+ nodes = {
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ { n = G.UIT.O, config = { id = "blind_col_changer_sprite", object = blind_anim } },
+ },
+ },
+ {
+ n = G.UIT.C,
+ config = { align = "cm" },
+ nodes = {
+ create_option_cycle({
+ id = "blind_col_changer_option",
+ label = localize({
+ type = "name_text",
+ key = MP.UTILS.blind_col_numtokey(MP.LOBBY.blind_col),
+ set = "Blind",
+ }),
+ scale = 0.8,
+ options = {
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ }, -- blind_cols are being saved as numbers because of this option cycle. if this is changed then we should probably change to keys
+ opt_callback = "change_blind_col",
+ current_option = MP.LOBBY.blind_col,
+ }),
+ },
+ },
+ },
+ },
+ },
+ }
+ return ret
+end
+
+function MP.UI.create_extra_tabs()
+ return {
+ {
+ label = localize("k_customization"),
+ tab_definition_function = MP.UI.create_customization_tab,
+ },
+ }
+end
diff --git a/ui/smods_menu/smods_menu.lua b/ui/smods_menu/smods_menu.lua
new file mode 100644
index 00000000..7f8718d7
--- /dev/null
+++ b/ui/smods_menu/smods_menu.lua
@@ -0,0 +1,42 @@
+SMODS.Mods.Multiplayer.credits_tab = MP.UI.create_credits_tab
+
+SMODS.Mods.Multiplayer.config_tab = MP.UI.create_config_tab
+
+SMODS.Mods.Multiplayer.extra_tabs = MP.UI.create_extra_tabs
+
+function G.FUNCS.bmp_discord(e)
+ love.system.openURL("https://discord.gg/gEemz4ptuF")
+end
+
+function G.FUNCS.bmp_github(e)
+ love.system.openURL("https://github.com/Balatro-Multiplayer/BalatroMultiplayer/")
+end
+
+function G.FUNCS.change_blind_col(args) -- all we're doing is just saving + redefining the ui elements here
+ MP.UTILS.save_blind_col(args.to_val)
+ MP.LOBBY.blind_col = args.to_val
+ local sprite = G.OVERLAY_MENU:get_UIE_by_ID("blind_col_changer_sprite")
+ sprite.config.object:remove()
+ sprite.config.object = AnimatedSprite(
+ 0,
+ 0,
+ 1.4,
+ 1.4,
+ G.ANIMATION_ATLAS["mp_player_blind_col"],
+ G.P_BLINDS[MP.UTILS.blind_col_numtokey(MP.LOBBY.blind_col)].pos
+ )
+ sprite.config.object:define_draw_steps({
+ { shader = "dissolve", shadow_height = 0.05 },
+ { shader = "dissolve" },
+ })
+ sprite.UIBox:recalculate()
+ local option = G.OVERLAY_MENU:get_UIE_by_ID("blind_col_changer_option")
+ option.children[1].children[1].config.text =
+ localize({ type = "name_text", key = MP.UTILS.blind_col_numtokey(MP.LOBBY.blind_col), set = "Blind" })
+ option.UIBox:recalculate()
+end
+
+function G.FUNCS.mp_change_timersfx(args)
+ SMODS.Mods["Multiplayer"].config.timersfx = args.to_key
+ SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) -- probably unnecessary?
+end
diff --git a/ui/utils.lua b/ui/utils.lua
new file mode 100644
index 00000000..7f1a882c
--- /dev/null
+++ b/ui/utils.lua
@@ -0,0 +1,131 @@
+MP.UI.UTILS = {}
+
+-- Creates a text node
+function MP.UI.UTILS.create_text_node(text, config)
+ config = config or {}
+ config.text = text
+ return { n = G.UIT.T, config = config }
+end
+
+-- Creates a row container
+function MP.UI.UTILS.create_row(config, nodes)
+ config = config or {}
+ return { n = G.UIT.R, config = config, nodes = nodes or {} }
+end
+
+-- Creates a column container
+function MP.UI.UTILS.create_column(config, nodes)
+ config = config or {}
+ return { n = G.UIT.C, config = config, nodes = nodes or {} }
+end
+
+-- Creates a DynaText object
+function MP.UI.UTILS.create_dynatext(string_or_table, config)
+ config = config or {}
+ config.string = string_or_table
+ return DynaText(config)
+end
+
+-- Creates a blank spacer
+function MP.UI.UTILS.create_blank(w, h)
+ return { n = G.UIT.B, config = { w = w, h = h } }
+end
+
+-- Creates a container with object
+function MP.UI.UTILS.create_object_node(object, config)
+ config = config or {}
+ config.object = object
+ return { n = G.UIT.O, config = config }
+end
+
+--- Overlay with a DynaText countdown timer
+--- @param message string Static message lines (newline-separated)
+--- @param countdown_table table Table with a "display" key that gets updated externally
+--- @param no_back boolean If true, disables back/esc buttons
+function MP.UI.UTILS.overlay_message_countdown(message, countdown_table, no_back)
+ G.SETTINGS.paused = true
+ local message_table = MP.UTILS.string_split(message, "\n")
+ local message_ui = {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.2 }, {
+ MP.UI.UTILS.create_text_node("MULTIPLAYER", {
+ scale = 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ }
+
+ for _, v in ipairs(message_table) do
+ table.insert(
+ message_ui,
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.1 }, {
+ MP.UI.UTILS.create_text_node(v, {
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ })
+ )
+ end
+
+ -- Countdown row using DynaText with ref_table for live updates
+ table.insert(
+ message_ui,
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.2 }, {
+ MP.UI.UTILS.create_object_node(
+ DynaText({
+ string = {{ ref_table = countdown_table, ref_value = "display" }},
+ colours = { G.C.UI.TEXT_LIGHT },
+ shadow = true,
+ silent = true,
+ scale = 0.7,
+ pop_in = 0,
+ })
+ ),
+ })
+ )
+
+ G.FUNCS.overlay_menu({
+ definition = create_UIBox_generic_options({
+ no_back = no_back,
+ no_esc = no_back,
+ contents = {
+ MP.UI.UTILS.create_column({ align = "cm", padding = 0.2 }, message_ui),
+ },
+ }),
+ })
+end
+
+-- Overlay message function (moved from misc/utils.lua)
+function MP.UI.UTILS.overlay_message(message, no_back)
+ G.SETTINGS.paused = true
+ local message_table = MP.UTILS.string_split(message, "\n")
+ local message_ui = {
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.2 }, {
+ MP.UI.UTILS.create_text_node("MULTIPLAYER", {
+ scale = 0.8,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ }),
+ }
+
+ for _, v in ipairs(message_table) do
+ table.insert(
+ message_ui,
+ MP.UI.UTILS.create_row({ align = "cm", padding = 0.1 }, {
+ MP.UI.UTILS.create_text_node(v, {
+ scale = 0.6,
+ colour = G.C.UI.TEXT_LIGHT,
+ }),
+ })
+ )
+ end
+
+ G.FUNCS.overlay_menu({
+ definition = create_UIBox_generic_options({
+ no_back = no_back,
+ no_esc = no_back,
+ contents = {
+ MP.UI.UTILS.create_column({ align = "cm", padding = 0.2 }, message_ui),
+ },
+ }),
+ })
+end