diff --git a/core.lua b/core.lua index 96f2ed98..8a520766 100644 --- a/core.lua +++ b/core.lua @@ -308,6 +308,7 @@ 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/jokers/experimental") +MP.load_mp_dir("objects/jokers/release") MP.load_mp_dir("objects/stakes") MP.load_mp_dir("objects/tags") MP.load_mp_dir("objects/consumables") diff --git a/layers/experimental.lua b/layers/experimental.lua index 02472274..caba1838 100644 --- a/layers/experimental.lua +++ b/layers/experimental.lua @@ -8,7 +8,6 @@ MP.Layer("experimental", { "j_bloodstone", "c_ouija", "j_todo_list", - "j_idol", }, banned_jokers = { "j_mp_speedrun", @@ -25,7 +24,7 @@ MP.Layer("experimental", { "j_mp_seltzer", "j_mp_todo_list", "j_mp_bloodstone", - "j_mp_idol_rare", + "j_idol", }, reworked_consumables = { "c_mp_ouija_standard", diff --git a/layers/release.lua b/layers/release.lua index 9fa86f05..8ce6d7c3 100644 --- a/layers/release.lua +++ b/layers/release.lua @@ -1,6 +1,8 @@ ---[[ +-- The 1.0.0-release rebalance layer. Still WIP (most of its center reworks live +-- commented in rulesets/release.lua), but the layer itself is defined so reworks +-- can target it by name. No live ruleset composes it yet, so enabling it here +-- changes nothing in shipped play. MP.Layer("release", { multiplayer_content = false, -- Allan please add details }) -]] \ No newline at end of file diff --git a/localization/en-us.lua b/localization/en-us.lua index 777d9978..bd863534 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -94,15 +94,6 @@ return { "{s:0.8}Card changes every round", }, }, - j_mp_idol_rare = { - 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 = { diff --git a/localization/ru.lua b/localization/ru.lua index e74a65da..c9e6e34d 100644 --- a/localization/ru.lua +++ b/localization/ru.lua @@ -95,15 +95,6 @@ return { "{s:0.8}Карта меняется каждый раунд", }, }, - j_mp_idol_rare = { - name = "Идол", - text = { - "Каждая сыгранная {C:attention}#2#", - "{V:1}#3#{} даёт", - "{X:mult,C:white} X#1# {} множ. при подсчёте", - "{s:0.8}Карта меняется каждый раунд", - }, - }, j_mp_ticket = { name = "Золотой билет", text = { diff --git a/objects/jokers/experimental/idol_rare.lua b/objects/jokers/experimental/idol_rare.lua index 96fba59a..be94304a 100644 --- a/objects/jokers/experimental/idol_rare.lua +++ b/objects/jokers/experimental/idol_rare.lua @@ -1,36 +1,7 @@ -SMODS.Joker({ - key = "idol_rare", - unlocked = true, - discovered = true, - blueprint_compat = true, - perishable_compat = true, - eternal_compat = true, +MP.ReworkCenter("j_idol", { + layers = "experimental", rarity = 3, cost = 8, - pos = { x = 6, y = 7 }, - no_collection = true, - config = { extra = { xmult = 2 }, mp_balanced = true }, - loc_vars = function(self, info_queue, card) - local idol_card = G.GAME.current_round.idol_card or { rank = "Ace", suit = "Spades" } - return { - vars = { - card.ability.extra.xmult, - localize(idol_card.rank, "ranks"), - localize(idol_card.suit, "suits_plural"), - colours = { G.C.SUITS[idol_card.suit] }, - }, - } - end, - calculate = function(self, card, context) - if - context.individual - and context.cardarea == G.play - and context.other_card:get_id() == G.GAME.current_round.idol_card.id - and context.other_card:is_suit(G.GAME.current_round.idol_card.suit) - then - return { - xmult = card.ability.extra.xmult, - } - end - end, + unlocked = true, + discovered = true, }) diff --git a/rulesets/_rulesets.lua b/rulesets/_rulesets.lua index aa4ed2fe..bccd632e 100644 --- a/rulesets/_rulesets.lua +++ b/rulesets/_rulesets.lua @@ -229,149 +229,377 @@ function MP.ApplyBans() end end -local LOADED_REWORKS = {} --- Rework a center for specific layer(s). Use MP.LoadReworks() to swap in the active ruleset. --- Multiple calls for the same key accumulate — each call targets its own layer slot --- on the center, so registering a key once per layer is the supported pattern. +-- ---------------------------------------------------------------------------- +-- ReworkCenter: center rebalancing as a pure projection of the active context +-- ---------------------------------------------------------------------------- +-- The predecessor mutated the shared center in place and let the result depend on +-- call history, which is a polite way of saying two clients desynced if they +-- did things in a different order. Three tiers now, nothing touched +-- until run start, and only ever rebuilt from a frozen copy of vanilla: +-- BASELINE — deep-frozen vanilla, captured once. The same on call 1 and call 100. +-- LEDGER — declared overrides, filed per (table, key, layer), off to the side. +-- LIVE — G.P_* recomputed from (baseline ⊕ active layers). ApplyReworks is +-- the only writer; PreviewReworks projects elsewhere and touches nothing. +-- effective_props() is pure in (table, key, layer-chain). + +-- Tables ReworkCenter can target, keyed by a stable id so baseline/ledger don't +-- alias across tables that happen to share a key string. +local function rework_tables() + return { + P_CENTERS = G.P_CENTERS, + P_TAGS = G.P_TAGS, + P_SEALS = G.P_SEALS, + PokerHands = SMODS.PokerHands, + P_STAKES = G.P_STAKES, + P_BLINDS = G.P_BLINDS, + } +end + +-- Resolve an opts.center_table (table ref, global name, or nil) to a stable id. +local function resolve_table_id(center_table) + if center_table == nil then return "P_CENTERS" end + local tables = rework_tables() + if type(center_table) == "string" then + return tables[center_table] and center_table or nil + end + for id, tbl in pairs(tables) do + if tbl == center_table then return id end + end + return nil +end + +-- Frozen tables are read-through proxies: the real data lives behind FROZEN_DATA +-- so EVERY write (new key or existing) trips __newindex, not just new keys. +-- LuaJIT is 5.1 (no __pairs / table.freeze), so deep_copy unwraps them directly. +local FROZEN_DATA = setmetatable({}, { __mode = "k" }) + +local function deep_copy(v) + if type(v) ~= "table" then return v end + local src = FROZEN_DATA[v] or v + local out = {} + for k, vv in pairs(src) do + out[k] = deep_copy(vv) + end + return out +end + +-- Recursive freeze. Writing to the baseline errors loudly here and now, rather +-- than desyncing two clients quietly, three weeks from now, mid-tournament. +local function deep_freeze(v) + if type(v) ~= "table" then return v end + local inner = {} + for k, vv in pairs(v) do + inner[k] = deep_freeze(vv) + end + local proxy = setmetatable({}, { + __index = inner, + __newindex = function() + error("attempt to mutate a frozen rework baseline", 2) + end, + __metatable = false, + }) + FROZEN_DATA[proxy] = inner + return proxy +end + +-- Deep-merge src onto dst. Tables recurse; scalars overwrite. So {extra={...}} +-- layers onto vanilla config instead of clobbering the whole table. +local function deep_merge(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep_merge(dst[k], v) + else + dst[k] = deep_copy(v) + end + end + return dst +end + +MP._REWORK_BASELINE = {} -- [table_id][key] = { [prop] = { value, present = true } } (frozen) +MP._REWORK_LEDGER = {} -- [table_id][key][layer] = { props = , silent } +MP._REWORK_OWNED = {} -- [table_id][key] = { prop = true } — the key-reset set +MP._PREVIEW_VIEW = {} -- [table_id][key] = { [prop] = value } — read-only preview projection +MP._PREVIEW_ACTIVE = false -- phase guard: true while a preview projection is live + +-- Pending ReworkCenter calls, drained once at injectItems. We can't capture the +-- baseline at call time because the center may not be registered yet. +local PENDING_REWORKS = {} + +-- Rework a center for specific layer(s). Effective props are derived from the +-- active context at ApplyReworks/PreviewReworks time — registering is pure data. +-- Multiple calls for the same key accumulate; each call's layers get their own +-- ledger slot, so a key reworked differently per layer is the supported pattern. ---@param key string e.g. "j_hanging_chad" ----@param opts table { layers, loc_key?, silent?, ...center properties } +---@param opts table { layers, loc_key?, silent?, center_table?, ...center properties } function MP.ReworkCenter(key, opts) - LOADED_REWORKS[key] = LOADED_REWORKS[key] or {} - table.insert(LOADED_REWORKS[key], opts or {}) + PENDING_REWORKS[#PENDING_REWORKS + 1] = { key = key, opts = opts or {} } end --- inject reworks properly -local inject_ref = SMODS.injectItems -function SMODS.injectItems() - local ret = inject_ref() - for key, opts_list in pairs(LOADED_REWORKS) do - for _, opts in ipairs(opts_list) do - local center_table = type(opts.center_table) == "table" and opts.center_table - or G[opts.center_table] - or G.P_CENTERS - local center = center_table[key] - - -- Meta keys (not center properties) - local reserved = { layers = true, loc_key = true, silent = true } - local layers = opts.layers - local loc_key = opts.loc_key - local silent = opts.silent - - -- Convert single layer to list - if type(layers) == "string" then layers = { layers } 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 +-- Normalize one ReworkCenter call into the ledger. Runs once, post-registration. +-- Where the loc_var-wrap / generate_ui / mp_balanced enrichment happens. +local function ingest_rework(key, opts) + local table_id = resolve_table_id(opts.center_table) + if not table_id then return end + local center = rework_tables()[table_id][key] + if not center then return 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") + local reserved = { layers = true, loc_key = true, silent = true, center_table = true } + local layers = opts.layers + if type(layers) == "string" then layers = { layers } end + if not layers then return end - -- inject mp_balanced if applicable - if center.config then - opts.config = opts.config or copy_table(center.config) - opts.config.mp_balanced = true - end + -- Wrap loc_vars to inject loc_key if provided. + local loc_key = opts.loc_key + 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 - -- Apply changes to all specified layers - for _, layer in ipairs(layers) do - local prefix = "mp_" .. layer .. "_" + -- Inject generate_ui when adding loc_vars to a vanilla center. + local needs_generate_ui = opts.loc_vars + and not opts.generate_ui + and not (center.generate_ui and type(center.generate_ui) == "function") - -- 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 + -- Force mp_balanced on the config so the sticker patch fires. Seed from vanilla + -- config so a caller can override one field without re-declaring the whole table. + if center.config then + opts.config = opts.config or deep_copy(center.config) + opts.config.mp_balanced = true + 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 + -- Collect the declared props once (same for every layer in this call). + local props = {} + for k, v in pairs(opts) do + if not reserved[k] then props[k] = v end + end + if needs_generate_ui then props.generate_ui = SMODS.Center.generate_ui end - -- Mark this center as having reworks - center.mp_reworks = center.mp_reworks or {} - center.mp_reworks[layer] = true - center.mp_reworks["vanilla"] = true + MP._REWORK_LEDGER[table_id] = MP._REWORK_LEDGER[table_id] or {} + MP._REWORK_LEDGER[table_id][key] = MP._REWORK_LEDGER[table_id][key] or {} + MP._REWORK_OWNED[table_id] = MP._REWORK_OWNED[table_id] or {} + MP._REWORK_OWNED[table_id][key] = MP._REWORK_OWNED[table_id][key] or {} + local owned = MP._REWORK_OWNED[table_id][key] - center.mp_silent = center.mp_silent or {} - center.mp_silent[layer] = silent + -- Baseline: deep snapshot of every prop this rework touches, taken before + -- anything mutates. A present-flag, not a magic "absent" value, so nil stays nil. + MP._REWORK_BASELINE[table_id] = MP._REWORK_BASELINE[table_id] or {} + MP._REWORK_BASELINE[table_id][key] = MP._REWORK_BASELINE[table_id][key] or {} + local baseline = MP._REWORK_BASELINE[table_id][key] + + for prop in pairs(props) do + owned[prop] = true + if baseline[prop] == nil then + local cur = center[prop] + if cur == nil then + baseline[prop] = deep_freeze({ present = false }) + else + baseline[prop] = deep_freeze({ value = deep_copy(cur), present = true }) end end end - return ret + + for _, layer in ipairs(layers) do + -- Last write for a (key, layer) wins. + MP._REWORK_LEDGER[table_id][key][layer] = { + props = deep_copy(props), + silent = opts.silent, + } + end end --- Load reworks for the active ruleset. Resolves via layer order then self-layer. --- You can also call this function with a key to only affect that specific center. -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_, prefix_, tbl_) - local center = tbl_[key_] - for k, v in pairs(center) do - if string.sub(k, 1, #prefix_) == prefix_ then - local orig = string.sub(k, #prefix_ + 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 +-- Effective props for one center under an ordered layer chain. PURE: reads only +-- (frozen baseline, ledger, chain), never the live center. Returns (effective, +-- owned) where `effective[prop] = value | nil`. +local function effective_props(table_id, key, chain) + local owned = (MP._REWORK_OWNED[table_id] or {})[key] or {} + local baseline = (MP._REWORK_BASELINE[table_id] or {})[key] or {} + local ledger = (MP._REWORK_LEDGER[table_id] or {})[key] or {} + + -- Start from vanilla: every owned prop resets to baseline (or absent). No + -- history survives this line, which is the entire point of the rewrite. + local effective = {} + for prop in pairs(owned) do + local b = baseline[prop] + if b and b.present then effective[prop] = deep_copy(b.value) end + end + + -- Fold each active layer in chain order; later layers win, config deep-merges. + for _, layer in ipairs(chain) do + local slot = ledger[layer] + if slot then + for prop, v in pairs(slot.props) do + if prop == "config" and type(v) == "table" and type(effective.config) == "table" then + deep_merge(effective.config, v) else - center[orig] = center[k] + effective[prop] = deep_copy(v) end end end end + return effective, owned +end - -- Resolution: vanilla → ruleset's layers → ruleset self → modifiers (only - -- when target is the active ruleset). active_layer_chain handles the full - -- layer-name list; vanilla is processed separately. - local resolution = MP.active_layer_chain(ruleset) +-- Set of centers some ledger entry touches, per table. Iterating this (not the +-- whole P_* table) keeps ApplyReworks scoped to rework-owned keys. +local function reworked_keys(table_id) + local out = {} + local ledger = MP._REWORK_LEDGER[table_id] + if ledger then + for key in pairs(ledger) do + out[#out + 1] = key + end + end + return out +end - if key then - process(key, "mp_vanilla_") - for _, layer in ipairs(resolution) do - process(key, "mp_" .. layer .. "_") +-- Rebuild G.P_JOKER_RARITY_POOLS from scratch. NOT incremental: collect every +-- joker whose effective rarity is bucket B, stable-sort by (.order, key), replace +-- wholesale. Incremental re-sorting is precisely how the rarity pool used to drift +-- two clients apart, so we don't do that anymore. +local function rebuild_rarity_pools(chain, effective_rarity) + if not G.P_JOKER_RARITY_POOLS then return end + for bucket, pool in pairs(G.P_JOKER_RARITY_POOLS) do + local members = {} + for _, center in pairs(G.P_CENTERS) do + -- Membership mirrors vanilla pool init byte-for-byte (game.lua:818/822): + -- if not v.wip then if v.rarity and v.set == 'Joker' and not v.demo + -- — i.e. a non-WIP Joker with a rarity that isn't a demo card. Effective + -- rarity = the layer override if reworked, else the center's own rarity. + -- (Vanilla applies no banned/discovered/skip_pool gate to the rarity + -- pool; those act elsewhere or at draw-time, so we don't either.) + local rarity = effective_rarity[center.key] + if rarity == nil then rarity = center.rarity end + if not center.wip and rarity and center.set == "Joker" and not center.demo and rarity == bucket then + members[#members + 1] = center + end + end + -- Vanilla sorts by .order alone; we add a key tiebreak so equal-order + -- jokers can't land in different positions on two clients (table.sort is + -- not stable). Strictly more deterministic than the game's own sort. + table.sort(members, function(a, b) + if a.order ~= b.order then return (a.order or 0) < (b.order or 0) end + return tostring(a.key) < tostring(b.key) + end) + -- Replace contents in place (other code holds a reference to `pool`). + for i = #pool, 1, -1 do + pool[i] = nil + end + for i, center in ipairs(members) do + pool[i] = center end - else - for _, tbl in ipairs({ - G.P_CENTERS, - G.P_TAGS, - G.P_SEALS, - SMODS.PokerHands, - G.P_STAKES, - G.P_BLINDS, - }) do - for k, v in pairs(tbl) do - if v.mp_reworks then - -- Always reset to vanilla first - if v.mp_reworks["vanilla"] then process(k, "mp_vanilla_", tbl) end - -- Apply layers in order, then self - for _, layer in ipairs(resolution) do - if v.mp_reworks[layer] then process(k, "mp_" .. layer .. "_", tbl) end + end +end + +-- Strip ruleset_mp_ prefix; nil/empty means vanilla (no layers active). +local function chain_for(ruleset) + if not ruleset or ruleset == "" then return {} end + if string.sub(ruleset, 1, 11) == "ruleset_mp_" then ruleset = string.sub(ruleset, 12) end + return MP.active_layer_chain(ruleset) +end + +-- The ONLY writer of G.P_*. Called once at run start (game_state.lua). Idempotent +-- by construction: every owned prop is rebuilt from the frozen baseline, so once, +-- twice, or after fifty menu previews all land in exactly the same place. +-- Pass a `key` to limit to one center (parity with old call sites). +function MP.ApplyReworks(ruleset, key) + if MP._PREVIEW_ACTIVE then + error("ApplyReworks called during preview phase — pool mutation is forbidden while previewing") + end + local chain = chain_for(ruleset) + local effective_rarity = {} + + for table_id, tbl in pairs(rework_tables()) do + local keys = key and { key } or reworked_keys(table_id) + for _, k in ipairs(keys) do + local center = tbl[k] + if center and (MP._REWORK_LEDGER[table_id] or {})[k] then + local effective, owned = effective_props(table_id, k, chain) + -- KEY-RESET the union of owned props: effective, else baseline, + -- else nil. No prefix scanning — we touch exactly what reworks own. + local baseline = MP._REWORK_BASELINE[table_id][k] + for prop in pairs(owned) do + if effective[prop] ~= nil then + center[prop] = effective[prop] + else + local b = baseline[prop] + center[prop] = (b and b.present) and deep_copy(b.value) or nil end end + if table_id == "P_CENTERS" and owned.rarity then + effective_rarity[k] = center.rarity + end end end end + + -- One deterministic rebuild after all rarities are live (skip in by-key mode; + -- a single center can't define a consistent whole-pool order). + if not key and next(effective_rarity) then rebuild_rarity_pools(chain, effective_rarity) end +end + +-- Project reworks into an isolated read-only namespace. Never writes G.P_* or the +-- pools; the info panel reads through MP.preview_center(). So previewing ruleset Y +-- leaves no residue for a later game under X — the exact bug this rewrite exists +-- to kill, back when the menu and the run shared one mutable center. +function MP.PreviewReworks(ruleset) + local chain = chain_for(ruleset) + MP._PREVIEW_VIEW = {} + MP._PREVIEW_ACTIVE = true + for table_id in pairs(rework_tables()) do + for _, k in ipairs(reworked_keys(table_id)) do + local effective = effective_props(table_id, k, chain) + MP._PREVIEW_VIEW[table_id] = MP._PREVIEW_VIEW[table_id] or {} + MP._PREVIEW_VIEW[table_id][k] = effective + end + end + MP._PREVIEW_ACTIVE = false +end + +-- The center as the active preview would render it. UI reads through this instead +-- of the live table, so it shows preview numbers without mutating anything. +-- Outside a preview (empty view) it just hands back the live center. +function MP.preview_center(key, center_table) + local table_id = resolve_table_id(center_table) or "P_CENTERS" + local tbl = rework_tables()[table_id] + local live = tbl and tbl[key] + local overlay = (MP._PREVIEW_VIEW[table_id] or {})[key] + if not live or not overlay then return live end + -- Read-through proxy: overlaid props (incl. the merged config the panel + + -- balanced sticker read) come from the projection, the rest fall through to + -- live. Writes hit the throwaway proxy, never live — a Card scribbling on its + -- center can't desync us. (It can still surprise us in other ways: this proxy + -- has no identity in G.P_CENTERS, which is its own small saga elsewhere.) + return setmetatable({}, { + __index = function(_, prop) + local ov = overlay[prop] + if ov ~= nil then return ov end + return live[prop] + end, + }) +end + +-- Backwards-compatible shim for the ~70 old call sites. Menu/replay sites really +-- ought to use PreviewReworks, but routing them through here stays correct — the +-- next ApplyReworks rebuilds from baseline regardless, so the worst case is some +-- wasted work, not a desync. +function MP.LoadReworks(ruleset, key) + MP.ApplyReworks(ruleset, key) +end + +-- inject reworks properly: drain every pending ReworkCenter into the ledger +-- AFTER the real injectItems has registered all centers. +local inject_ref = SMODS.injectItems +function SMODS.injectItems() + local ret = inject_ref() + for _, entry in ipairs(PENDING_REWORKS) do + ingest_rework(entry.key, entry.opts) + end + PENDING_REWORKS = {} + return ret end diff --git a/rulesets/release.lua b/rulesets/release.lua index 82f8be3e..996a257c 100644 --- a/rulesets/release.lua +++ b/rulesets/release.lua @@ -1,12 +1,9 @@ -- reverts gameplay-related changes in the game to the 1.0.0 release version ---[[ MP.Ruleset({ key = "release", layers = { "release" }, }):inject() --- below nonsense should be in the layers equivalent but reworkcenter loads with rulesets so - SMODS.Atlas({ key = "release_jokers", path = "release_jokers.png", @@ -16,45 +13,45 @@ SMODS.Atlas({ MP.ReworkCenter("j_greedy_joker", { layers = "release", - config = {extra = {s_mult = 4, suit = 'Diamonds'}}, + config = { extra = { s_mult = 4, suit = "Diamonds" } }, }) MP.ReworkCenter("j_lusty_joker", { layers = "release", - config = {extra = {s_mult = 4, suit = 'Hearts'}}, + config = { extra = { s_mult = 4, suit = "Hearts" } }, }) MP.ReworkCenter("j_wrathful_joker", { layers = "release", - config = {extra = {s_mult = 4, suit = 'Spades'}}, + config = { extra = { s_mult = 4, suit = "Spades" } }, }) MP.ReworkCenter("j_gluttenous_joker", { layers = "release", - config = {extra = {s_mult = 4, suit = 'Clubs'}}, + config = { extra = { s_mult = 4, suit = "Clubs" } }, }) MP.ReworkCenter("j_mad", { layers = "release", - config = {t_mult = 20, type = 'Four of a Kind'}, + config = { t_mult = 20, type = "Four of a Kind" }, atlas = "mp_release_jokers", }) MP.ReworkCenter("j_clever", { layers = "release", - config = {t_chips = 150, type = 'Four of a Kind'}, + config = { t_chips = 150, type = "Four of a Kind" }, atlas = "mp_release_jokers", }) MP.ReworkCenter("j_banner", { layers = "release", - config = {extra = 40}, + config = { extra = 40 }, }) MP.ReworkCenter("j_8_ball", { layers = "release", loc_key = "j_mp_8ball_release", - config = {extra = 2}, + config = { extra = 2 }, atlas = "mp_release_jokers", loc_vars = function(self, info_queue, card) return { @@ -74,25 +71,25 @@ MP.ReworkCenter("j_8_ball", { if eights >= card.ability.extra then G.GAME.consumeable_buffer = G.GAME.consumeable_buffer + 1 G.E_MANAGER:add_event(Event({ - trigger = 'before', + trigger = "before", delay = 0.0, - func = (function() - local card = create_card('Planet',G.consumeables, nil, nil, nil, nil, nil, '8ba') + func = function() + local card = create_card("Planet", G.consumeables, nil, nil, nil, nil, nil, "8ba") card:add_to_deck() G.consumeables:emplace(card) G.GAME.consumeable_buffer = 0 return true - end) + end, })) return { - message = localize('k_plus_planet'), + message = localize("k_plus_planet"), colour = G.C.SECONDARY_SET.Planet, - card = card + card = card, } end end end - end + end, }) MP.ReworkCenter("j_fibonacci", { @@ -102,22 +99,22 @@ MP.ReworkCenter("j_fibonacci", { MP.ReworkCenter("j_steel_joker", { layers = "release", - config = {extra = 0.25}, + config = { extra = 0.25 }, }) MP.ReworkCenter("j_gros_michel", { layers = "release", - config = {extra = {odds = 4, mult = 15}}, + config = { extra = { odds = 4, mult = 15 } }, }) MP.ReworkCenter("j_odd_todd", { layers = "release", - config = {extra = 30}, + config = { extra = 30 }, }) MP.ReworkCenter("j_runner", { layers = "release", - config = {extra = {chips = 20, chip_mod = 10}}, + config = { extra = { chips = 20, chip_mod = 10 } }, }) MP.ReworkCenter("j_sixth_sense", { @@ -127,13 +124,13 @@ MP.ReworkCenter("j_sixth_sense", { MP.ReworkCenter("j_hiker", { layers = "release", - config = {extra = 4}, + config = { extra = 4 }, }) MP.ReworkCenter("j_todo_list", { layers = "release", loc_key = "j_mp_todo_list_release", - config = {extra = {dollars = 5, poker_hand = 'High Card'}}, + config = { extra = { dollars = 5, poker_hand = "High Card" } }, calculate = function(self, card, context) if context.end_of_round then -- stops to-do list from changing return nil, true @@ -145,21 +142,28 @@ MP.ReworkCenter("j_todo_list", { func = function() local _poker_hands = {} for k, v in pairs(G.GAME.hands) do - if v.visible and k ~= card.ability.to_do_poker_hand then _poker_hands[#_poker_hands+1] = k end + if v.visible and k ~= card.ability.to_do_poker_hand then + _poker_hands[#_poker_hands + 1] = k + end end - card.ability.to_do_poker_hand = pseudorandom_element(_poker_hands, pseudoseed('to_do')) + card.ability.to_do_poker_hand = pseudorandom_element(_poker_hands, pseudoseed("to_do")) return true - end + end, })) G.GAME.dollar_buffer = (G.GAME.dollar_buffer or 0) + card.ability.extra.dollars - G.E_MANAGER:add_event(Event({func = (function() G.GAME.dollar_buffer = 0; return true end)})) + G.E_MANAGER:add_event(Event({ + func = function() + G.GAME.dollar_buffer = 0 + return true + end, + })) return { - dollars = card.ability.extra.dollars + dollars = card.ability.extra.dollars, } end return nil, true end - end + end, }) MP.ReworkCenter("j_madness", { @@ -170,28 +174,46 @@ MP.ReworkCenter("j_madness", { card.ability.x_mult = card.ability.x_mult + card.ability.extra local destructable_jokers = {} for i = 1, #G.jokers.cards do - if G.jokers.cards[i] ~= card and not G.jokers.cards[i].ability.eternal and not G.jokers.cards[i].getting_sliced then destructable_jokers[#destructable_jokers+1] = G.jokers.cards[i] end + if + G.jokers.cards[i] ~= card + and not G.jokers.cards[i].ability.eternal + and not G.jokers.cards[i].getting_sliced + then + destructable_jokers[#destructable_jokers + 1] = G.jokers.cards[i] + end end - local joker_to_destroy = #destructable_jokers > 0 and pseudorandom_element(destructable_jokers, pseudoseed('madness')) or nil + local joker_to_destroy = #destructable_jokers > 0 + and pseudorandom_element(destructable_jokers, pseudoseed("madness")) + or nil - if joker_to_destroy and not (context.blueprint_card or card).getting_sliced then + if joker_to_destroy and not (context.blueprint_card or card).getting_sliced then joker_to_destroy.getting_sliced = true - G.E_MANAGER:add_event(Event({func = function() - (context.blueprint_card or card):juice_up(0.8, 0.8) - joker_to_destroy:start_dissolve({G.C.RED}, nil, 1.6) - return true end })) + G.E_MANAGER:add_event(Event({ + func = function() + (context.blueprint_card or card):juice_up(0.8, 0.8) + joker_to_destroy:start_dissolve({ G.C.RED }, nil, 1.6) + return true + end, + })) end if not (context.blueprint_card or card).getting_sliced then - card_eval_status_text((context.blueprint_card or card), 'extra', nil, nil, nil, {message = localize{type = 'variable', key = 'a_xmult', vars = {card.ability.x_mult}}}) + card_eval_status_text( + (context.blueprint_card or card), + "extra", + nil, + nil, + nil, + { message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.x_mult } }) } + ) end return nil, true end - end + end, }) MP.ReworkCenter("j_square", { layers = "release", - config = {extra = {chips = 16, chip_mod = 4}}, + config = { extra = { chips = 16, chip_mod = 4 } }, cost = 5, atlas = "mp_release_jokers", }) @@ -224,30 +246,34 @@ MP.ReworkCenter("j_riff_raff", { MP.ReworkCenter("j_vampire", { layers = "release", loc_key = "j_mp_vampire_release", - config = {extra = 0.2, Xmult = 1}, + config = { extra = 0.2, Xmult = 1 }, calculate = function(self, card, context) -- this one is copied from vremade instead of vanilla if context.before and not context.blueprint then local enhanced = {} for _, scored_card in ipairs(context.full_hand) do - if next(SMODS.get_enhancements(scored_card)) and not scored_card.debuff and not scored_card.vampired then + if + next(SMODS.get_enhancements(scored_card)) + and not scored_card.debuff + and not scored_card.vampired + then enhanced[#enhanced + 1] = scored_card scored_card.vampired = true - scored_card:set_ability('c_base', nil, true) + scored_card:set_ability("c_base", nil, true) G.E_MANAGER:add_event(Event({ func = function() scored_card:juice_up() scored_card.vampired = nil return true - end + end, })) end end if #enhanced > 0 then card.ability.Xmult = card.ability.Xmult + card.ability.extra * #enhanced return { - message = localize { type = 'variable', key = 'a_xmult', vars = { card.ability.Xmult } }, - colour = G.C.MULT + message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.Xmult } }), + colour = G.C.MULT, } end return nil, true @@ -258,7 +284,7 @@ MP.ReworkCenter("j_vampire", { MP.ReworkCenter("j_vagabond", { layers = "release", rarity = 2, - config = {extra = 3}, + config = { extra = 3 }, cost = 6, }) @@ -278,21 +304,19 @@ MP.ReworkCenter("j_midas_mask", { for _, scored_card in ipairs(context.full_hand) do if scored_card:is_face() then faces = faces + 1 - scored_card:set_ability('m_gold', nil, true) + scored_card:set_ability("m_gold", nil, true) G.E_MANAGER:add_event(Event({ func = function() scored_card:juice_up() return true - end + end, })) end end - if faces > 0 then - return { - message = localize('k_gold'), - colour = G.C.MONEY - } - end + if faces > 0 then return { + message = localize("k_gold"), + colour = G.C.MONEY, + } end return nil, true end end, @@ -310,12 +334,12 @@ MP.ReworkCenter("j_reserved_parking", { MP.ReworkCenter("j_mail", { layers = "release", - config = {extra = 3}, + config = { extra = 3 }, }) MP.ReworkCenter("j_lucky_cat", { layers = "release", - config = {Xmult = 1, extra = 0.2}, + config = { Xmult = 1, extra = 0.2 }, }) MP.ReworkCenter("j_trading", { @@ -325,23 +349,23 @@ MP.ReworkCenter("j_trading", { MP.ReworkCenter("j_smiley", { layers = "release", - config = {extra = 4}, + config = { extra = 4 }, }) MP.ReworkCenter("j_campfire", { layers = "release", - config = {extra = 0.5}, + config = { extra = 0.5 }, }) MP.ReworkCenter("j_ticket", { layers = "release", - config = {extra = 3}, + config = { extra = 3 }, }) MP.ReworkCenter("j_swashbuckler", { layers = "release", loc_key = "j_mp_swashbuckler_release", - config = {mult = 1, release = true}, + config = { mult = 1, release = true }, }) local card_update_ref = Card.update @@ -362,22 +386,22 @@ end MP.ReworkCenter("j_hanging_chad", { layers = "release", loc_key = "j_mp_hanging_chad_release", - config = {extra = 1}, + config = { extra = 1 }, }) MP.ReworkCenter("j_bloodstone", { layers = "release", - config = {extra = {odds = 3, Xmult = 2}}, + config = { extra = { odds = 3, Xmult = 2 } }, }) MP.ReworkCenter("j_onyx_agate", { layers = "release", - config = {extra = 8}, + config = { extra = 8 }, }) MP.ReworkCenter("j_glass", { layers = "release", - config = {extra = 0.5, Xmult = 1}, + config = { extra = 0.5, Xmult = 1 }, }) MP.ReworkCenter("j_flower_pot", { @@ -386,34 +410,41 @@ MP.ReworkCenter("j_flower_pot", { calculate = function(self, card, context) if context.joker_main then local suits = { - ['Hearts'] = 0, - ['Diamonds'] = 0, - ['Spades'] = 0, - ['Clubs'] = 0 + ["Hearts"] = 0, + ["Diamonds"] = 0, + ["Spades"] = 0, + ["Clubs"] = 0, } for i = 1, #context.scoring_hand do - if context.scoring_hand[i].ability.name ~= 'Wild Card' then - if context.scoring_hand[i]:is_suit('Hearts') and suits["Hearts"] == 0 then suits["Hearts"] = suits["Hearts"] + 1 - elseif context.scoring_hand[i]:is_suit('Diamonds') and suits["Diamonds"] == 0 then suits["Diamonds"] = suits["Diamonds"] + 1 - elseif context.scoring_hand[i]:is_suit('Spades') and suits["Spades"] == 0 then suits["Spades"] = suits["Spades"] + 1 - elseif context.scoring_hand[i]:is_suit('Clubs') and suits["Clubs"] == 0 then suits["Clubs"] = suits["Clubs"] + 1 end + if context.scoring_hand[i].ability.name ~= "Wild Card" then + if context.scoring_hand[i]:is_suit("Hearts") and suits["Hearts"] == 0 then + suits["Hearts"] = suits["Hearts"] + 1 + elseif context.scoring_hand[i]:is_suit("Diamonds") and suits["Diamonds"] == 0 then + suits["Diamonds"] = suits["Diamonds"] + 1 + elseif context.scoring_hand[i]:is_suit("Spades") and suits["Spades"] == 0 then + suits["Spades"] = suits["Spades"] + 1 + elseif context.scoring_hand[i]:is_suit("Clubs") and suits["Clubs"] == 0 then + suits["Clubs"] = suits["Clubs"] + 1 + end end end for i = 1, #context.scoring_hand do - if context.scoring_hand[i].ability.name == 'Wild Card' then - if context.scoring_hand[i]:is_suit('Hearts') and suits["Hearts"] == 0 then suits["Hearts"] = suits["Hearts"] + 1 - elseif context.scoring_hand[i]:is_suit('Diamonds') and suits["Diamonds"] == 0 then suits["Diamonds"] = suits["Diamonds"] + 1 - elseif context.scoring_hand[i]:is_suit('Spades') and suits["Spades"] == 0 then suits["Spades"] = suits["Spades"] + 1 - elseif context.scoring_hand[i]:is_suit('Clubs') and suits["Clubs"] == 0 then suits["Clubs"] = suits["Clubs"] + 1 end + if context.scoring_hand[i].ability.name == "Wild Card" then + if context.scoring_hand[i]:is_suit("Hearts") and suits["Hearts"] == 0 then + suits["Hearts"] = suits["Hearts"] + 1 + elseif context.scoring_hand[i]:is_suit("Diamonds") and suits["Diamonds"] == 0 then + suits["Diamonds"] = suits["Diamonds"] + 1 + elseif context.scoring_hand[i]:is_suit("Spades") and suits["Spades"] == 0 then + suits["Spades"] = suits["Spades"] + 1 + elseif context.scoring_hand[i]:is_suit("Clubs") and suits["Clubs"] == 0 then + suits["Clubs"] = suits["Clubs"] + 1 + end end end - if suits["Hearts"] > 0 and - suits["Diamonds"] > 0 and - suits["Spades"] > 0 and - suits["Clubs"] > 0 then + if suits["Hearts"] > 0 and suits["Diamonds"] > 0 and suits["Spades"] > 0 and suits["Clubs"] > 0 then return { - message = localize{type='variable',key='a_xmult',vars={card.ability.extra}}, - Xmult_mod = card.ability.extra + message = localize({ type = "variable", key = "a_xmult", vars = { card.ability.extra } }), + Xmult_mod = card.ability.extra, } end return nil, true @@ -423,19 +454,19 @@ MP.ReworkCenter("j_flower_pot", { MP.ReworkCenter("j_wee", { layers = "release", - config = {extra = {chips = 10, chip_mod = 8}}, + config = { extra = { chips = 10, chip_mod = 8 } }, }) MP.ReworkCenter("j_stuntman", { layers = "release", rarity = 2, - config = {extra = {h_size = 2, chip_mod = 300}}, + config = { extra = { h_size = 2, chip_mod = 300 } }, cost = 6, }) MP.ReworkCenter("j_invisible", { layers = "release", - config = {extra = 3}, + config = { extra = 3 }, cost = 10, }) @@ -448,7 +479,7 @@ MP.ReworkCenter("j_burnt", { MP.ReworkCenter("j_yorick", { layers = "release", loc_key = "j_mp_yorick_release", - config = {extra = {xmult = 5, discards = 23}}, + config = { extra = { xmult = 5, discards = 23 } }, calculate = function(self, card, context) if context.discard then if card.ability.yorick_discards > 0 and not card.ability.yorick_tallied and not context.blueprint then @@ -458,22 +489,35 @@ MP.ReworkCenter("j_yorick", { card.ability.yorick_tallied = nil card.ability.yorick_discards = card.ability.yorick_discards - 1 if card.ability.yorick_discards == 0 then - card_eval_status_text(card, 'extra', nil, nil, nil, {message = localize('k_active_ex'),colour = G.C.FILTER, delay = 0.45}) + card_eval_status_text( + card, + "extra", + nil, + nil, + nil, + { message = localize("k_active_ex"), colour = G.C.FILTER, delay = 0.45 } + ) else - card_eval_status_text(card, 'extra', nil, nil, nil, {message = localize{type='variable',key='a_remaining',vars={card.ability.yorick_discards}},colour = G.C.FILTER, delay = 0.45}) + card_eval_status_text(card, "extra", nil, nil, nil, { + message = localize({ + type = "variable", + key = "a_remaining", + vars = { card.ability.yorick_discards }, + }), + colour = G.C.FILTER, + delay = 0.45, + }) end return true - end + end, })) end return nil, true end if context.joker_main then - if card.ability.yorick_discards <= 0 then - return { - xmult = card.ability.extra.xmult - } - end + if card.ability.yorick_discards <= 0 then return { + xmult = card.ability.extra.xmult, + } end return nil, true end end, @@ -482,12 +526,15 @@ MP.ReworkCenter("j_yorick", { MP.ReworkCenter("c_magician", { layers = "release", loc_key = "c_mp_magician_release", - config = {mod_conv = "m_lucky", max_highlighted = 1}, + config = { mod_conv = "m_lucky", max_highlighted = 1 }, -- don't understand why we need to redefine loc_vars here loc_vars = function(self, info_queue, card) - info_queue[#info_queue+1] = G.P_CENTERS[self.config.mod_conv] + info_queue[#info_queue + 1] = G.P_CENTERS[self.config.mod_conv] return { - vars = { self.config.max_highlighted, localize{type = 'name_text', set = 'Enhanced', key = self.config.mod_conv} }, + vars = { + self.config.max_highlighted, + localize({ type = "name_text", set = "Enhanced", key = self.config.mod_conv }), + }, } end, }) @@ -497,11 +544,11 @@ MP.ReworkCenter("tag_uncommon", { center_table = "P_TAGS", loc_key = "tag_mp_uncommon_release", apply = function(self, tag, context) - if context.type == 'store_joker_create' then - local card = create_card('Joker', context.area, nil, 0.9, nil, nil, nil, 'uta') - create_shop_card_ui(card, 'Joker', context.area) + if context.type == "store_joker_create" then + local card = create_card("Joker", context.area, nil, 0.9, nil, nil, nil, "uta") + create_shop_card_ui(card, "Joker", context.area) card.states.visible = false - tag:yep('+', G.C.GREEN,function() + tag:yep("+", G.C.GREEN, function() card:start_materialize() return true end) @@ -516,21 +563,21 @@ MP.ReworkCenter("tag_rare", { center_table = "P_TAGS", loc_key = "tag_mp_rare_release", apply = function(self, tag, context) - if context.type == 'store_joker_create' then + if context.type == "store_joker_create" then local card = nil - local rares_in_posession = {0} + local rares_in_posession = { 0 } for k, v in ipairs(G.jokers.cards) do if v.config.center.rarity == 3 and not rares_in_posession[v.config.center.key] then - rares_in_posession[1] = rares_in_posession[1] + 1 + rares_in_posession[1] = rares_in_posession[1] + 1 rares_in_posession[v.config.center.key] = true end end - if #G.P_JOKER_RARITY_POOLS[3] > rares_in_posession[1] then - card = create_card('Joker', context.area, nil, 1, nil, nil, nil, 'rta') - create_shop_card_ui(card, 'Joker', context.area) + if #G.P_JOKER_RARITY_POOLS[3] > rares_in_posession[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() + tag:yep("+", G.C.RED, function() card:start_materialize() return true end) @@ -548,13 +595,18 @@ MP.ReworkCenter("tag_negative", { center_table = "P_TAGS", loc_key = "tag_mp_negative_release", apply = function(self, tag, context) - if context.type == 'store_joker_modify' and not context.card.edition and not context.card.temp_edition and context.card.ability.set == 'Joker' then + if + context.type == "store_joker_modify" + and not context.card.edition + and not context.card.temp_edition + and context.card.ability.set == "Joker" + then local lock = tag.ID G.CONTROLLER.locks[lock] = true context.card.temp_edition = true - tag:yep('+', G.C.DARK_EDITION,function() + tag:yep("+", G.C.DARK_EDITION, function() context.card.temp_edition = nil - context.card:set_edition({negative = true}, true) + context.card:set_edition({ negative = true }, true) G.CONTROLLER.locks[lock] = nil return true end) @@ -569,13 +621,18 @@ MP.ReworkCenter("tag_foil", { center_table = "P_TAGS", loc_key = "tag_mp_foil_release", apply = function(self, tag, context) - if context.type == 'store_joker_modify' and not context.card.edition and not context.card.temp_edition and context.card.ability.set == 'Joker' then + if + context.type == "store_joker_modify" + and not context.card.edition + and not context.card.temp_edition + and context.card.ability.set == "Joker" + then local lock = tag.ID G.CONTROLLER.locks[lock] = true context.card.temp_edition = true - tag:yep('+', G.C.DARK_EDITION,function() + tag:yep("+", G.C.DARK_EDITION, function() context.card.temp_edition = nil - context.card:set_edition({foil = true}, true) + context.card:set_edition({ foil = true }, true) G.CONTROLLER.locks[lock] = nil return true end) @@ -590,13 +647,18 @@ MP.ReworkCenter("tag_holo", { center_table = "P_TAGS", loc_key = "tag_mp_holo_release", apply = function(self, tag, context) - if context.type == 'store_joker_modify' and not context.card.edition and not context.card.temp_edition and context.card.ability.set == 'Joker' then + if + context.type == "store_joker_modify" + and not context.card.edition + and not context.card.temp_edition + and context.card.ability.set == "Joker" + then local lock = tag.ID G.CONTROLLER.locks[lock] = true context.card.temp_edition = true - tag:yep('+', G.C.DARK_EDITION,function() + tag:yep("+", G.C.DARK_EDITION, function() context.card.temp_edition = nil - context.card:set_edition({holo = true}, true) + context.card:set_edition({ holo = true }, true) G.CONTROLLER.locks[lock] = nil return true end) @@ -611,13 +673,18 @@ MP.ReworkCenter("tag_polychrome", { center_table = "P_TAGS", loc_key = "tag_mp_poly_release", apply = function(self, tag, context) - if context.type == 'store_joker_modify' and not context.card.edition and not context.card.temp_edition and context.card.ability.set == 'Joker' then + if + context.type == "store_joker_modify" + and not context.card.edition + and not context.card.temp_edition + and context.card.ability.set == "Joker" + then local lock = tag.ID G.CONTROLLER.locks[lock] = true context.card.temp_edition = true - tag:yep('+', G.C.DARK_EDITION,function() + tag:yep("+", G.C.DARK_EDITION, function() context.card.temp_edition = nil - context.card:set_edition({polychrome = true}, true) + context.card:set_edition({ polychrome = true }, true) G.CONTROLLER.locks[lock] = nil return true end) @@ -643,7 +710,7 @@ end MP.ReworkCenter("tag_investment", { layers = "release", center_table = "P_TAGS", - config = {type = 'eval', dollars = 15}, + config = { type = "eval", dollars = 15 }, }) MP.ReworkCenter("Blue", { @@ -655,26 +722,31 @@ MP.ReworkCenter("Blue", { local card_get_end_of_round_effect_ref = Card.get_end_of_round_effect function Card:get_end_of_round_effect(context) - if self.seal == "Blue" and G.P_SEALS["Blue"].release then - self.seal = "Not Blue Lmao" - end + if self.seal == "Blue" and G.P_SEALS["Blue"].release then self.seal = "Not Blue Lmao" end local ret = card_get_end_of_round_effect_ref(self, context) if self.seal == "Not Blue Lmao" then if #G.consumeables.cards + G.GAME.consumeable_buffer < G.consumeables.config.card_limit then - local card_type = 'Planet' + local card_type = "Planet" G.GAME.consumeable_buffer = G.GAME.consumeable_buffer + 1 G.E_MANAGER:add_event(Event({ - trigger = 'before', + trigger = "before", delay = 0.0, - func = (function() - local card = create_card(card_type,G.consumeables, nil, nil, nil, nil, nil, 'blusl') + func = function() + local card = create_card(card_type, G.consumeables, nil, nil, nil, nil, nil, "blusl") card:add_to_deck() G.consumeables:emplace(card) G.GAME.consumeable_buffer = 0 return true - end) + end, })) - card_eval_status_text(self, 'extra', nil, nil, nil, {message = localize('k_plus_planet'), colour = G.C.SECONDARY_SET.Planet}) + card_eval_status_text( + self, + "extra", + nil, + nil, + nil, + { message = localize("k_plus_planet"), colour = G.C.SECONDARY_SET.Planet } + ) ret.effect = true end self.seal = "Blue" @@ -690,7 +762,7 @@ MP.ReworkCenter("Straight", { -- no behaviour change, just so it shows the sticker MP.ReworkCenter("c_saturn", { - layers = "release" + layers = "release", }) MP.ReworkCenter("Straight Flush", { @@ -746,18 +818,32 @@ function get_blind_amount(ante) if G.GAME.mp_release_scaling then -- if green then local amounts = { - 300, 1000, 3200, 9000, 18000, 32000, 56000, 90000 + 300, + 1000, + 3200, + 9000, + 18000, + 32000, + 56000, + 90000, } if G.GAME.mp_release_scaling == "purple" then amounts = { - 300, 1200, 3600, 10000, 25000, 50000, 90000, 180000 + 300, + 1200, + 3600, + 10000, + 25000, + 50000, + 90000, + 180000, } end if ante < 1 then return 100 end if ante <= 8 then return amounts[ante] end - local a, b, c, d = amounts[8],1.6,ante-8, 1 + 0.2*(ante-8) - local amount = math.floor(a*(b+(0.75*c)^d)^c) - amount = amount - amount%(10^math.floor(math.log10(amount)-1)) + local a, b, c, d = amounts[8], 1.6, ante - 8, 1 + 0.2 * (ante - 8) + local amount = math.floor(a * (b + (0.75 * c) ^ d) ^ c) + amount = amount - amount % (10 ^ math.floor(math.log10(amount) - 1)) return amount end return get_blind_amount_ref(ante) @@ -782,17 +868,16 @@ MP.ReworkCenter("stake_gold", { }) -- there's an incredibly obscure crash directly caused by adding any sort of function or recursive table to the blind center, so this will crash the game even if the ruleset isn't loaded. i cba to figure out why at this point -MP.ReworkCenter("bl_arm", { - layers = "release", - center_table = "P_BLINDS", - debuff_hand = function(self, cards, hand, handname, check) - if G.GAME.hands[handname].level > 0 then - G.GAME.blind.triggered = true - if not check then - level_up_hand(G.GAME.blind.children.animatedSprite, handname, nil, -1) - G.GAME.blind:wiggle() - end - end - end, -}) -]] +-- MP.ReworkCenter("bl_arm", { +-- layers = "release", +-- center_table = "P_BLINDS", +-- debuff_hand = function(self, cards, hand, handname, check) +-- if G.GAME.hands[handname].level > 0 then +-- G.GAME.blind.triggered = true +-- if not check then +-- level_up_hand(G.GAME.blind.children.animatedSprite, handname, nil, -1) +-- G.GAME.blind:wiggle() +-- end +-- end +-- end, +-- }) diff --git a/tests/readme.md b/tests/readme.md index 9ccaebfc..121c0f5d 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -31,4 +31,17 @@ After `capture`, review the diff in `tests/ruleset_snapshot.lua` before committi - Function bodies (only whether a function is defined) - Runtime behavior (ApplyBans hook chains, smallworld cull logic, speedlatro timer) -- Rework center definitions (`MP.ReworkCenter` calls) +- Rework center definitions (`MP.ReworkCenter` calls) — see the rework determinism test below + +## Rework Determinism + Desync-Safety + +`test_rework_determinism.lua` backstops the part the shape snapshot explicitly skips: the `MP.ReworkCenter` / `MP.ApplyReworks` / `MP.PreviewReworks` center-mutation path. It loads the real `rulesets/_rulesets.lua`, registers the shipped reworks (the multi-layer `m_glass`, the rarity-bumped `j_sixth_sense`), then drives the mechanism through hostile call histories (preview ruleset Y then apply X, apply twice, fake menu cycles) and asserts: + +- **D1 — determinism:** a reworked center's effective props are a pure function of the resolved context (ruleset + layers + modifiers), identical regardless of preview/cycle/call-count history. +- **D2 — desync-safety:** the rarity-pool *order* is byte-identical across divergent histories; the membership predicate matches vanilla `game.lua` (`not wip`, `not demo`, `set == 'Joker'`); previews never mutate live centers or pools; the frozen baseline rejects writes. + +Like the shape snapshot it is necessary-not-sufficient — it stubs game globals rather than running the real engine — but it exercises the mutation path the shape test cannot. + +```bash +lua tests/test_rework_determinism.lua +``` diff --git a/tests/ruleset_shape.snapshot.lua b/tests/ruleset_shape.snapshot.lua index 05a10c4a..1759b197 100644 --- a/tests/ruleset_shape.snapshot.lua +++ b/tests/ruleset_shape.snapshot.lua @@ -175,7 +175,6 @@ return { "m_glass", }, ["reworked_jokers"] = { - "j_mp_alloy_sandbox", "j_mp_ambrosia_sandbox", "j_mp_bloodstone", "j_mp_bobby_sandbox", @@ -253,7 +252,6 @@ return { "c_ouija", "j_bloodstone", "j_hanging_chad", - "j_idol", "j_selzer", "j_ticket", "j_todo_list", @@ -269,12 +267,11 @@ return { }, ["reworked_enhancements"] = { "m_glass", - "m_gold", }, ["reworked_jokers"] = { + "j_idol", "j_mp_bloodstone", "j_mp_hanging_chad", - "j_mp_idol_rare", "j_mp_seltzer", "j_mp_ticket_experimental", "j_mp_todo_list", @@ -311,7 +308,6 @@ return { ["banned_tags"] = {}, ["banned_vouchers"] = {}, ["forced_gamemode"] = "gamemode_mp_attrition", - ["forced_lobby_options"] = true, ["key"] = "experimental_legacy", ["multiplayer_content"] = true, ["reworked_blinds"] = {}, @@ -400,6 +396,29 @@ return { ["reworked_tags"] = {}, ["reworked_vouchers"] = {}, }, + ["release"] = { + ["_has_functions"] = { + ["create_info_menu"] = true, + ["force_lobby_options"] = true, + ["is_disabled"] = true, + }, + ["banned_blinds"] = {}, + ["banned_consumables"] = {}, + ["banned_enhancements"] = {}, + ["banned_jokers"] = {}, + ["banned_silent"] = {}, + ["banned_tags"] = {}, + ["banned_vouchers"] = {}, + ["key"] = "release", + ["multiplayer_content"] = false, + ["reworked_blinds"] = {}, + ["reworked_consumables"] = {}, + ["reworked_enhancements"] = {}, + ["reworked_jokers"] = {}, + ["reworked_tags"] = {}, + ["reworked_vouchers"] = {}, + ["spectral_banned_enhancements"] = {}, + }, ["sandbox"] = { ["_has_functions"] = { ["create_info_menu"] = true, @@ -446,7 +465,6 @@ return { "m_glass", }, ["reworked_jokers"] = { - "j_mp_alloy_sandbox", "j_mp_ambrosia_sandbox", "j_mp_bobby_sandbox", "j_mp_candynecklace_sandbox", diff --git a/tests/test_rework_determinism.lua b/tests/test_rework_determinism.lua new file mode 100644 index 00000000..61b87ea9 --- /dev/null +++ b/tests/test_rework_determinism.lua @@ -0,0 +1,272 @@ +--[[ + Rework determinism + desync-safety test. + + Backstops the design contract of MP.ReworkCenter / MP.ApplyReworks / + MP.PreviewReworks: the EFFECTIVE properties of a reworked center (and the + rarity-pool ORDER pool generation reads) must be a PURE FUNCTION of the + resolved context (ruleset + layers + modifiers), identical on two clients no + matter what either previewed/cycled first. + + It loads the REAL rulesets/_rulesets.lua, stubbing only the globals it touches, + registers the same reworks the shipped object files do (m_glass multi-layer, + j_sixth_sense rarity bump), then drives the mechanism through hostile call + histories and asserts the results agree byte-for-byte. + + Necessary-not-sufficient, like the shape snapshot: it exercises the + center-mutation path the shape test explicitly skips. + + Run from the repo root: + lua tests/test_rework_determinism.lua +]] + +local RULESETS = "rulesets/_rulesets.lua" + +-- ─── Tiny assert framework ────────────────────────────────────────────────── +local pass, fail, failures = 0, 0, {} +local function check(label, cond) + if cond then + pass = pass + 1 + else + fail = fail + 1 + failures[#failures + 1] = label + end +end + +-- ─── Minimal stubs (mirror tests/test_ruleset_shape.lua's approach) ────────── +function sendDebugMessage() end + +SMODS = { + injectItems = function() end, -- the real graft in _rulesets.lua wraps this + Center = { generate_ui = function() end }, + PokerHands = {}, + process_loc_text = function() end, + remove_pool = function() end, + GameObject = { + extend = function(_, tbl) + local cls = {} + for k, v in pairs(tbl) do cls[k] = v end + setmetatable(cls, { __call = function(_c, init) + local o = {} + for k, v in pairs(cls) do o[k] = v end + for k, v in pairs(init) do o[k] = v end + return o + end }) + return cls + end, + }, +} + +-- Two subjects + filler/edge jokers so the rarity rebuild has something to get +-- wrong (equal-order ties, a WIP joker, a demo joker, a non-joker decoy). +local function fresh_centers() + return { + m_glass = { key = "m_glass", set = "Enhanced", config = { Xmult = 2, extra = 5 } }, + j_sixth_sense = { key = "j_sixth_sense", set = "Joker", rarity = 1, order = 30 }, + j_common_a = { key = "j_common_a", set = "Joker", rarity = 1, order = 10 }, + j_common_b = { key = "j_common_b", set = "Joker", rarity = 1, order = 50 }, + j_rare_a = { key = "j_rare_a", set = "Joker", rarity = 3, order = 20 }, + j_rare_b = { key = "j_rare_b", set = "Joker", rarity = 3, order = 40 }, + j_rare_tie1 = { key = "j_rare_tie1", set = "Joker", rarity = 3, order = 30 }, + j_rare_tie2 = { key = "j_rare_tie2", set = "Joker", rarity = 3, order = 30 }, + j_wip = { key = "j_wip", set = "Joker", rarity = 3, order = 5, wip = true }, + j_demo = { key = "j_demo", set = "Joker", rarity = 3, order = 6, demo = true }, + c_decoy = { key = "c_decoy", set = "Tarot", rarity = 3, order = 7 }, + } +end + +G = { + P_CENTER_POOLS = { Ruleset = {} }, + localization = { descriptions = { Ruleset = {} } }, + FUNCS = {}, + P_TAGS = {}, + P_SEALS = {}, + P_STAKES = {}, + P_BLINDS = {}, + P_CENTERS = fresh_centers(), + P_JOKER_RARITY_POOLS = { {}, {}, {}, {} }, +} + +MP = { + LOBBY = { config = {} }, + SP = {}, + UI = {}, + Layers = {}, + Rulesets = {}, + MODIFIERS = {}, + _JOKER_LAYERS = {}, + _CONSUMABLE_LAYERS = {}, + _TAG_LAYERS = {}, + _LAYER_ARRAY_FIELDS = { + "banned_jokers", "banned_consumables", "banned_vouchers", + "banned_enhancements", "banned_tags", "banned_blinds", "banned_silent", + "reworked_jokers", "reworked_consumables", "reworked_vouchers", + "reworked_enhancements", "reworked_tags", "reworked_blinds", + "spectral_banned_enhancements", "stickers", + }, +} +MP.is_practice_mode = function() return false end +MP.GHOST = { is_active = function() return false end } +MP.Layer = function(name, def) MP.Layers[name] = def end +MP.UTILS = setmetatable({}, { __index = function() return function() return false end end }) +copy_table = function(t) local o = {} for k, v in pairs(t) do o[k] = v end return o end + +-- ─── Load the REAL mechanism ──────────────────────────────────────────────── +local chunk, err = loadfile(RULESETS) +if not chunk then + io.stderr:write("ERROR: cannot load " .. RULESETS .. " (run from repo root): " .. tostring(err) .. "\n") + os.exit(1) +end +chunk() + +-- Layers our subjects target + the rulesets that compose them. +for _, n in ipairs({ "standard", "classic", "sandbox", "release", "mod_glassbump" }) do MP.Layer(n, {}) end +local function ruleset(short, layer_order) + MP.Rulesets["ruleset_mp_" .. short] = { key = "ruleset_mp_" .. short, _layer_order = layer_order } +end +ruleset("standard_ctx", { "standard" }) +ruleset("classic_ctx", { "classic" }) +ruleset("sandbox_ctx", { "sandbox" }) +ruleset("release_ctx", { "release" }) +ruleset("vanilla_ctx", {}) +ruleset("chaos_ctx", { "standard", "classic", "sandbox", "release" }) + +-- (Re)register the shipped reworks against fresh centers, then drain into the +-- ledger via the real injectItems graft. +local function register_reworks() + MP._REWORK_BASELINE, MP._REWORK_LEDGER, MP._REWORK_OWNED = {}, {}, {} + MP.ReworkCenter("m_glass", { layers = { "standard", "classic" }, config = { Xmult = 1.5, extra = 4 } }) + MP.ReworkCenter("m_glass", { layers = "sandbox", config = { Xmult = 1.5, extra = 3 } }) + MP.ReworkCenter("j_sixth_sense", { layers = "release", rarity = 3 }) + MP.ReworkCenter("m_glass", { layers = "mod_glassbump", config = { extra = 9 } }) + SMODS.injectItems() +end + +-- ─── Read helpers ─────────────────────────────────────────────────────────── +local function glass() local c = G.P_CENTERS.m_glass.config return c.Xmult, c.extra, c.mp_balanced end +local function bucket_keys(b) + local o = {} + for i, c in ipairs(G.P_JOKER_RARITY_POOLS[b]) do o[i] = c.key end + return table.concat(o, ",") +end +local function all_pools() + local p = {} + for b = 1, 4 do p[b] = "[" .. b .. "]" .. bucket_keys(b) end + return table.concat(p, " | ") +end +local function set_live(short) MP.LOBBY.config.ruleset = short and ("ruleset_mp_" .. short) or nil end + +-- Resolve m_glass (config) and the pools after `history`, finalized on `final`. +-- history = list of { preview=short } or { apply=short }. +local function resolve(history, final) + G.P_CENTERS = fresh_centers() + G.P_JOKER_RARITY_POOLS = { {}, {}, {}, {} } + register_reworks() + for _, step in ipairs(history) do + if step.preview then + MP.PreviewReworks("ruleset_mp_" .. step.preview) + else + set_live(step.apply) + MP.ApplyReworks("ruleset_mp_" .. step.apply) + end + end + set_live(final) + MP.ApplyReworks("ruleset_mp_" .. final) + local x, e = glass() + return { glass = x .. "/" .. e, pools = all_pools(), rarity = G.P_CENTERS.j_sixth_sense.rarity } +end + +-- ─── D1: effective props are a pure function of context ───────────────────── +local std = resolve({}, "standard_ctx") +check("standard => m_glass 1.5/4", std.glass == "1.5/4") +check("standard => mp_balanced set", (function() return select(3, glass()) end)()) +check("sandbox => m_glass 1.5/3", resolve({}, "sandbox_ctx").glass == "1.5/3") +check("classic => m_glass 1.5/4", resolve({}, "classic_ctx").glass == "1.5/4") +check("vanilla => m_glass restored 2/5", resolve({}, "vanilla_ctx").glass == "2/5") + +-- Same finalize context, hostile histories => identical result. +check("standard after preview(sandbox) == standard", resolve({ { preview = "sandbox_ctx" } }, "standard_ctx").glass == std.glass) +check("standard after apply(sandbox) == standard", resolve({ { apply = "sandbox_ctx" } }, "standard_ctx").glass == std.glass) +check("standard after menu-cycle previews == standard", + resolve({ { preview = "classic_ctx" }, { preview = "sandbox_ctx" }, { preview = "vanilla_ctx" } }, "standard_ctx").glass == std.glass) +check("standard after apply(chaos) == standard", resolve({ { apply = "chaos_ctx" } }, "standard_ctx").glass == std.glass) +check("standard applied twice == standard", resolve({ { apply = "standard_ctx" } }, "standard_ctx").glass == std.glass) + +-- ─── D2: rarity / pool-order desync-safety ─────────────────────────────────── +local relA = resolve({}, "release_ctx") +local relB = resolve({ + { preview = "standard_ctx" }, { apply = "sandbox_ctx" }, + { preview = "vanilla_ctx" }, { apply = "standard_ctx" }, { preview = "release_ctx" }, +}, "release_ctx") +check("release => sixth_sense rarity 3 (client A)", relA.rarity == 3) +check("release => sixth_sense rarity 3 (client B, hostile history)", relB.rarity == 3) +check("release => rarity pools byte-identical across histories (A==B)", relA.pools == relB.pools) +check("release => sixth_sense in bucket 3, not 1", + bucket_keys(3):find("j_sixth_sense") and not bucket_keys(1):find("j_sixth_sense")) +check("WIP joker excluded from pools (vanilla `not v.wip`)", not all_pools():find("j_wip")) +check("demo joker excluded from pools (vanilla `not v.demo`)", not all_pools():find("j_demo")) +check("non-joker decoy excluded from pools (vanilla `set=='Joker'`)", not all_pools():find("c_decoy")) +check("equal-order jokers ordered by key tiebreak", + (function() local b = bucket_keys(3) local p1, p2 = b:find("j_rare_tie1"), b:find("j_rare_tie2") return p1 and p2 and p1 < p2 end)()) +check("release applied twice => pools unchanged (no incremental re-sort)", + resolve({ { apply = "release_ctx" } }, "release_ctx").pools == relA.pools) +local van = resolve({ { apply = "release_ctx" }, { apply = "chaos_ctx" } }, "vanilla_ctx") +check("vanilla finalize after release/chaos => sixth_sense rarity back to 1", van.rarity == 1) +check("vanilla finalize => sixth_sense in bucket 1, not 3", + bucket_keys(1):find("j_sixth_sense") and not bucket_keys(3):find("j_sixth_sense")) + +-- ─── Preview isolation (asymmetric call-site fix) ─────────────────────────── +G.P_CENTERS = fresh_centers() +G.P_JOKER_RARITY_POOLS = { {}, {}, {}, {} } +register_reworks() +local bx, be = glass() +local bpools = all_pools() +MP.PreviewReworks("ruleset_mp_chaos_ctx") -- would change live state if it leaked +local ax, ae = glass() +check("preview does not mutate live m_glass", ax == bx and ae == be) +check("preview does not mutate rarity pools", all_pools() == bpools) +local pv = MP.preview_center("m_glass") +check("preview_center surfaces projected config (1.5/3 under chaos)", pv.config.Xmult == 1.5 and pv.config.extra == 3) +check("preview_center falls through to live for untouched prop", pv.set == "Enhanced") +pv.config = { Xmult = 999 } +check("write through preview proxy never touches live center", G.P_CENTERS.m_glass.config.Xmult == 2) + +-- ─── Phase guard ──────────────────────────────────────────────────────────── +MP._PREVIEW_ACTIVE = true +check("ApplyReworks errors while a preview projection is live", + not pcall(function() MP.ApplyReworks("ruleset_mp_standard_ctx") end)) +MP._PREVIEW_ACTIVE = false + +-- ─── Modifier folding + documented SP run-start modifier-drop ─────────────── +-- A modifier reworking m_glass to extra 9 folds in only when its target IS the +-- live ruleset; the SP run-start path (lobby ruleset nil, SP.ruleset set, +-- practice=false) yields active=nil, so modifiers drop — matching the OLD +-- chain-based LoadReworks. Pinned so any future change is deliberate. +G.P_CENTERS = fresh_centers() +register_reworks() +set_live("standard_ctx") +MP.MODIFIERS = { "mod_glassbump" } +MP.ApplyReworks("ruleset_mp_standard_ctx") -- target == live +check("modifier folds when target == live ruleset (extra 9)", select(2, glass()) == 9) + +G.P_CENTERS = fresh_centers() +MP.LOBBY.config.ruleset = nil +MP.SP.ruleset = "ruleset_mp_standard_ctx" +MP.MODIFIERS = { "mod_glassbump" } +MP.ApplyReworks(MP.LOBBY.config.ruleset or MP.SP.ruleset) -- mirrors game_state.lua +check("SP run-start drops modifiers (extra 4, not 9) — matches old behavior", select(2, glass()) == 4) +MP.MODIFIERS, MP.SP.ruleset, MP.LOBBY.config.ruleset = {}, nil, nil + +-- ─── Frozen baseline immutability ─────────────────────────────────────────── +G.P_CENTERS = fresh_centers() +register_reworks() +check("frozen baseline config snapshot rejects writes", + not pcall(function() MP._REWORK_BASELINE.P_CENTERS.m_glass.config.value.Xmult = 7 end)) + +-- ─── Report ───────────────────────────────────────────────────────────────── +print(string.format("Rework determinism test: %d passed, %d failed", pass, fail)) +if fail > 0 then + print("\nFailures:") + for _, f in ipairs(failures) do print(" " .. f) end + os.exit(1) +end +print("All rework determinism + desync-safety checks passed.") diff --git a/tests/test_ruleset_shape.lua b/tests/test_ruleset_shape.lua index d77ce330..76ae0ed6 100644 --- a/tests/test_ruleset_shape.lua +++ b/tests/test_ruleset_shape.lua @@ -103,7 +103,7 @@ SMODS.showman = function() return false end local function lua_files_in(dir) local files = {} - local p = io.popen('ls "' .. dir .. '"/*.lua 2>/dev/null') + local p = io.popen('find "' .. dir .. '" -name "*.lua" 2>/dev/null') if p then for line in p:lines() do files[#files + 1] = line diff --git a/ui/game/game_state.lua b/ui/game/game_state.lua index e99e59bd..5158cb54 100644 --- a/ui/game/game_state.lua +++ b/ui/game/game_state.lua @@ -350,11 +350,22 @@ function Game:update_blind_select(dt) update_blind_select_ref(self, dt) end +-- Symmetric counterpart to the start_run apply below: when a run ends and we drop +-- back to the menu, rebuild the live centers from the frozen baseline (nil ruleset +-- = empty layer chain = pure vanilla). Otherwise the centers keep the last run's +-- reworked numbers and the vanilla collection screen shows them until the next run. +local main_menu_ref_reworks = Game.main_menu +function Game:main_menu(change_context) + main_menu_ref_reworks(self, change_context) + MP.ApplyReworks(nil) +end + local start_run_ref = Game.start_run function Game:start_run(args) -- Not get_active_ruleset(): the sp run flow leaves practice=false but still -- sets MP.SP.ruleset, which get_active_ruleset() only honours in practice. - MP.LoadReworks(MP.LOBBY.config.ruleset or MP.SP.ruleset) + -- This is the one and only place G.P_* gets written for the run. + MP.ApplyReworks(MP.LOBBY.config.ruleset or MP.SP.ruleset) start_run_ref(self, args) diff --git a/ui/main_menu/play_button/ghost_replay_picker.lua b/ui/main_menu/play_button/ghost_replay_picker.lua index 94e961e7..04ad3397 100644 --- a/ui/main_menu/play_button/ghost_replay_picker.lua +++ b/ui/main_menu/play_button/ghost_replay_picker.lua @@ -51,7 +51,9 @@ function G.FUNCS.load_previewed_ghost(e) MP.SP.ruleset = ruleset_key local ruleset_name = ruleset_key:gsub("^ruleset_mp_", "") MP.apply_default_modifiers(ruleset_name) - MP.LoadReworks(ruleset_name) + -- Preview projection only; the live centers are mutated at run start + -- by start_practice_run → Game:start_run → ApplyReworks. + MP.PreviewReworks(ruleset_name) end _preview_idx = nil diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index f33a19e6..802ff660 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -17,6 +17,7 @@ local ruleset_buttons_data = { { button_id = "badlatro_ruleset_button", button_localize_key = "k_badlatro" }, { button_id = "speedlatro_ruleset_button", button_localize_key = "k_speedlatro" }, { button_id = "chaos_ruleset_button", button_localize_key = "k_chaos" }, + { button_id = "release_ruleset_button", button_localize_key = "k_release" }, }, }, { @@ -68,6 +69,7 @@ local rulesets_tabs = { { button_id = "speedlatro_ruleset_button", button_localize_key = "k_speedlatro" }, { button_id = "badlatro_ruleset_button", button_localize_key = "k_badlatro" }, { button_id = "chaos_ruleset_button", button_localize_key = "k_chaos" }, + { button_id = "release_ruleset_button", button_localize_key = "k_release" }, }, }, }, @@ -79,7 +81,10 @@ local rulesets_tabs = { name = "k_experimental", buttons = { { button_id = "experimental_ruleset_button", button_localize_key = "k_experimental_standard" }, - { button_id = "experimental_legacy_ruleset_button", button_localize_key = "k_experimental_legacy" }, + { + button_id = "experimental_legacy_ruleset_button", + button_localize_key = "k_experimental_legacy", + }, }, }, }, @@ -149,7 +154,9 @@ function G.UIDEF.ruleset_selection_options(mode, buttons) -- here we are MP.apply_default_modifiers(default_ruleset) - MP.LoadReworks(default_ruleset) + -- Preview only — projects into _PREVIEW_VIEW, never the live centers the next + -- game reads. Browse all you like; nobody desyncs. + MP.PreviewReworks(default_ruleset) MP.UI.ruleset_selection_mode = mode MP.UI.ruleset_selection_default_button = default_ruleset .. "_ruleset_button" @@ -181,7 +188,7 @@ function G.FUNCS.change_ruleset_selection(e) function(ruleset_name) set_selected_ruleset(mode, "ruleset_mp_" .. ruleset_name) MP.apply_default_modifiers(ruleset_name) - MP.LoadReworks(ruleset_name) + MP.PreviewReworks(ruleset_name) end ) @@ -449,9 +456,16 @@ function G.UIDEF.ruleset_cardarea_definition(args) G.CARD_W * card_size, G.CARD_H * card_size, nil, - G.P_CENTERS[v], + -- Read through the preview projection, not the live center, + -- so the panel shows reworked numbers without mutating G.P_*. + MP.preview_center(v), { bypass_discovery_center = true, bypass_discovery_ui = true } ) + -- preview_center returns a proxy, so Card:set_ability's identity + -- scan of G.P_CENTERS can't find it to populate center_key. Set it + -- from the key we already have, or the balanced sticker's loc_vars + -- crashes on hover. + card.config.center_key = v card_area:emplace(card) end