diff --git a/CLAUDE.md b/CLAUDE.md index 85f7cbd8..acf84b5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,3 +169,7 @@ Error responses follow JSON-RPC 2.0 format: "id": 1 } ``` + +**Gotcha — held tags vs blind tag offers:** `GameState.tags` is the list of skip rewards currently held (`G.GAME.tags`). Blind *offers* for the next skip remain under `blinds.small|big.tag_name` / `tag_effect`. Immediate tags may resolve on skip and leave `tags` empty. + +**Gotcha — `economy.shop_slots` vs Area `shop`:** `economy.shop_slots` is how many cards the main shop holds (Overstock). `shop` is still the Area of cards currently for sale. diff --git a/docs/api.md b/docs/api.md index 6f3ed22f..b33850c5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -697,6 +697,19 @@ The complete game state returned by most methods. "stake": "WHITE", "seed": "ABC123", "won": false, + "tags": [], + "economy": { + "interest_cap": 25, + "interest_amount": 1, + "bankrupt_at": 0, + "discount_percent": 0, + "shop_slots": 2 + }, + "probabilities": { "normal": 1 }, + "pool_flags": {}, + "skips": 0, + "starting_deck_size": 52, + "consumable_usage_total": { "tarot": 1, "planet": 0, "spectral": 0, "tarot_planet": 1, "all": 1 }, "used_vouchers": {}, "hands": { ... }, "round": { ... }, @@ -712,6 +725,8 @@ The complete game state returned by most methods. } ``` +`tags` are held skip rewards (not the Small/Big blind *offer* tags under `blinds.*.tag_name`). `economy.shop_slots` is the shop card-slot count; `shop` remains the shop card Area. `consumable_usage_total` is omitted until a consumable has been used. `round` also includes `idol_card` / `mail_card` / `ancient_card` / `castle_card` targets and `free_rerolls`. + ### Area Represents a card area (hand, jokers, consumables, shop, etc.). @@ -763,6 +778,11 @@ Represents a card area (hand, jokers, consumables, shop, etc.). ```json { "hands_left": 4, + "free_rerolls": 0, + "idol_card": { "suit": "S", "rank": "A" }, + "mail_card": { "rank": "A" }, + "ancient_card": { "suit": "S" }, + "castle_card": { "suit": "S" }, "hands_played": 0, "discards_left": 3, "discards_used": 0, diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..4598423f 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -441,26 +441,31 @@ local function extract_round_info() return {} end + local current = G.GAME.current_round local round = {} - if G.GAME.current_round.hands_left then - round.hands_left = G.GAME.current_round.hands_left + if current.hands_left ~= nil then + round.hands_left = current.hands_left end - if G.GAME.current_round.hands_played then - round.hands_played = G.GAME.current_round.hands_played + if current.hands_played ~= nil then + round.hands_played = current.hands_played end - if G.GAME.current_round.discards_left then - round.discards_left = G.GAME.current_round.discards_left + if current.discards_left ~= nil then + round.discards_left = current.discards_left end - if G.GAME.current_round.discards_used then - round.discards_used = G.GAME.current_round.discards_used + if current.discards_used ~= nil then + round.discards_used = current.discards_used end - if G.GAME.current_round.reroll_cost then - round.reroll_cost = G.GAME.current_round.reroll_cost + if current.reroll_cost ~= nil then + round.reroll_cost = current.reroll_cost + end + + if current.free_rerolls ~= nil then + round.free_rerolls = current.free_rerolls end -- Chips is stored in G.GAME not G.GAME.current_round @@ -468,6 +473,31 @@ local function extract_round_info() round.chips = G.GAME.chips end + if current.idol_card then + round.idol_card = { + suit = convert_suit_to_enum(current.idol_card.suit), + rank = convert_rank_to_enum(current.idol_card.rank), + } + end + + if current.mail_card then + round.mail_card = { + rank = convert_rank_to_enum(current.mail_card.rank), + } + end + + if current.ancient_card then + round.ancient_card = { + suit = convert_suit_to_enum(current.ancient_card.suit), + } + end + + if current.castle_card then + round.castle_card = { + suit = convert_suit_to_enum(current.castle_card.suit), + } + end + return round end @@ -570,6 +600,61 @@ local function get_tag_info(tag_key) return result end +---Extracts held tags from G.GAME.tags (skip rewards that have not resolved yet) +---@return GameState.Tag[] tags +local function extract_tags() + local tags = {} + if not G or not G.GAME or not G.GAME.tags then + return tags + end + + for i, tag in ipairs(G.GAME.tags) do + local key = tag.key or "" + local info = get_tag_info(key) + tags[i] = { + key = key, + name = info.name, + effect = info.effect, + } + end + + return tags +end + +---Extracts economy helpers (interest, discount, shop slots) +---@return GameState.Economy economy +local function extract_economy() + local shop_slots = 2 + if G and G.GAME and G.GAME.shop and G.GAME.shop.joker_max ~= nil then + shop_slots = G.GAME.shop.joker_max + end + + return { + interest_cap = (G and G.GAME and G.GAME.interest_cap) or 25, + interest_amount = (G and G.GAME and G.GAME.interest_amount) or 1, + bankrupt_at = (G and G.GAME and G.GAME.bankrupt_at) or 0, + discount_percent = (G and G.GAME and G.GAME.discount_percent) or 0, + shop_slots = shop_slots, + } +end + +---Extracts consumable usage totals (Fortune Teller / Satellite counters) +---@return table? usage +local function extract_consumable_usage_total() + if not G or not G.GAME or not G.GAME.consumeable_usage_total then + return nil + end + + local usage = G.GAME.consumeable_usage_total + return { + tarot = usage.tarot or 0, + planet = usage.planet or 0, + spectral = usage.spectral or 0, + tarot_planet = usage.tarot_planet or 0, + all = usage.all or 0, + } +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") @@ -754,6 +839,34 @@ function gamestate.get_gamestate() state_data.seed = G.GAME.pseudorandom.seed end + -- Held tags (skip rewards not yet resolved) + state_data.tags = extract_tags() + + -- Economy helpers (interest / discount / shop slots) + state_data.economy = extract_economy() + + -- Odds multiplier (Oops All 6s scales probabilities.normal) + state_data.probabilities = { + normal = (G.GAME.probabilities and G.GAME.probabilities.normal) or 1, + } + + -- Pool flags (e.g. Gros Michel extinct) + state_data.pool_flags = G.GAME.pool_flags or {} + + -- Blind skips this run (Throwback) + state_data.skips = G.GAME.skips or 0 + + -- Starting deck size (Erosion) + if G.GAME.starting_deck_size ~= nil then + state_data.starting_deck_size = G.GAME.starting_deck_size + end + + -- Consumable usage totals (omitted until a consumable has been used) + local consumable_usage_total = extract_consumable_usage_total() + if consumable_usage_total then + state_data.consumable_usage_total = consumable_usage_total + end + -- Used vouchers (table) if G.GAME.used_vouchers then local used_vouchers = {} diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index eaea0856..8ecef5f4 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -906,6 +906,46 @@ "type": "boolean", "description": "Whether the game has been won" }, + "tags": { + "type": "array", + "description": "Held tags from skips (not blind skip offers). Empty when none.", + "items": { + "$ref": "#/components/schemas/GameTag" + } + }, + "economy": { + "$ref": "#/components/schemas/Economy", + "description": "Interest, discount, and shop slot helpers" + }, + "probabilities": { + "type": "object", + "description": "Odds multipliers (Oops All 6s scales normal)", + "properties": { + "normal": { + "type": "number", + "description": "Base odds multiplier (1 by default)" + } + } + }, + "pool_flags": { + "type": "object", + "description": "Run pool flags (e.g. Gros Michel extinct)", + "additionalProperties": { + "type": "boolean" + } + }, + "skips": { + "type": "integer", + "description": "Number of blinds skipped this run" + }, + "starting_deck_size": { + "type": "integer", + "description": "Starting deck size (Erosion baseline)" + }, + "consumable_usage_total": { + "$ref": "#/components/schemas/ConsumableUsageTotal", + "description": "Tarot/Planet/Spectral use counts; omitted until first consumable is used" + }, "used_vouchers": { "type": "object", "description": "Vouchers used (name -> description)", @@ -1047,9 +1087,129 @@ "type": "integer", "description": "Current cost to reroll the shop" }, + "free_rerolls": { + "type": "integer", + "description": "Free shop rerolls remaining" + }, "chips": { "type": "integer", "description": "Current chips scored in this round" + }, + "idol_card": { + "type": "object", + "description": "Current The Idol suit/rank target", + "properties": { + "suit": { + "$ref": "#/components/schemas/Suit" + }, + "rank": { + "$ref": "#/components/schemas/Rank" + } + } + }, + "mail_card": { + "type": "object", + "description": "Current Mail-In Rebate rank target", + "properties": { + "rank": { + "$ref": "#/components/schemas/Rank" + } + } + }, + "ancient_card": { + "type": "object", + "description": "Current Ancient Joker suit target", + "properties": { + "suit": { + "$ref": "#/components/schemas/Suit" + } + } + }, + "castle_card": { + "type": "object", + "description": "Current Castle suit target", + "properties": { + "suit": { + "$ref": "#/components/schemas/Suit" + } + } + } + } + }, + "GameTag": { + "type": "object", + "description": "A held tag from skipping a blind", + "properties": { + "key": { + "type": "string", + "description": "Tag key (e.g. tag_coupon)" + }, + "name": { + "type": "string", + "description": "Localized tag name" + }, + "effect": { + "type": "string", + "description": "Localized tag effect description" + } + }, + "required": [ + "key", + "name", + "effect" + ] + }, + "Economy": { + "type": "object", + "description": "Interest, discount, and shop slot helpers", + "properties": { + "interest_cap": { + "type": "integer", + "description": "Money needed for max interest" + }, + "interest_amount": { + "type": "integer", + "description": "Interest earned per $5 held" + }, + "bankrupt_at": { + "type": "integer", + "description": "Minimum money before bankruptcy (Credit Card: -20)" + }, + "discount_percent": { + "type": "integer", + "description": "Shop discount percent (Clearance Sale / Liquidation)" + }, + "shop_slots": { + "type": "integer", + "description": "Number of main shop card slots (not the shop Area)" + } + }, + "required": [ + "interest_cap", + "interest_amount", + "bankrupt_at", + "discount_percent", + "shop_slots" + ] + }, + "ConsumableUsageTotal": { + "type": "object", + "description": "Consumable use counts for this run", + "properties": { + "tarot": { + "type": "integer" + }, + "planet": { + "type": "integer" + }, + "spectral": { + "type": "integer" + }, + "tarot_planet": { + "type": "integer" + }, + "all": { + "type": "integer" } } }, diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 53f43b13..e61c998d 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -16,6 +16,13 @@ ---@field round_num integer Current round number ---@field ante_num integer Current ante number ---@field money integer Current money amount +---@field tags GameState.Tag[]? Held tags from skips (not blind skip offers) +---@field economy GameState.Economy? Interest, discount, and shop slot helpers +---@field probabilities GameState.Probabilities? Odds multipliers (Oops All 6s) +---@field pool_flags table? Run pool flags (e.g. Gros Michel extinct) +---@field skips integer? Number of blinds skipped this run +---@field starting_deck_size integer? Starting deck size (Erosion baseline) +---@field consumable_usage_total GameState.ConsumableUsageTotal? Tarot/Planet/Spectral use counts; omitted until first use ---@field used_vouchers table? Vouchers used (name -> description) ---@field hands table? Poker hands information ---@field round Round? Current round state @@ -30,6 +37,28 @@ ---@field packs Area? Booster packs area (available during shop phase) ---@field won boolean? Whether the game has been won +---@class GameState.Tag +---@field key string Tag key (e.g. "tag_coupon") +---@field name string Localized tag name +---@field effect string Localized tag effect description + +---@class GameState.Economy +---@field interest_cap integer Money needed for max interest +---@field interest_amount integer Interest earned per $5 held +---@field bankrupt_at integer Minimum money before bankruptcy (Credit Card: -20) +---@field discount_percent integer Shop discount percent (Clearance Sale / Liquidation) +---@field shop_slots integer Number of main shop card slots (not the shop Area) + +---@class GameState.Probabilities +---@field normal number Base odds multiplier (1 by default; 2 with Oops All 6s) + +---@class GameState.ConsumableUsageTotal +---@field tarot integer Tarot cards used this run +---@field planet integer Planet cards used this run +---@field spectral integer Spectral cards used this run +---@field tarot_planet integer Tarot + Planet cards used this run +---@field all integer All consumables used this run + ---@class Hand ---@field order integer The importance/ordering of the hand ---@field level integer Level of the hand in the current run @@ -45,7 +74,22 @@ ---@field discards_left integer? Number of discards remaining in this round ---@field discards_used integer? Number of discards used in this round ---@field reroll_cost integer? Current cost to reroll the shop +---@field free_rerolls integer? Free shop rerolls remaining ---@field chips integer? Current chips scored in this round +---@field idol_card Round.IdolCard? Current The Idol suit/rank target +---@field mail_card Round.MailCard? Current Mail-In Rebate rank target +---@field ancient_card Round.SuitCard? Current Ancient Joker suit target +---@field castle_card Round.SuitCard? Current Castle suit target + +---@class Round.IdolCard +---@field suit Card.Value.Suit +---@field rank Card.Value.Rank + +---@class Round.MailCard +---@field rank Card.Value.Rank + +---@class Round.SuitCard +---@field suit Card.Value.Suit ---@class Blind ---@field type Blind.Type Type of the blind diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index e35af2ba..ff3b766c 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -89,6 +89,63 @@ def test_won_true_extraction(self, client: httpx.Client) -> None: response = api(client, "play", {"cards": [0]}) assert response["result"]["won"] is True + def test_tags_empty_at_run_start(self, client: httpx.Client) -> None: + """Fresh runs have no held tags.""" + gamestate = load_fixture(client, "gamestate", "state-SELECTING_HAND") + assert gamestate["tags"] == [] + + def test_tags_and_skips_after_skip(self, client: httpx.Client) -> None: + """Skipping a blind increments skips; held tags expose key/name/effect.""" + before = load_fixture( + client, "skip", "state-BLIND_SELECT--blinds.small.status-SELECT" + ) + assert before.get("skips", 0) == 0 + response = api(client, "skip", {}) + after = assert_gamestate_response(response, state="BLIND_SELECT") + assert after["skips"] == 1 + assert isinstance(after["tags"], list) + for tag in after["tags"]: + assert isinstance(tag["key"], str) and tag["key"].startswith("tag_") + assert isinstance(tag["name"], str) and tag["name"] + assert isinstance(tag["effect"], str) + + def test_economy_defaults(self, client: httpx.Client) -> None: + """Economy helpers expose interest and shop slot defaults.""" + gamestate = load_fixture(client, "gamestate", "state-SELECTING_HAND") + economy = gamestate["economy"] + assert economy["interest_cap"] == 25 + assert economy["interest_amount"] == 1 + assert economy["bankrupt_at"] == 0 + assert economy["discount_percent"] == 0 + assert economy["shop_slots"] == 2 + + def test_probabilities_normal_default_and_oops(self, client: httpx.Client) -> None: + """probabilities.normal starts at 1 and doubles with Oops All 6s.""" + gamestate = load_fixture(client, "gamestate", "state-SELECTING_HAND") + assert gamestate["probabilities"]["normal"] == 1 + response = api(client, "add", {"key": "j_oops"}) + after = assert_gamestate_response(response) + assert after["probabilities"]["normal"] == 2 + + def test_pool_flags_and_starting_deck_size(self, client: httpx.Client) -> None: + """pool_flags and starting_deck_size are present on a started run.""" + gamestate = load_fixture(client, "gamestate", "state-SELECTING_HAND") + assert gamestate["pool_flags"] == {} + assert gamestate["starting_deck_size"] == 52 + + def test_consumable_usage_total_after_using_tarot( + self, client: httpx.Client + ) -> None: + """Using a Tarot populates consumable_usage_total.""" + before = load_fixture(client, "gamestate", "state-SELECTING_HAND") + assert before.get("consumable_usage_total") is None + api(client, "add", {"key": "c_hermit"}) + response = api(client, "use", {"consumable": 0}) + after = assert_gamestate_response(response) + usage = after["consumable_usage_total"] + assert usage["tarot"] >= 1 + assert usage["all"] >= 1 + class TestGamestateRound: """Test gamestate round extraction.""" @@ -133,6 +190,47 @@ def test_round_reroll_cost_extraction(self, client: httpx.Client) -> None: response = api(client, "reroll", {}) assert response["result"]["round"]["reroll_cost"] == 6 + def test_round_joker_targets_and_free_rerolls(self, client: httpx.Client) -> None: + """Round exposes Idol/Mail/Ancient/Castle targets and free_rerolls.""" + selecting = load_fixture(client, "gamestate", "state-SELECTING_HAND") + assert selecting["round"]["idol_card"]["suit"] in {"H", "D", "C", "S"} + assert selecting["round"]["idol_card"]["rank"] in { + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "T", + "J", + "Q", + "K", + "A", + } + assert selecting["round"]["mail_card"]["rank"] in { + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "T", + "J", + "Q", + "K", + "A", + } + assert selecting["round"]["ancient_card"]["suit"] in {"H", "D", "C", "S"} + assert selecting["round"]["castle_card"]["suit"] in {"H", "D", "C", "S"} + + shop = load_fixture(client, "gamestate", "state-SHOP") + assert isinstance(shop["round"]["free_rerolls"], int) + assert shop["round"]["free_rerolls"] >= 0 + class TestGamestateBlinds: """Test gamestate blind extraction."""