diff --git a/lib/cocktail_config.lua b/lib/cocktail_config.lua new file mode 100644 index 00000000..de2cb752 --- /dev/null +++ b/lib/cocktail_config.lua @@ -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 diff --git a/localization/en-us.lua b/localization/en-us.lua index ed9224d0..54422564 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -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", @@ -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", diff --git a/objects/decks/ZZ_cocktail.lua b/objects/decks/ZZ_cocktail.lua index 05a8f370..632f2652 100644 --- a/objects/decks/ZZ_cocktail.lua +++ b/objects/decks/ZZ_cocktail.lua @@ -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, @@ -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 @@ -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) @@ -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() diff --git a/pvp_api/actions/run_lifecycle.lua b/pvp_api/actions/run_lifecycle.lua index 10949476..3b8fe6ee 100644 --- a/pvp_api/actions/run_lifecycle.lua +++ b/pvp_api/actions/run_lifecycle.lua @@ -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 diff --git a/pvp_api/draft_pool.lua b/pvp_api/draft_pool.lua new file mode 100644 index 00000000..65a32d7e --- /dev/null +++ b/pvp_api/draft_pool.lua @@ -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 diff --git a/pvp_api/flow.lua b/pvp_api/flow.lua index 11e3cc21..ebf4195f 100644 --- a/pvp_api/flow.lua +++ b/pvp_api/flow.lua @@ -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 @@ -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 diff --git a/tests/test_cocktail_config.lua b/tests/test_cocktail_config.lua new file mode 100644 index 00000000..25e9a3f5 --- /dev/null +++ b/tests/test_cocktail_config.lua @@ -0,0 +1,124 @@ +--[[ + Cocktail Deck config resolution + selection tests. + + Covers the pure decision logic behind the "cocktail just doesn't work" bug: + MP.CocktailConfig.select (cull/forced partitioning from a position-encoded cfg + string) and MP.CocktailConfig.resolve (lobby-vs-saved cfg fallback, treating "" + as unset -- Lua's "" is truthy, which is exactly how an unseeded lobby cocktail + config silently culled every deck to zero). Also exercises MP.pvp_lobby_metadata + (pvp_api/flow.lua) to confirm the lobby-creation metadata snapshot ships a real + cocktail value (plus sleeve/challenge) instead of the reset_lobby_config "" default. + + Run from the repo root: + luajit tests/test_cocktail_config.lua +]] + +local failures = 0 +local function check(name, cond) + if cond then + print("ok - " .. name) + else + failures = failures + 1 + print("FAIL - " .. name) + end +end + +-- ─── MP.CocktailConfig.select ──────────────────────────────────────────────── + +MP = {} +dofile("lib/cocktail_config.lua") + +local keys = { "b_red", "b_blue", "b_yellow", "b_green" } + +do + local included, forced = MP.CocktailConfig.select(keys, "1010") + check("select: '1' at pos 1 includes b_red", included[1] == "b_red") + check("select: '1' at pos 3 includes b_yellow", included[2] == "b_yellow") + check("select: '0' positions excluded from both lists", #included == 2 and #forced == 0) +end + +do + local included, forced = MP.CocktailConfig.select(keys, "2110") + check("select: '2' forces b_red instead of including it", forced[1] == "b_red" and #forced == 1) + check("select: '1' still includes b_blue/b_yellow", included[1] == "b_blue" and included[2] == "b_yellow") + check("select: forced decks are not duplicated into included", #included == 2) +end + +do + local included, forced = MP.CocktailConfig.select(keys, "0000") + check("select: all-zero cfg string excludes every deck", #included == 0 and #forced == 0) +end + +do + -- Regression: this is the exact shape of the bug -- an unseeded lobby's cocktail + -- cfg ("") culled every deck to zero, so the cocktail Back's apply() loop ran 0 + -- iterations and mixed in nothing. + local included_empty, forced_empty = MP.CocktailConfig.select(keys, "") + check("select: empty cfg string behaves like all-zero (regression: unseeded lobby)", #included_empty == 0 and #forced_empty == 0) + local included_nil, forced_nil = MP.CocktailConfig.select(keys, nil) + check("select: nil cfg string also behaves like all-zero", #included_nil == 0 and #forced_nil == 0) +end + +-- ─── MP.CocktailConfig.resolve ─────────────────────────────────────────────── + +do + check("resolve: non-empty lobby value wins over fallback", MP.CocktailConfig.resolve("1111H", "0000H") == "1111H") + check("resolve: empty-string lobby value falls back (the bug)", MP.CocktailConfig.resolve("", "1111H") == "1111H") + check("resolve: nil lobby value falls back", MP.CocktailConfig.resolve(nil, "1111H") == "1111H") + check("resolve: false (not-in-lobby / no-deck) falls back", MP.CocktailConfig.resolve(false, "1111H") == "1111H") +end + +-- Scenario table mirroring MP.cocktail_cfg_get's actual call shape: +-- resolve(lobby_code and lobby_deck_cocktail, MP.config.cocktail) +local get_cases = { + { name = "solo (no lobby): uses saved config", lobby_code = nil, lobby_cocktail = nil, saved = "1111H", expect = "1111H" }, + { name = "lobby, seeded cocktail: uses lobby value", lobby_code = "ABCD", lobby_cocktail = "1010H", saved = "1111H", expect = "1010H" }, + { name = 'lobby, unseeded ("") cocktail: falls back to saved (the bug)', lobby_code = "ABCD", lobby_cocktail = "", saved = "1111H", expect = "1111H" }, + { name = "lobby, deck table not populated yet: falls back to saved", lobby_code = "ABCD", lobby_cocktail = nil, saved = "1111H", expect = "1111H" }, +} +for _, c in ipairs(get_cases) do + local lobby_value = c.lobby_code and c.lobby_cocktail + local got = MP.CocktailConfig.resolve(lobby_value, c.saved) + check("cocktail_cfg_get shape: " .. c.name, got == c.expect) +end + +-- ─── MP.pvp_lobby_metadata ships a real cocktail value ────────────────────── + +MP = { + id = "MultiplayerPvP", + LOBBY = { + config = { + back = "Cocktail Deck", + stake = 1, + starting_lives = 4, + pvp_start_round = 2, + cocktail = "", -- reset_lobby_config default: never touched the edit overlay + sleeve = "sleeve_casl_none", + challenge = "", + }, + }, + config = { cocktail = "1111H" }, -- the host's saved mod-config preference + LobbyKind = {}, + PVP_GAMEMODES = { pvp_standard = { ruleset = "ruleset_mp_blitz" } }, +} +G = { FUNCS = {} } +dofile("lib/cocktail_config.lua") +dofile("pvp_api/flow.lua") + +local meta = MP.pvp_lobby_metadata("pvp_standard", "private") +check("lobby metadata carries a non-empty cocktail value", meta.cocktail == "1111H") +check("lobby metadata cocktail is not the unseeded default", meta.cocktail ~= "") +check("lobby metadata carries sleeve", meta.sleeve == "sleeve_casl_none") +check("lobby metadata carries challenge (empty string is a legit value here)", meta.challenge == "") + +-- Regression: once a lobby's own config.cocktail has actually been set (e.g. via +-- the edit overlay before create-lobby), metadata must ship THAT value, not +-- silently keep re-deriving from the saved config. +MP.LOBBY.config.cocktail = "2110H" +local meta2 = MP.pvp_lobby_metadata("pvp_standard", "private") +check("lobby metadata prefers an already-set lobby cocktail over the saved default", meta2.cocktail == "2110H") + +if failures > 0 then + error(failures .. " check(s) failed") +end +print("\nAll cocktail config checks passed.") diff --git a/tests/test_draft_pool.lua b/tests/test_draft_pool.lua new file mode 100644 index 00000000..2f55ed42 --- /dev/null +++ b/tests/test_draft_pool.lua @@ -0,0 +1,100 @@ +--[[ + Client-side draft-pool support test. + + The draft pool is server-authoritative -- there is no local generator. This + covers what remains client-side: validate_server_pool (crash-guard against + an unusable server pool), decorate_cocktail_items (PvP's display wording), + set_match_cocktail (match-scoped composition derivation), and the fetch + wrapper degrading to nil without a match handle. + + Run from the repo root: + luajit tests/test_draft_pool.lua +]] + +-- ── Stubs to load the real module ─────────────────────────────────────────── +MP = { DECK = {} } +MPAPI = { matchmaking = {} } +-- decorate_cocktail_items localizes the display wording; map keys to the en-us +-- values so the test asserts the real composed strings. +local _loc = { k_cocktail_suffix = 'Cocktail', k_banpick_weekly_mix = 'A rotating 3-deck mix' } +function localize(k) + return _loc[k] or k +end + +dofile('pvp_api/draft_pool.lua') + +-- ── Harness ──────────────────────────────────────────────────────────────── +local failures = 0 +local function check(cond, msg) + if cond then print('PASS: ' .. msg) else failures = failures + 1; print('FAIL: ' .. msg) end +end + +-- ── weekly cocktail tagging ───────────────────────────────────────────────── +print() +print('-- decorate_cocktail_items: PvP display wording on server-composed items --') +-- The server delivers the composition ON the item (decks + a bare name); +-- PvP adds the localized display strings the engine renders verbatim. +local decorated = MP.decorate_cocktail_items({ + { key = 'b_red', stake = 1 }, + { key = 'b_mp_cocktail', stake = 4, decks = { 'b_green', 'b_black', 'b_mp_orange' }, name = 'Casjb' }, + { key = 'b_mp_cocktail', stake = 8, decks = { 'b_green', 'b_black', 'b_mp_orange' }, name = 'Casjb' }, +}) +check(decorated[1].name == nil and decorated[1].subtitle == nil, 'non-cocktail tiles untouched') +check(decorated[2].name == 'Casjb Cocktail', 'PvP appends the Cocktail suffix to the server name') +check(decorated[2].subtitle == 'A rotating 3-deck mix', 'subtitle line set') +check(type(decorated[2].decks) == 'table' and #decorated[2].decks == 3, 'server-provided decks preserved') +check(decorated[3].name == 'Casjb Cocktail', 'every cocktail tile is decorated (twin stakes too)') +-- A cocktail item with NO decks (a non-weekly random cocktail roll) passes +-- through untouched -- it renders as a plain deck. +local plain = MP.decorate_cocktail_items({ { key = 'b_mp_cocktail', stake = 1 } }) +check(plain[1].name == nil, 'no decks on the item -> pass through untouched') + +-- ── server pool validation gate ───────────────────────────────────────────── +print() +print('-- validate_server_pool: gate against unusable server pools --') +G = G or {} +G.P_CENTERS = { b_red = {}, b_blue = {}, b_green = {} } +local good = { { key = 'b_red', stake = 1 }, { key = 'b_blue', stake = 8 }, { key = 'b_red', stake = 8 } } +check(MP.validate_server_pool(good, 3) == true, 'well-formed pool of the right size passes') +check(MP.validate_server_pool(good, 9) == false, 'wrong size rejected (fixed draft schedule)') +check(MP.validate_server_pool({ { key = 'b_nope', stake = 1 }, { key = 'b_red', stake = 1 }, { key = 'b_blue', stake = 1 } }, 3) == false, + 'unknown deck key rejected (would crash tile construction)') +check(MP.validate_server_pool({ { key = 'b_red', stake = 9 }, { key = 'b_blue', stake = 1 }, { key = 'b_green', stake = 1 } }, 3) == false, + 'out-of-range stake rejected') +check(MP.validate_server_pool({ { key = 'b_red', stake = 1 }, { key = 'b_red', stake = 1 }, { key = 'b_blue', stake = 1 } }, 3) == false, + 'duplicate (key, stake) pair rejected') +check(MP.validate_server_pool({}, 0) == true and MP.validate_server_pool({}, 9) == false, + 'empty pool only valid when nothing is expected') +MP.DECK.MAX_STAKE = 4 +check(MP.validate_server_pool({ { key = 'b_red', stake = 8 }, { key = 'b_blue', stake = 1 }, { key = 'b_green', stake = 1 } }, 3) == false, + 'stake above the compat cap rejected') +MP.DECK.MAX_STAKE = nil +G.P_CENTERS = nil + +-- ── match cocktail derivation (from the picked broadcast item) ────────────── +print() +print('-- set_match_cocktail: composition comes from the picked item --') +MP.set_match_cocktail({ key = 'b_mp_cocktail', decks = { 'b_green', 'b_black' }, name = 'Casjb Cocktail' }) +check(MP._match_cocktail ~= nil and #MP._match_cocktail.decks == 2 and MP._match_cocktail.name == 'Casjb Cocktail', + 'picked cocktail with composition sets the match stash') +MP.set_match_cocktail({ key = 'b_red', stake = 1 }) +check(MP._match_cocktail == nil, 'picking a non-cocktail clears the stash') +MP.set_match_cocktail({ key = 'b_mp_cocktail', stake = 1 }) +check(MP._match_cocktail == nil, 'cocktail WITHOUT composition metadata clears it (random cocktail path)') + +-- ── fetch wrapper degrades to nil without a match ─────────────────────────── +print() +print('-- wrappers without a match handle --') +local got = 'unset' +MP.fetch_draft_pool(function(pool) got = pool end) +check(got == nil, 'fetch_draft_pool hands back nil (caller must abort the draft) with no match') + +-- ── Summary ───────────────────────────────────────────────────────────────── +print() +if failures == 0 then + print('ALL TESTS PASSED') + os.exit(0) +else + print(failures .. ' TEST(S) FAILED') + os.exit(1) +end diff --git a/tests/test_pool_contract.lua b/tests/test_pool_contract.lua new file mode 100644 index 00000000..cc4a1ba2 --- /dev/null +++ b/tests/test_pool_contract.lua @@ -0,0 +1,88 @@ +--[[ + CONTRACT: this pool shape is produced by the server -- keep in sync with + server apps/server/src/tests/services/draft-contract.test.ts + + Canonical wire shape (exact JSON issueDraftPool returns): an array of items -- + plain item: { key = , stake = } (no other fields) + cocktail item: { key = 'b_mp_cocktail', stake = , + decks = { , ... }, name = } + + This test pins that the client ACCEPTS exactly what the server emits + (validate_server_pool) and decorates it as expected (decorate_cocktail_items), + and that each contract violation the parse layer relies on is rejected. + + Run from the repo root: + luajit tests/test_pool_contract.lua +]] + +-- ── Stubs to load the real module ─────────────────────────────────────────── +MP = { DECK = {} } +MPAPI = { matchmaking = {} } +local _loc = { k_cocktail_suffix = 'Cocktail', k_banpick_weekly_mix = 'A rotating 3-deck mix' } +function localize(k) + return _loc[k] or k +end + +dofile('pvp_api/draft_pool.lua') + +-- ── Harness ──────────────────────────────────────────────────────────────── +local failures = 0 +local function check(cond, msg) + if cond then print('PASS: ' .. msg) else failures = failures + 1; print('FAIL: ' .. msg) end +end + +G = G or {} +G.P_CENTERS = { b_red = {}, b_blue = {}, b_green = {}, b_black = {}, b_mp_orange = {}, b_mp_cocktail = {} } + +-- Canonical server pool (returns a fresh copy every call -- decorate_cocktail_items +-- mutates its argument in place, and the negative cases build their own tables). +local function canonical_pool() + return { + { key = 'b_red', stake = 1 }, + { key = 'b_blue', stake = 8 }, + { key = 'b_mp_cocktail', stake = 4, decks = { 'b_green', 'b_black', 'b_mp_orange' }, name = 'Casjb' }, + } +end + +-- ── the client accepts exactly what the server emits ──────────────────────── +print() +print('-- validate_server_pool: accepts the canonical server pool verbatim --') +local pool = canonical_pool() +check(MP.validate_server_pool(pool, #pool) == true, 'canonical server pool (plain + cocktail items) passes') + +-- ── decoration matches the contract ────────────────────────────────────────── +print() +print('-- decorate_cocktail_items: applied to the canonical server pool --') +local decorated = MP.decorate_cocktail_items(canonical_pool()) +check(decorated[1].name == nil and decorated[1].subtitle == nil, 'plain item name untouched') +check(decorated[2].name == nil and decorated[2].subtitle == nil, 'plain item subtitle untouched') +check(decorated[3].name == 'Casjb Cocktail', 'bare server name gets the localized Cocktail suffix') +check(decorated[3].subtitle == 'A rotating 3-deck mix', 'subtitle set from the localized weekly-mix string') +check(type(decorated[3].decks) == 'table' and #decorated[3].decks == 3, 'decks length preserved') +check( + decorated[3].decks[1] == 'b_green' and decorated[3].decks[2] == 'b_black' and decorated[3].decks[3] == 'b_mp_orange', + 'decks contents preserved in order' +) + +-- ── negative contract cases: each violation is rejected ───────────────────── +print() +print('-- validate_server_pool: contract violations are rejected --') + +local missing_stake = { { key = 'b_red' }, { key = 'b_blue', stake = 8 }, { key = 'b_green', stake = 1 } } +check(MP.validate_server_pool(missing_stake, 3) == false, 'item missing stake is rejected') + +local unknown_key = { { key = 'b_totally_unknown', stake = 1 }, { key = 'b_blue', stake = 8 }, { key = 'b_green', stake = 1 } } +check(MP.validate_server_pool(unknown_key, 3) == false, "item whose key isn't in G.P_CENTERS is rejected") + +local duplicate_pair = { { key = 'b_red', stake = 1 }, { key = 'b_red', stake = 1 }, { key = 'b_green', stake = 1 } } +check(MP.validate_server_pool(duplicate_pair, 3) == false, 'duplicate (key, stake) pair is rejected') + +-- ── Summary ───────────────────────────────────────────────────────────────── +print() +if failures == 0 then + print('ALL TESTS PASSED') + os.exit(0) +else + print(failures .. ' TEST(S) FAILED') + os.exit(1) +end diff --git a/tests/test_weekly_cocktail.lua b/tests/test_weekly_cocktail.lua new file mode 100644 index 00000000..2a748322 --- /dev/null +++ b/tests/test_weekly_cocktail.lua @@ -0,0 +1,109 @@ +--[[ + Weekly cocktail test. + + In MATCHMAKING, when the server has published a weekly cocktail composition + (MP._match_cocktail, derived from the PICKED draft item riding the host broadcast), the Cocktail deck's pool is + replaced outright: every weekly deck is forced, nothing else mixes in. + Private lobbies (not matchmaking) keep the player/lobby cocktail config path. + + Run from the repo root: + luajit tests/test_weekly_cocktail.lua +]] + +-- ── Stubs to load the real modules ────────────────────────────────────────── +MP = {} +MPAPI = {} +SMODS = { + Back = function(t) return t end, + Atlas = function(t) return t end, + DrawStep = function(t) return t end, + save_mod_config = function() end, +} +Back = { change_to = function() end } +-- ZZ_cocktail wraps several engine methods at load time; give it inert hosts. +Card = { click = function() end, highlight = function() end } +CardArea = { can_highlight = function() end } +Controller = { queue_R_cursor_press = function() end } +Event = function(t) return t end +G = { + FUNCS = {}, + E_MANAGER = { add_event = function() end }, + P_CENTERS = { + b_mp_cocktail = { set = 'Back', order = 99, deck_blacklist = { b_mp_cocktail = true }, mod_whitelist = { MultiplayerPvP = true } }, + b_red = { set = 'Back', order = 1 }, + b_green = { set = 'Back', order = 4 }, + b_black = { set = 'Back', order = 5 }, + b_mp_orange = { set = 'Back', order = 18, mod = { id = 'MultiplayerPvP' } }, + }, +} + +dofile('lib/cocktail_config.lua') +dofile('objects/decks/ZZ_cocktail.lua') + +-- Runtime collaborators the culled path reads. +local matchmaking = false +MP.is_matchmaking = function() return matchmaking end +MP.cocktail_cfg_get = function() return '1212' end -- b_red in, b_green forced, b_black in, b_mp_orange forced + +-- ── Harness ──────────────────────────────────────────────────────────────── +local failures = 0 +local function check(cond, msg) + if cond then print('PASS: ' .. msg) else failures = failures + 1; print('FAIL: ' .. msg) end +end + +-- ── weekly + matchmaking: composition is exactly the weekly ───────────────── +print() +print('-- weekly + matchmaking: forced composition, empty pool --') +matchmaking = true +MP._match_cocktail = { name = "casjb's", decks = { 'b_green', 'b_black', 'b_mp_orange' } } +local included, forced = MP.get_cocktail_decks(true) +check(#included == 0, 'nothing random mixes in') +check(#forced == 3 and forced[1] == 'b_green' and forced[2] == 'b_black' and forced[3] == 'b_mp_orange', + "forced == casjb's green/black/orange, in order") + +-- ── unknown keys are filtered ──────────────────────────────────────────────── +print() +print('-- weekly with an unknown key: filtered --') +MP._match_cocktail = { name = 'x', decks = { 'b_green', 'b_not_installed' } } +included, forced = MP.get_cocktail_decks(true) +check(#forced == 1 and forced[1] == 'b_green', 'missing decks are dropped, valid ones kept') + +-- ── weekly entirely unresolvable: normal path ──────────────────────────────── +print() +print('-- weekly with no resolvable decks: falls back to the config path --') +MP._match_cocktail = { name = 'x', decks = { 'b_nope' } } +included, forced = MP.get_cocktail_decks(true) +check(#included == 2 and #forced == 2, 'config path used (1212 -> 2 included, 2 forced)') + +-- ── not matchmaking: private lobbies keep their config ────────────────────── +print() +print('-- weekly set but NOT matchmaking: config path wins --') +matchmaking = false +MP._match_cocktail = { name = "casjb's", decks = { 'b_green', 'b_black', 'b_mp_orange' } } +included, forced = MP.get_cocktail_decks(true) +check(#included == 2 and included[1] == 'b_red' and included[2] == 'b_black', 'included from cfg string') +check(#forced == 2 and forced[1] == 'b_green' and forced[2] == 'b_mp_orange', 'forced from cfg string') + +-- ── no weekly at all: unchanged behaviour ──────────────────────────────────── +print() +print('-- no weekly: unchanged behaviour --') +matchmaking = true +MP._match_cocktail = nil +included, forced = MP.get_cocktail_decks(true) +check(#included == 2 and #forced == 2, 'config path used when no weekly is stashed') + +-- ── uncalled path (cull=false) unaffected ──────────────────────────────────── +print() +print('-- cull=false lists candidates, never the weekly --') +local all = MP.get_cocktail_decks(false) +check(#all == 4, 'candidate list built from P_CENTERS (4 eligible)') + +-- ── Summary ───────────────────────────────────────────────────────────────── +print() +if failures == 0 then + print('ALL TESTS PASSED') + os.exit(0) +else + print(failures .. ' TEST(S) FAILED') + os.exit(1) +end