feat!(lua.endpoints): support challenge runs in start endpoint
- Dominant language
- Python
- Stars
- 72
- Forks
- 18
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
Extend the `start` endpoint to support **challenge runs**. The 20 base-game challenges (e.g. *The Omelette*, *Mad World*, *Jokerless*) are currently unreachable via the API — there is no way to start one.
## Context
Challenges are a distinct run mode that forces a specific deck, fixed starting jokers/consumables/vouchers, custom rules/modifiers, and card restrictions. They are defined in `G.CHALLENGES` (`challenges.lua`), an array where each entry has a stable string `id` with the `c_` prefix (e.g. `c_omelette_1`).
The game's own entry point is `G.FUNCS.start_challenge_run` (`button_callbacks.lua:1847`):
```lua
G.FUNCS.start_challenge_run = function(e)
G.FUNCS.start_run(e, { stake = 1, challenge = G.CHALLENGES[e.config.id] })
end
```
and `G:start_run(args)` already honors `args.challenge` (`game.lua:2041-2145`), applying the challenge's deck, jokers, consumeables, vouchers, rules, modifiers, and restrictions.
## Proposed params
Add an optional `challenge` param to `start`:
```
method: start
params:
deck Deck # existing — required today
stake Stake # existing — required today
seed string? # existing
challenge Challenge? # NEW — challenge id, e.g. "c_omelette_1"
```
### Argument interaction rules (from source)
When `challenge` is provided:
- **`deck` → must NOT be provided** (rejected). The challenge dictates its own deck via `args.challenge.deck.type`, which **overrides** any user-selected deck in `G:start_run` (`game.lua:2037`). Passing both is meaningless.
- **`stake` → must NOT be provided** (rejected). The canonical challenge path hardcodes `stake = 1` (`button_callbacks.lua:1849`). Challenges are not designed to be combined with higher stakes.
- **`seed` → compatible** (optional, allowed). Seed handling in `G:start_run` (`game.lua:2158`) is independent of `challenge`. No base-game challenge forces a seed (the `set_seed` rule exists only in a commented-out test entry).
So `challenge` is mutually exclusive with `deck` and `stake`, but composes freely with `seed`.
### Validation
Resolve the challenge id by scanning the live `G.CHALLENGES` array (mirrors SMODS's own lookup in `game_object.lua:2854`):
```lua
local challenge_entry
for _, v in ipairs(G.CHALLENGES) do
if v.id == args.challenge then challenge_entry = v; break end
end
if not challenge_entry then return BAD_REQUEST end
```
This is runtime validation against the live table — same approach `start` already uses for `deck` (against `G.P_CENTERS`) and `stake` (against `G.P_STAKES`). Required because `G.CHALLENGES` is SMODS-extensible (`SMODS.Challenge` inserts into the array, `game_object.lua:2882`).
### Call
```lua
local run_params = { stake = 1, challenge = challenge_entry }
if args.seed then run_params.seed = args.seed end
G.FUNCS.setup_run({ config = {} })
G.FUNCS.exit_overlay_menu()
G.GAME.selected_back:change_to(...) -- skipped; challenge sets its own deck
G.GAME.viewed_back:change_to(...)
G.FUNCS.start_run(nil, run_params)
```
## Type definition
Add a static `Challenge` enum alias in `src/lua/utils/enums.lua` (following the existing `Deck` and `Stake` aliases — both SMODS-extensible, both defined statically, both still validated at runtime in `start.lua`). Each description combines the challenge's signature rule with its key starting condition, drawn from `challenges.lua`, the canonical rule text in `localization/en-us.lua:4240-4316` (`ch_c_*` entries), and the rule implementations:
```lua
---@alias Challenge
---| "c_omelette_1" # The Omelette: no blind rewards, no interest, no hand money; start with 5 Eggs
---| "c_city_1" # 15 Minute City: eternal Ride the Bus + Shortcut; deck is only 4–K
---| "c_rich_1" # Rich get Richer: chips capped at current $; start with $100
---| "c_knife_1" # On a Knife's Edge: eternal pinned Ceremonial Dagger
---| "c_xray_1" # X-ray Vision: 1 in 4 cards drawn face-down
---| "c_mad_world_1" # Mad World: no hand money/interest; eternal Pareidolia + Business Cards; deck is 2–9 only
---| "c_luxury_1" # Luxury Tax: hand size -1 per $5 held; start with 10 hand size
---| "c_non_perishable_1" # Non-Perishable: all Jokers are Eternal
---| "c_medusa_1" # Medusa: eternal Marble Joker; all J/Q/K are Stone cards
---| "c_double_nothing_1" # Double or Nothing: played cards debuff after scoring; all cards have Red seal
---| "c_typecast_1" # Typecast: at ante 4 all Jokers become Eternal and slots drop to 0
---| "c_inflation_1" # Inflation: prices +$1 every purchase; start with Credit Card
---| "c_bram_poker_1" # Bram Poker: no shop jokers; eternal Vampire + Tarot-focused start
---| "c_fragile_1" # Fragile: all-Glass deck; 2× negative eternal Oops; suit-change Tarots banned
---| "c_monolith_1" # Monolith: eternal Obelisk + negative Marble
---| "c_blast_off_1" # Blast Off: 2 hands, 2 discards, 4 slots; eternal Constellation + Rocket
---| "c_five_card_1" # Five-Card Draw: 5-card hand size, 7 slots, 6 discards; Card Sharp + Joker
---| "c_golden_needle_1" # Golden Needle: 1 hand; discards cost $1; start with Credit Card
---| "c_cruelty_1" # Cruelty: 3 joker slots; Small & Big blinds give no reward
---| "c_jokerless_1" # Jokerless: 0 joker slots; no jokers in shop; joker-granting cards banned
```
## Notes
- Prefix `c_` set by `class_prefix = 'c'` (`game_object.lua:2869`), shared with base game. The `c_` prefix is also shared with consumables (`c_fool`), but they live in separate tables (`G.CHALLENGES` vs `G.P_CENTERS`), so there is no lookup collision. The `_1` suffix is a base-game convention, not enforced — SMODS challenges may omit it.
- Display names: `G.localization.misc.challenge_names[id]`.
- `requires_state` stays `{ G.STATES.MENU }`; the existing completion check (waits for `blind_on_deck` + `blind_select_opts`) applies unchanged.
Contributor guide
Assessment
This issue has not been assessed yet.