diff --git a/docs/api.md b/docs/api.md index 6f3ed22f..c93b7fc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -395,7 +395,7 @@ curl -X POST http://127.0.0.1:12346 \ ### `sell` -Sell a joker or consumable. +Sell a joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (to make room for new jokers). **Parameters:** (exactly one required) @@ -406,7 +406,7 @@ Sell a joker or consumable. **Returns:** [GameState](#gamestate-schema) -**Errors:** `BAD_REQUEST`, `NOT_ALLOWED` +**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` **Example:** @@ -698,6 +698,7 @@ The complete game state returned by most methods. "seed": "ABC123", "won": false, "used_vouchers": {}, + "tags": [ ... ], "hands": { ... }, "round": { ... }, "blinds": { ... }, @@ -780,8 +781,23 @@ Represents a card area (hand, jokers, consumables, shop, etc.). "name": "Small Blind", "effect": "No special effect", "score": 300, - "tag_name": "Uncommon Tag", - "tag_effect": "Shop has a free Uncommon Joker" + "tag": { + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" + } +} +``` + +### Tag + +Represents a Balatro tag that provides bonuses when triggered. + +```json +{ + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" } ``` @@ -925,6 +941,37 @@ Represents a card area (hand, jokers, consumables, shop, etc.). | `DEFEATED` | Previously beaten | | `SKIPPED` | Previously skipped | +### Tags + +Tags provide bonuses when triggered, typically after skipping a blind or defeating a boss blind. + +| Value | Description | +| ---------------- | ------------------------------------------------------------ | +| `tag_uncommon` | Shop has a free Uncommon Joker | +| `tag_rare` | Shop has a free Rare Joker | +| `tag_negative` | Next base edition shop Joker is free and becomes Negative | +| `tag_foil` | Next base edition shop Joker is free and becomes Foil | +| `tag_holo` | Next base edition shop Joker is free and becomes Holographic | +| `tag_polychrome` | Next base edition shop Joker is free and becomes Polychrome | +| `tag_investment` | Gain $25 after defeating the next Boss Blind | +| `tag_voucher` | Adds one Voucher to the next shop | +| `tag_boss` | Rerolls the Boss Blind | +| `tag_standard` | Gives a free Mega Standard Pack | +| `tag_charm` | Gives a free Mega Arcana Pack | +| `tag_meteor` | Gives a free Mega Celestial Pack | +| `tag_buffoon` | Gives a free Mega Buffoon Pack | +| `tag_handy` | Gives $1 per played hand this run | +| `tag_garbage` | Gives $1 per unused discard this run | +| `tag_ethereal` | Gives a free Spectral Pack | +| `tag_coupon` | Initial cards and booster packs in next shop are free | +| `tag_double` | Gives a copy of the next selected Tag (Double Tag excluded) | +| `tag_juggle` | +3 hand size next round | +| `tag_d_six` | Rerolls in next shop start at $0 | +| `tag_top_up` | Create up to 2 Common Jokers (Must have room) | +| `tag_skip` | Gives $5 per skipped Blind this run | +| `tag_orbital` | Upgrade [poker hand] by 3 levels | +| `tag_economy` | Doubles your money (Max of $40) | + ### Card Keys Card keys are used with the `add` method and appear in the `key` field of Card objects. diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 0bee6a21..05b4f790 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -121,13 +121,13 @@ return { name = "add", - description = "Add a new card to the game (joker, consumable, voucher, or playing card)", + description = "Add a new card to the game (joker, consumable, voucher, pack, or playing card)", schema = { key = { type = "string", required = true, - description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards like H_A)", + description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, p_* for packs, SUIT_RANK for playing cards like H_A)", }, seal = { type = "string", @@ -173,7 +173,7 @@ return { if not card_type then send_response({ - message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -378,12 +378,6 @@ return { if enhancement_value then params.enhancement = enhancement_value end - elseif card_type == "voucher" then - params = { - key = args.key, - area = G.shop_vouchers, - skip_materialize = true, - } else -- For jokers and consumables - just pass the key params = { @@ -429,6 +423,9 @@ return { if card_type == "pack" then -- Packs use dedicated SMODS function success, result = pcall(SMODS.add_booster_to_shop, args.key) + elseif card_type == "voucher" then + -- Vouchers use dedicated SMODS function + success, result = pcall(SMODS.add_voucher_to_shop, args.key) else -- Other cards use SMODS.add_card success, result = pcall(SMODS.add_card, params) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 77e42963..0e615138 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -86,11 +86,11 @@ return { if #area.cards == 0 then local msg if args.card then - msg = "No jokers/consumables/cards in the shop. Reroll to restock the shop" + msg = "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop." elseif args.voucher then - msg = "No vouchers to redeem. Defeat boss blind to restock" + msg = "No vouchers to redeem. Defeat boss blind to restock." elseif args.pack then - msg = "No packs to open" + msg = "No packs to open. Use `next_round` to advance to the next blind and restock the shop." end send_response({ message = msg, @@ -136,7 +136,8 @@ return { message = "Cannot purchase joker card, joker slots are full. Current: " .. gamestate.jokers.count .. ", Limit: " - .. gamestate.jokers.limit, + .. gamestate.jokers.limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -150,7 +151,8 @@ return { message = "Cannot purchase consumable card, consumable slots are full. Current: " .. gamestate.consumables.count .. ", Limit: " - .. gamestate.consumables.limit, + .. gamestate.consumables.limit + .. ". Use `use` to activate a consumable or `sell` to remove one.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 77316976..cd40854a 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -46,7 +46,7 @@ return { if G.GAME.current_round.discards_left <= 0 then send_response({ - message = "No discards left", + message = "No discards left. Play cards using `play` instead.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -54,7 +54,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index d60efa80..2e9a338f 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -113,7 +113,7 @@ return { -- Validate pack_cards exists if not G.pack_cards or G.pack_cards.REMOVED then send_response({ - message = "No pack is currently open", + message = "No pack is currently open. Use `buy` with `pack` parameter to buy and open a pack.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -144,7 +144,8 @@ return { message = "Cannot select joker, joker slots are full. Current: " .. joker_count .. ", Limit: " - .. joker_limit, + .. joker_limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -160,7 +161,11 @@ return { local joker_count = G.jokers and G.jokers.config and G.jokers.config.card_count or 0 if joker_count == 0 then send_response({ - message = string.format("Card '%s' requires at least 1 joker. Current: %d", card_key, joker_count), + message = string.format( + "Card '%s' requires at least 1 joker. Current: %d. Ensure you have enough jokers before selecting this card.", + card_key, + joker_count + ), name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -173,14 +178,14 @@ return { local msg if req.min == req.max then msg = string.format( - "Card '%s' requires exactly %d target card(s). Provided: %d", + "Card '%s' requires exactly %d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, target_count ) else msg = string.format( - "Card '%s' requires %d-%d target card(s). Provided: %d", + "Card '%s' requires %d-%d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, req.max, diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 1b9f0a98..b450039a 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -46,7 +46,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only play " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only play " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 5d112222..58593f1c 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -32,7 +32,7 @@ return { }, }, - requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.SMODS_BOOSTER_OPENED }, ---@param args Request.Endpoint.Sell.Params ---@param send_response fun(response: Response.Endpoint) @@ -55,6 +55,22 @@ return { return end + -- If in SMODS_BOOSTER_OPENED, verify it's a Buffoon pack (contains Jokers) + if G.STATE == G.STATES.SMODS_BOOSTER_OPENED then + local pack_set = G.pack_cards + and G.pack_cards.cards + and G.pack_cards.cards[1] + and G.pack_cards.cards[1].ability + and G.pack_cards.cards[1].ability.set + if pack_set ~= "Joker" then + send_response({ + message = "Can only sell jokers when a Buffoon pack is open", + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return + end + end + -- Determine which type to sell and validate existence local source_array, pos, sell_type @@ -144,7 +160,11 @@ return { local state_stable = G.STATE_COMPLETE == true -- 5. Still in valid state - local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) + local valid_state = ( + G.STATE == G.STATES.SHOP + or G.STATE == G.STATES.SELECTING_HAND + or G.STATE == G.STATES.SMODS_BOOSTER_OPENED + ) -- All conditions must be met if count_decreased and money_increased and card_gone and state_stable and valid_state then diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 0e684bed..5fea1c77 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -36,7 +36,7 @@ return { if blind.type == "BOSS" then sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") send_response({ - message = "Cannot skip Boss blind", + message = "Cannot skip Boss blind. Use `select` to select and play the boss blind.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 7801ed37..dedba80c 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -62,7 +62,7 @@ return { send_response({ message = "Consumable '" .. consumable_card.ability.name - .. "' requires card selection and can only be used in SELECTING_HAND state", + .. "' requires card selection and can only be used in SELECTING_HAND state.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -72,7 +72,9 @@ return { if requires_cards then if not args.cards or #args.cards == 0 then send_response({ - message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", + message = "Consumable '" + .. consumable_card.ability.name + .. "' requires card selection. Provide target cards via the `cards` parameter.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -100,7 +102,7 @@ return { if min_cards == max_cards and card_count ~= min_cards then send_response({ message = string.format( - "Consumable '%s' requires exactly %d card%s (provided: %d)", + "Consumable '%s' requires exactly %d card%s (provided: %d). Provide the correct number of cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -115,7 +117,7 @@ return { if card_count < min_cards then send_response({ message = string.format( - "Consumable '%s' requires at least %d card%s (provided: %d)", + "Consumable '%s' requires at least %d card%s (provided: %d). Provide more cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -129,7 +131,7 @@ return { if card_count > max_cards then send_response({ message = string.format( - "Consumable '%s' requires at most %d card%s (provided: %d)", + "Consumable '%s' requires at most %d card%s (provided: %d). Provide fewer cards via the `cards` parameter.", consumable_card.ability.name, max_cards, max_cards == 1 and "" or "s", @@ -176,7 +178,9 @@ return { -- Step 8: Space Check (not tested) if consumable_card:check_use() then send_response({ - message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", + message = "Cannot use consumable '" + .. consumable_card.ability.name + .. "': insufficient space. Use `sell` or `use` to free up space.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 3d563de0..f236eff9 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -411,3 +411,29 @@ ---| "UPCOMING" # Future blind ---| "DEFEATED" # Previously defeated blind ---| "SKIPPED" # Previously skipped blind + +---@alias Tag.Key +---| "tag_uncommon" # Uncommon Tag: Shop has a free Uncommon Joker +---| "tag_rare" # Rare Tag: Shop has a free Rare Joker +---| "tag_negative" # Negative Tag: Next base edition shop Joker is free and becomes Negative +---| "tag_foil" # Foil Tag: Next base edition shop Joker is free and becomes Foil +---| "tag_holo" # Holographic Tag: Next base edition shop Joker is free and becomes Holographic +---| "tag_polychrome" # Polychrome Tag: Next base edition shop Joker is free and becomes Polychrome +---| "tag_investment" # Investment Tag: Gain $25 after defeating the next Boss Blind +---| "tag_voucher" # Voucher Tag: Adds one Voucher to the next shop +---| "tag_boss" # Boss Tag: Rerolls the Boss Blind +---| "tag_standard" # Standard Tag: Gives a free Mega Standard Pack +---| "tag_charm" # Charm Tag: Gives a free Mega Arcana Pack +---| "tag_meteor" # Meteor Tag: Gives a free Mega Celestial Pack +---| "tag_buffoon" # Buffoon Tag: Gives a free Mega Buffoon Pack +---| "tag_handy" # Handy Tag: Gives $1 per played hand this run +---| "tag_garbage" # Garbage Tag: Gives $1 per unused discard this run +---| "tag_ethereal" # Ethereal Tag: Gives a free Spectral Pack +---| "tag_coupon" # Coupon Tag: Initial cards and booster packs in next shop are free +---| "tag_double" # Double Tag: Gives a copy of the next selected Tag (Double Tag excluded) +---| "tag_juggle" # Juggle Tag: +3 hand size next round +---| "tag_d_six" # D6 Tag: Rerolls in next shop start at $0 +---| "tag_top_up" # Top-up Tag: Create up to 2 Common Jokers (Must have room) +---| "tag_skip" # Skip Tag (aka Speed Tag): Gives $5 per skipped Blind this run +---| "tag_orbital" # Orbital Tag: Upgrade [poker hand] by 3 levels +---| "tag_economy" # Economy Tag: Doubles your money (Max of $40) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..359d76ef 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -515,6 +515,88 @@ local function get_blind_effect_from_ui(blind_config) return table.concat(effect_parts, " ") end +---Strips Balatro color codes from text +---Color codes are in format {C:color}text{} or {X:color}text{} +---@param text string The text with color codes +---@return string clean_text The text without color codes +local function strip_color_codes(text) + if not text then + return "" + end + -- Remove color codes: {C:color_name}, {X:mult}, etc. and closing {} + return text:gsub("%b{}", ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") +end + +---Gets voucher effect description using the game's localize function +---Uses the same approach as generate_card_ui() in common_events.lua +---@param voucher_key string The voucher key (e.g., "v_overstock_norm") +---@return string effect The effect description +local function get_voucher_effect(voucher_key) + if not voucher_key then + return "" + end + + -- Get voucher config from G.P_CENTERS + local center = G.P_CENTERS and G.P_CENTERS[voucher_key] + if not center then + return "" + end + + -- Build loc_vars based on voucher name (mirrors common_events.lua:2559-2576) + local loc_vars = {} + local name = center.name + + if name == "Overstock" or name == "Overstock Plus" then + -- No vars needed + elseif name == "Tarot Merchant" or name == "Tarot Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Planet Merchant" or name == "Planet Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Hone" or name == "Glow Up" then + loc_vars = { center.config.extra } + elseif name == "Reroll Surplus" or name == "Reroll Glut" then + loc_vars = { center.config.extra } + elseif name == "Grabber" or name == "Nacho Tong" then + loc_vars = { center.config.extra } + elseif name == "Wasteful" or name == "Recyclomancy" then + loc_vars = { center.config.extra } + elseif name == "Seed Money" or name == "Money Tree" then + loc_vars = { center.config.extra / 5 } + elseif name == "Blank" or name == "Antimatter" then + -- No vars needed + elseif name == "Hieroglyph" or name == "Petroglyph" then + loc_vars = { center.config.extra } + elseif name == "Director's Cut" or name == "Retcon" then + loc_vars = { center.config.extra } + elseif name == "Paint Brush" or name == "Palette" then + loc_vars = { center.config.extra } + elseif name == "Telescope" or name == "Observatory" then + loc_vars = { center.config.extra } + elseif name == "Clearance Sale" or name == "Liquidation" then + loc_vars = { center.config.extra } + end + + -- Use localize to get description text + if not localize then ---@diagnostic disable-line: undefined-global + return "" + end + + local text_lines = localize({ ---@diagnostic disable-line: undefined-global + type = "raw_descriptions", + key = voucher_key, + set = "Voucher", + vars = loc_vars, + }) + + if not text_lines or type(text_lines) ~= "table" then + return "" + end + + -- Concatenate and strip color codes + local text = table.concat(text_lines, " ") + return strip_color_codes(text) +end + ---Gets tag information using localize function (same approach as Tag:set_text) ---@param tag_key string The tag key from G.P_TAGS ---@return table tag_info {name: string, effect: string} @@ -570,6 +652,29 @@ local function get_tag_info(tag_key) return result end +---Gets all owned tags from G.GAME.tags +---@return Tag[] tags Array of Tag objects +local function get_owned_tags() + local tags = {} + + if not G or not G.GAME or not G.GAME.tags then + return tags + end + + for _, tag in pairs(G.GAME.tags) do + if tag and tag.key then + local tag_info = get_tag_info(tag.key) + table.insert(tags, { + key = tag.key, + name = tag_info.name, + effect = tag_info.effect, + }) + end + end + + return tags +end + ---Converts game blind status to uppercase enum ---@param status string Game status (e.g., "Defeated", "Current", "Select") ---@return string uppercase_status Uppercase status enum (e.g., "DEFEATED", "CURRENT", "SELECT") @@ -600,8 +705,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, big = { type = "BIG", @@ -609,8 +713,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, boss = { type = "BOSS", @@ -618,8 +721,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, } @@ -657,8 +759,11 @@ function gamestate.get_blinds_info() local small_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Small if small_tag_key then local tag_info = get_tag_info(small_tag_key) - blinds.small.tag_name = tag_info.name - blinds.small.tag_effect = tag_info.effect + blinds.small.tag = { + key = small_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -681,8 +786,11 @@ function gamestate.get_blinds_info() local big_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Big if big_tag_key then local tag_info = get_tag_info(big_tag_key) - blinds.big.tag_name = tag_info.name - blinds.big.tag_effect = tag_info.effect + blinds.big.tag = { + key = big_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -706,7 +814,7 @@ function gamestate.get_blinds_info() blinds.boss.score = math.floor(base_amount * 2 * ante_scaling) end - -- Boss blind has no tags (tag_name and tag_effect remain empty strings) + -- Boss blind has no tags (tag remains nil) return blinds end @@ -757,16 +865,18 @@ function gamestate.get_gamestate() -- Used vouchers (table) if G.GAME.used_vouchers then local used_vouchers = {} - for voucher_name, voucher_data in pairs(G.GAME.used_vouchers) do - if type(voucher_data) == "table" and voucher_data.description then - used_vouchers[voucher_name] = voucher_data.description - else - used_vouchers[voucher_name] = "" - end + for voucher_name, _ in pairs(G.GAME.used_vouchers) do + used_vouchers[voucher_name] = get_voucher_effect(voucher_name) end state_data.used_vouchers = used_vouchers end + -- Owned tags (Tag[]) + local owned_tags = get_owned_tags() + if #owned_tags > 0 then + state_data.tags = owned_tags + end + -- Poker hands if G.GAME.hands then state_data.hands = extract_hand_info(G.GAME.hands) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 32b789bf..aea1d7cc 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -37,7 +37,7 @@ { "name": "add", "summary": "Add a new card to the game", - "description": "Add a new card to the game (joker, consumable, voucher, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", + "description": "Add a new card to the game (joker, consumable, voucher, pack, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", "tags": [ { "$ref": "#/components/tags/cards" @@ -46,7 +46,7 @@ "params": [ { "name": "key", - "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", + "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), packs (p_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", "required": true, "schema": { "$ref": "#/components/schemas/CardKey" @@ -577,7 +577,7 @@ { "name": "sell", "summary": "Sell a joker or consumable", - "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable.", + "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (SMODS_BOOSTER_OPENED state with Joker set pack) to make room for new jokers.", "tags": [ { "$ref": "#/components/tags/shop" @@ -614,6 +614,9 @@ { "$ref": "#/components/errors/BadRequest" }, + { + "$ref": "#/components/errors/InvalidState" + }, { "$ref": "#/components/errors/NotAllowed" } @@ -913,6 +916,13 @@ "type": "string" } }, + "tags": { + "type": "array", + "description": "Accumulated tags owned by the player", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, "hands": { "type": "object", "description": "Poker hands information", @@ -1053,6 +1063,29 @@ } } }, + "Tag": { + "type": "object", + "description": "Tag information", + "properties": { + "key": { + "type": "string", + "description": "The tag key (e.g., 'tag_polychrome')" + }, + "name": { + "type": "string", + "description": "Display name (e.g., 'Polychrome Tag')" + }, + "effect": { + "type": "string", + "description": "Description of the tag's effect" + } + }, + "required": [ + "key", + "name", + "effect" + ] + }, "Blind": { "type": "object", "description": "Blind information", @@ -1075,13 +1108,9 @@ "type": "integer", "description": "Score requirement to beat this blind" }, - "tag_name": { - "type": "string", - "description": "Name of the tag associated with this blind (Small/Big only)" - }, - "tag_effect": { - "type": "string", - "description": "Description of the tag's effect (Small/Big only)" + "tag": { + "$ref": "#/components/schemas/Tag", + "description": "Tag associated with this blind (Small/Big only)" } }, "required": [ diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 53f43b13..3f2f3854 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -17,6 +17,7 @@ ---@field ante_num integer Current ante number ---@field money integer Current money amount ---@field used_vouchers table? Vouchers used (name -> description) +---@field tags Tag[]? Accumulated tags owned by the player ---@field hands table? Poker hands information ---@field round Round? Current round state ---@field blinds table<"small"|"big"|"boss", Blind>? Blind information @@ -47,14 +48,18 @@ ---@field reroll_cost integer? Current cost to reroll the shop ---@field chips integer? Current chips scored in this round +---@class Tag +---@field key string The tag key (e.g., "tag_polychrome", "tag_double") +---@field name string Display name of the tag (e.g., "Polychrome Tag") +---@field effect string Description of the tag's effect + ---@class Blind ---@field type Blind.Type Type of the blind ---@field status Blind.Status Status of the bilnd ---@field name string Name of the blind (e.g., "Small", "Big" or the Boss name) ---@field effect string Description of the blind's effect ---@field score integer Score requirement to beat this blind ----@field tag_name string? Name of the tag associated with this blind (Small/Big only) ----@field tag_effect string? Description of the tag's effect (Small/Big only) +---@field tag Tag? Tag associated with this blind (Small/Big only) ---@class Area ---@field count integer Current number of cards in this area diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index fe11a58b..f482c64a 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -155,7 +155,7 @@ def test_invalid_key_unknown_format(self, client: httpx.Client) -> None: assert_error_response( api(client, "add", {"key": "x_unknown"}), "BAD_REQUEST", - "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", ) def test_invalid_key_known_format(self, client: httpx.Client) -> None: diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 5aaa6081..82e08189 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -46,7 +46,7 @@ def test_buy_no_card_in_shop_area(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "No jokers/consumables/cards in the shop. Reroll to restock the shop", + "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop.", ) def test_buy_invalid_card_index(self, client: httpx.Client) -> None: @@ -110,7 +110,7 @@ def test_buy_joker_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", + "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5. Sell a joker using `sell` to free a slot.", ) def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: @@ -126,7 +126,7 @@ def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 1}), "BAD_REQUEST", - "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", + "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2. Use `use` to activate a consumable or `sell` to remove one.", ) def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: @@ -137,7 +137,7 @@ def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"voucher": 0}), "BAD_REQUEST", - "No vouchers to redeem. Defeat boss blind to restock", + "No vouchers to redeem. Defeat boss blind to restock.", ) def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: @@ -148,7 +148,7 @@ def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"pack": 0}), "BAD_REQUEST", - "No packs to open", + "No packs to open. Use `next_round` to advance to the next blind and restock the shop.", ) def test_buy_joker_success(self, client: httpx.Client) -> None: diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index e35af2ba..a4ba0536 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -3,6 +3,7 @@ import re import httpx +import pytest from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -147,24 +148,28 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: "name": "Small Blind", "effect": "", "score": 300, - "tag_effect": "Next base edition shop Joker is free and becomes Polychrome", - "tag_name": "Polychrome Tag", + "tag": { + "key": "tag_polychrome", + "name": "Polychrome Tag", + "effect": "Next base edition shop Joker is free and becomes Polychrome", + }, }, "big": { - "effect": "", + "type": "BIG", "name": "Big Blind", + "effect": "", "score": 450, - "tag_effect": "After defeating the Boss Blind, gain $25", - "tag_name": "Investment Tag", - "type": "BIG", + "tag": { + "key": "tag_investment", + "name": "Investment Tag", + "effect": "After defeating the Boss Blind, gain $25", + }, }, "boss": { - "effect": "-1 Hand Size", + "type": "BOSS", "name": "The Manacle", + "effect": "-1 Hand Size", "score": 600, - "tag_effect": "", - "tag_name": "", - "type": "BOSS", }, } actual_blinds = { @@ -816,6 +821,167 @@ def test_cost_sell_owned_joker(self, client: httpx.Client) -> None: assert joker["cost"]["sell"] > 0 +class TestGamestateUsedVouchers: + """Test gamestate used_vouchers effect text extraction.""" + + @pytest.mark.parametrize( + "voucher_key,expected_effect", + [ + # --- No loc_vars --- + ("v_overstock_norm", "+1 card slot available in shop"), + ("v_overstock_plus", "+1 card slot available in shop"), + ("v_crystal_ball", "+1 consumable slot"), + ( + "v_omen_globe", + "Spectral cards may appear in any of the Arcana Packs", + ), + ( + "v_telescope", + "Celestial Packs always contain the Planet card for your " + "most played poker hand", + ), + ("v_magic_trick", "Playing cards can be purchased from the shop"), + ( + "v_illusion", + "Playing cards in shop may have an Enhancement, Edition, and/or a Seal", + ), + ("v_blank", "Does nothing?"), + ("v_antimatter", "+1 Joker Slot"), + # --- Uses center.config.extra_disp --- + ( + "v_tarot_merchant", + "Tarot cards appear 2X more frequently in the shop", + ), + ( + "v_tarot_tycoon", + "Tarot cards appear 4X more frequently in the shop", + ), + ( + "v_planet_merchant", + "Planet cards appear 2X more frequently in the shop", + ), + ( + "v_planet_tycoon", + "Planet cards appear 4X more frequently in the shop", + ), + # --- Uses center.config.extra --- + ( + "v_hone", + "Foil, Holographic, and Polychrome cards appear 2X more often", + ), + ( + "v_glow_up", + "Foil, Holographic, and Polychrome cards appear 4X more often", + ), + ("v_reroll_surplus", "Rerolls cost $2 less"), + ("v_reroll_glut", "Rerolls cost $2 less"), + ("v_grabber", "Permanently gain +1 hand per round"), + ("v_nacho_tong", "Permanently gain +1 hand per round"), + ("v_wasteful", "Permanently gain +1 discard each round"), + ("v_recyclomancy", "Permanently gain +1 discard each round"), + ("v_clearance_sale", "All cards and packs in shop are 25% off"), + ("v_liquidation", "All cards and packs in shop are 50% off"), + ( + "v_directors_cut", + "Reroll Boss Blind 1 time per Ante, $10 per roll", + ), + ("v_retcon", "Reroll Boss Blind unlimited times, $10 per roll"), + ("v_paint_brush", "+1 hand size"), + ("v_palette", "+1 hand size"), + ("v_hieroglyph", "-1 Ante, -1 hand each round"), + ("v_petroglyph", "-1 Ante, -1 discard each round"), + # --- Uses center.config.extra / 5 --- + ( + "v_seed_money", + "Raise the cap on interest earned in each round to $10", + ), + ( + "v_money_tree", + "Raise the cap on interest earned in each round to $20", + ), + # --- Uses center.config.extra (mult) --- + ( + "v_observatory", + "Planet cards in your consumable area give X1.5 Mult " + "for their specified poker hand", + ), + ], + ids=lambda v: v if v.startswith("v_") else "", + ) + def test_voucher_effect_text( + self, client: httpx.Client, voucher_key: str, expected_effect: str + ) -> None: + """Test that used_vouchers contains correct effect text for each voucher.""" + load_fixture( + client, + "gamestate", + "state-SHOP", + ) + response = api(client, "add", {"key": voucher_key}) + gamestate = assert_gamestate_response(response) + assert gamestate["vouchers"]["cards"][1]["value"]["effect"] == expected_effect + response = api(client, "buy", {"voucher": 1}) + gamestate = assert_gamestate_response(response) + assert voucher_key in gamestate["used_vouchers"] + assert gamestate["used_vouchers"][voucher_key] == expected_effect + + +class TestGamestateTags: + """Test gamestate Tag structure and owned_tags extraction.""" + + def test_blind_tag_structure(self, client: httpx.Client) -> None: + """Test blind tag has key, name, effect fields.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + gamestate = load_fixture(client, "gamestate", fixture_name) + + # Small blind should have a tag + small_tag = gamestate["blinds"]["small"]["tag"] + assert small_tag is not None + assert "key" in small_tag + assert "name" in small_tag + assert "effect" in small_tag + assert small_tag["key"] == "tag_polychrome" + assert small_tag["name"] == "Polychrome Tag" + assert "Polychrome" in small_tag["effect"] + + # Big blind should have a tag + big_tag = gamestate["blinds"]["big"]["tag"] + assert big_tag is not None + assert "key" in big_tag + assert "name" in big_tag + assert "effect" in big_tag + + # Boss blind should not have a tag + assert gamestate["blinds"]["boss"].get("tag") is None + + def test_tags_empty_initially(self, client: httpx.Client) -> None: + """Test tags is empty/not present at start of run.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + gamestate = load_fixture(client, "gamestate", fixture_name) + # tags should not be present when empty + assert "tags" not in gamestate + + def test_tags_populated_after_skip(self, client: httpx.Client) -> None: + """Test tags is populated after skipping a blind.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + load_fixture(client, "gamestate", fixture_name) + + # Skip the small blind to get its tag + response = api(client, "skip", {}) + gamestate = assert_gamestate_response(response) + + # Should now have tags + assert "tags" in gamestate + assert len(gamestate["tags"]) >= 1 + + # Check tag structure + tag = gamestate["tags"][0] + assert "key" in tag + assert "name" in tag + assert "effect" in tag + assert tag["key"].startswith("tag_") + + class TestGamestateCardModifiers: """Test gamestate card modifiers.""" diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 1ff2a514..0e60555b 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -158,6 +158,35 @@ def test_pack_joker_slots_full(self, client: httpx.Client) -> None: "Cannot select joker, joker slots are full. Current: 5, Limit: 5", ) + def test_pack_joker_slots_full_sell_joker(self, client: httpx.Client) -> None: + """Test selling a joker to make room when joker slots are full during pack selection.""" + gamestate = load_fixture( + client, + "pack", + "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-5", + ) + assert gamestate["jokers"]["count"] == 5 + before_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + result = api(client, "sell", {"joker": 0}) + gamestate = assert_gamestate_response(result) + assert gamestate["jokers"]["count"] == 4 + result = api(client, "pack", {"card": 0}) + gamestate = assert_gamestate_response(result, state="SHOP") + assert gamestate["jokers"]["count"] == 5 + after_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + assert before_jokers != after_jokers + + def test_pack_tarot_try_to_sell_joker(self, client: httpx.Client) -> None: + """Test that selling jokers is not allowed when a non-buffoon pack is open.""" + load_fixture( + client, "pack", "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_heirophant" + ) + assert_error_response( + api(client, "sell", {"joker": 0}), + "NOT_ALLOWED", + "Can only sell jokers when a Buffoon pack is open", + ) + def test_pack_joker_slots_available(self, client: httpx.Client) -> None: """Test selecting joker when slots available succeeds.""" load_fixture( diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 5a89edc8..1ca8179d 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -20,10 +20,12 @@ def test_skip_small_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["small"]["status"] == "SELECT" + assert "tags" not in gamestate response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["small"]["status"] == "SKIPPED" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" def test_skip_big_blind(self, client: httpx.Client) -> None: """Test skipping Big blind in BLIND_SELECT state.""" @@ -32,10 +34,14 @@ def test_skip_big_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert {"tag_polychrome"} == set(k["key"] for k in gamestate["tags"]) response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["big"]["status"] == "SKIPPED" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) def test_skip_big_boss(self, client: httpx.Client) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -44,6 +50,10 @@ def test_skip_big_boss(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) assert_error_response( api(client, "skip", {}), "NOT_ALLOWED",