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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/cocktail_config.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- Pure decision logic for the Cocktail Deck (objects/decks/ZZ_cocktail.lua).
--
-- Kept separate from ZZ_cocktail.lua (which is all impure shell: SMODS
-- registration, G.P_CENTERS enumeration, UI overlays) so the actual decisions --
-- which decks are in/forced/out for a given cfg string, and which cfg string is
-- authoritative -- are plain-data-in/plain-data-out and unit-testable without
-- any game globals.
MP.CocktailConfig = MP.CocktailConfig or {}

-- Partition an ordered list of candidate deck keys against a position-encoded
-- cfg string: one character per key, by position ("1" = included in the pool,
-- "2" = forced to always appear, anything else/missing = excluded).
-- Mirrors the encoding written by MP.cocktail_cfg_edit.
function MP.CocktailConfig.select(keys, cfg_str)
cfg_str = cfg_str or ""
local included, forced = {}, {}
for i, key in ipairs(keys) do
local c = cfg_str:sub(i, i)
if c == "1" then
included[#included + 1] = key
elseif c == "2" then
forced[#forced + 1] = key
end
end
return included, forced
end

-- Cocktail cfg strings are truthy in Lua even when "" -- and "" specifically
-- means "never seeded" (the reset_lobby_config default), not a deliberate
-- all-decks-off choice. Treat "" (and nil) as unset and fall back.
-- Used both to resolve which cfg string governs deck application in a lobby
-- (MP.cocktail_cfg_get) and to seed a host's lobby metadata at lobby-create
-- time from their own saved preference (MP.pvp_lobby_metadata).
function MP.CocktailConfig.resolve(value, fallback)
if value and value ~= "" then
return value
end
return fallback
end

return MP.CocktailConfig
6 changes: 6 additions & 0 deletions localization/en-us.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,11 @@ return {
mp_sticker_draining = "Draining",
},
dictionary = {
-- Composite-deck (cocktail) + draft display wording. PvP owns these --
-- the generic ban-pick engine renders item.name/subtitle verbatim.
k_cocktail_suffix = "Cocktail",
k_banpick_weekly_mix = "A rotating 3-deck mix",
k_draft_failed = "Draft failed",
k_trap_pack = "Trap Pack",
b_singleplayer = "Singleplayer",
b_sp_with_ruleset = "Practice Mode",
Expand Down Expand Up @@ -1541,6 +1546,7 @@ return {
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_cocktail_show_decks = "Show active decks during run",
k_bans = "Bans",
k_reworks = "Reworks",
k_edit = "Edit",
Expand Down
37 changes: 24 additions & 13 deletions objects/decks/ZZ_cocktail.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ SMODS.Back({
atlas = "mp_decks",
pos = { x = 4, y = 0 },
mod_whitelist = {
-- "Multiplayer" is the pre-MQTT-conversion mod id (kept for back-compat with
-- any decks still tagged under it); "MultiplayerPvP" is the current one --
-- without it, this mod's own cocktail-eligible decks (b_mp_orange etc.) are
-- excluded from the pool.
Multiplayer = true,
MultiplayerPvP = true,
Cryptid = true,
aikoyorisshenanigans = true,
allinjest = true,
Expand Down Expand Up @@ -154,15 +159,24 @@ function MP.get_cocktail_decks(cull)
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]
-- Match cocktail (matchmaking only): the composition attached to the
-- PICKED draft item -- which rode the host's state broadcast, so both
-- clients hold the identical list. It replaces the pool outright: every
-- listed deck is forced, nothing else mixes in. Private lobbies keep the
-- player/lobby cocktail config.
local match_mix = MP._match_cocktail
if match_mix and type(match_mix.decks) == "table" and MP.is_matchmaking and MP.is_matchmaking() then
local forced_mix = {}
for _, key in ipairs(match_mix.decks) do
if G.P_CENTERS[key] then
forced_mix[#forced_mix + 1] = key
end
end
if #forced_mix > 0 then
return {}, forced_mix
end
end
ret = _ret
ret, forced = MP.CocktailConfig.select(ret, MP.cocktail_cfg_get())
end
return ret, forced
end
Expand Down Expand Up @@ -261,7 +275,7 @@ function Card:click() -- i'd rather deal with the cardarea but this is fine i su
nodes = {
create_toggle({
id = "show_cocktail_decks",
label = "Show active decks during run",
label = localize("k_cocktail_show_decks"),
ref_table = MP,
ref_value = "show_cocktail_decks",
callback = function(bool)
Expand Down Expand Up @@ -584,11 +598,8 @@ function MP.cocktail_cfg_readpos(pos, construct)
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 MP.config.cocktail
end
local lobby_cocktail = MP.LOBBY.code and MP.LOBBY.deck and MP.LOBBY.deck.cocktail
return MP.CocktailConfig.resolve(lobby_cocktail, MP.config.cocktail)
end

function MP.cocktail_check_edited()
Expand Down
116 changes: 81 additions & 35 deletions pvp_api/actions/run_lifecycle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,45 +49,91 @@ A("pvp_start_game", function(_at, from, params)
-- lockstep off this same broadcast; the picked deck+stake then starts the run.
if gm_def and gm_def.ban_pick and MP.is_matchmaking and MP.is_matchmaking() then
local bp = gm_def.ban_pick
MPAPI.BanPick.start(lobby, {
pool_size = bp.pool_size,
keep = bp.keep,
schedule = bp.schedule,
-- 9 distinct random deck backs, each paired with a random stake. The stake
-- cap mirrors MP's own (ui/lobby/lobby.lua:346): MP.DECK.MAX_STAKE when a
-- compatibility mod restricts it, else all 8.
build_pool = function()
local cap = (MP.DECK and MP.DECK.MAX_STAKE and MP.DECK.MAX_STAKE > 0) and MP.DECK.MAX_STAKE or 8
local keys = {}
for _, center in ipairs(G.P_CENTER_POOLS.Back or {}) do
keys[#keys + 1] = center.key
-- Start the draft with the server-issued pool. The draft only ever runs
-- inside matchmaking, and every matchmaking queue has a server draft
-- policy, so only the host (below) ever calls this with a real pool --
-- guard against nil anyway so a stray call can't crash.
local function start_draft(server_pool)
MPAPI.BanPick.start(lobby, {
pool_size = bp.pool_size,
keep = bp.keep,
schedule = bp.schedule,
build_pool = function()
if not server_pool then
return {}
end
-- Server-provided cocktail items already carry their composition
-- (item.decks); add PvP's display wording (rides the broadcast).
return MP.decorate_cocktail_items(server_pool)
end,
-- Stamp the stake sticker onto each deck back (see the game's back_sticker DrawStep).
decorate_tile = function(card, item)
if type(item) == "table" and item.stake then
card.sticker = G.sticker_map[SMODS.stake_from_index(item.stake)]
end
end,
state_action = "pvp_ban_pick_state",
ban_action = "pvp_ban_pick_ban",
on_refresh = function()
if MP.lobby and MP.lobby.refresh_mm_status then
MP.lobby.refresh_mm_status()
end
end,
}, function(survivors)
local picked = survivors and survivors[1]
-- The cocktail composition both clients run comes from the PICKED
-- item (broadcast state) -- one source of truth, never the private
-- weekly stash.
if MP.set_match_cocktail then
MP.set_match_cocktail(picked)
end
for i = #keys, 2, -1 do
local j = math.random(i)
keys[i], keys[j] = keys[j], keys[i]
end
local pool = {}
for i = 1, math.min(bp.pool_size, #keys) do
pool[i] = { key = keys[i], stake = math.random(cap) }
proceed(picked)
end)
end
-- Only the host builds a pool, so only the host fetches; guests start
-- straight into the "Selecting decks..." waiting state and render off the
-- host's first broadcast. A fetch failure or an unusable pool (wrong size,
-- unknown deck keys, out-of-cap stakes) aborts the draft -- there is no
-- local-generation fallback to degrade into.
if lobby and lobby.is_host then
MP.fetch_draft_pool(function(server_pool)
-- Staleness guard: the fetch resolves through the FIFO; if the match
-- was cancelled (or this lobby died) meanwhile, don't start a draft
-- into a dead lobby.
if lobby ~= MPAPI.get_current_lobby() then
return
end
return pool
end,
-- Stamp the stake sticker onto each deck back (see the game's back_sticker DrawStep).
decorate_tile = function(card, item)
if type(item) == "table" and item.stake then
card.sticker = G.sticker_map[SMODS.stake_from_index(item.stake)]
local failure_detail
if not server_pool then
failure_detail = "no pool returned"
elseif not MP.validate_server_pool(server_pool, bp.pool_size) then
failure_detail = "pool failed validation"
end
end,
state_action = "pvp_ban_pick_state",
ban_action = "pvp_ban_pick_ban",
on_refresh = function()
if MP.lobby and MP.lobby.refresh_mm_status then
MP.lobby.refresh_mm_status()
if failure_detail then
sendWarnMessage("[draft] aborting draft -- " .. failure_detail, "MULTIPLAYER")
-- No local-generation fallback exists: show the user only a
-- generic error and tear the match down via the standard
-- leave-lobby path -- one abort path, never invent a second.
pcall(function()
attention_text({
text = localize("k_draft_failed"),
scale = 0.9,
hold = 4,
backdrop_colour = G.C.RED,
align = "cm",
offset = { x = 0, y = -3.5 },
major = G.ROOM_ATTACH,
})
end)
MP.pvp_leave_lobby()
pcall(MPAPI.refresh_current_view)
return
end
end,
}, function(survivors)
proceed(survivors and survivors[1])
end)
start_draft(server_pool)
end)
else
start_draft(nil)
end
else
proceed(meta.deck)
end
Expand Down
91 changes: 91 additions & 0 deletions pvp_api/draft_pool.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
-- Client-side draft-pool support.
--
-- The draft pool is SERVER-AUTHORITATIVE (idempotent per match, curated per
-- queue -- see the server's features/draft/). The draft only ever runs inside
-- matchmaking, and every matchmaking queue has a server draft policy, so there
-- is no local generator and no fallback pool: a fetch failure aborts the draft
-- instead of fabricating one. This file provides:
-- * MP.validate_server_pool -- crash-guard against an unusable server pool
-- (wrong size, unknown deck keys, out-of-cap
-- stakes, duplicate pairs)
-- * MP.fetch_draft_pool -- host-side: ask the server for this match's pool
-- * MP.decorate_cocktail_items -- adds PvP's Cocktail display wording
-- * MP.set_match_cocktail -- derives the match-scoped cocktail from the pick

-- Validate a server-issued pool against what THIS client can actually run:
-- exact expected size (the draft schedule is fixed), every key resolvable in
-- G.P_CENTERS (an unknown key would crash tile construction on BOTH clients),
-- stakes within the compat cap, no duplicate (key, stake) pairs. Anything off
-- means the pool is unusable -- the caller must abort the draft rather than
-- start it, since there is no local-generation fallback to degrade into.
function MP.validate_server_pool(pool, expected_count)
if type(pool) ~= 'table' or #pool ~= expected_count then
return false
end
local cap = (MP.DECK and MP.DECK.MAX_STAKE and MP.DECK.MAX_STAKE > 0) and MP.DECK.MAX_STAKE or 8
local seen = {}
for _, item in ipairs(pool) do
if type(item) ~= 'table' or type(item.key) ~= 'string' or not G.P_CENTERS[item.key] then
return false
end
if type(item.stake) ~= 'number' or item.stake < 1 or item.stake > cap or item.stake % 1 ~= 0 then
return false
end
local id = item.key .. '@' .. item.stake
if seen[id] then
return false
end
seen[id] = true
end
return true
end

-- Host-side: fetch this match's server-generated pool. callback(pool) with an
-- array of { key, stake }, or callback(nil) on any failure (no connection, no
-- match id, transport error) -- the caller must abort the draft on nil, never
-- fabricate a pool.
function MP.fetch_draft_pool(callback)
local match_id = MP._match_handle and MP._match_handle.match_id
if not match_id or not MPAPI.matchmaking.fetch_draft_pool then
callback(nil)
return
end
MPAPI.matchmaking.fetch_draft_pool(match_id, callback)
end

-- Match-scoped cocktail composition, derived from the PICKED draft item -- which
-- rides the host's state broadcast, so host and guest provably agree (each
-- client's private weekly stash is only ever the HOST's tagging source).
-- Set at draft completion, cleared on lobby teardown.
function MP.set_match_cocktail(picked)
if
type(picked) == 'table'
and picked.key == 'b_mp_cocktail'
and type(picked.decks) == 'table'
and #picked.decks > 0
then
MP._match_cocktail = { name = picked.name, decks = picked.decks }
else
MP._match_cocktail = nil
end
end

-- PvP owns the "Cocktail" wording. The server delivers the composition ON the
-- cocktail pool item (item.decks + a bare item.name like "Casjb"); this adds
-- the localized display strings the composite-agnostic engine renders verbatim
-- -- item.name becomes "Casjb Cocktail", item.subtitle the mix line. Items
-- without a decks list (e.g. a plain deck, or a non-weekly cocktail roll) pass
-- through untouched -- they render as a plain deck.
function MP.decorate_cocktail_items(pool)
if not pool then
return pool
end
for _, item in ipairs(pool) do
if type(item) == 'table' and item.key == 'b_mp_cocktail' and type(item.decks) == 'table' and #item.decks > 0 then
local suffix = localize('k_cocktail_suffix')
item.name = (item.name and (tostring(item.name) .. ' ') or '') .. suffix
item.subtitle = localize('k_banpick_weekly_mix')
end
end
return pool
end
9 changes: 9 additions & 0 deletions pvp_api/flow.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ function MP.pvp_lobby_metadata(gamemode_key, kind)
stake = tostring(MP.LOBBY.config.stake or 1),
starting_lives = MP.LOBBY.config.starting_lives or 4,
pvp_start_round = MP.LOBBY.config.pvp_start_round or 2,
-- reset_lobby_config defaults cocktail to "" (never synced); a lobby created
-- without ever opening the cocktail edit overlay would otherwise ship an empty
-- pool to the guest (and to the host's own copy_host_deck at run start) -- fall
-- back to the host's own saved preference so it always carries a real value.
cocktail = MP.CocktailConfig.resolve(MP.LOBBY.config.cocktail, MP.config.cocktail),
sleeve = MP.LOBBY.config.sleeve or "sleeve_casl_none",
challenge = MP.LOBBY.config.challenge or "",
}
end

Expand Down Expand Up @@ -117,6 +124,8 @@ function MP.pvp_leave_lobby()
MP.LOBBY.code = nil
MP.CURRENT_LOBBY = nil
MPAPI.MODIFIERS = {}
-- Match-scoped cocktail composition must not leak into the next lobby/match.
MP._match_cocktail = nil
MP._version_mismatch_shown = false
if G.STATE ~= G.STATES.MENU then
G.STATE = G.STATES.MENU
Expand Down
Loading