Skip to content

Generalized Quantum API#1351

Open
AllUniversal wants to merge 161 commits into
Steamodded:mainfrom
AllUniversal:general-quantum-api
Open

Generalized Quantum API#1351
AllUniversal wants to merge 161 commits into
Steamodded:mainfrom
AllUniversal:general-quantum-api

Conversation

@AllUniversal
Copy link
Copy Markdown
Contributor

@AllUniversal AllUniversal commented Apr 26, 2026

+Title

For questions/suggestions, respond here or ping me on Discord!

SMODS.QuantumCardField

A QuantumCardField (QField) defines an aspect of a card that can be multi-valued / which can be dynamically modified. By default the following QFields are defined;
rank, enhancement, seal, edition, sticker and suit. Each has to be enabled by setting SMODS.optional_features.quantum_fields[key] = true, or for custom fields (e.g. for Paperback's paperclips) by setting default_enabled = true in the object definition.

All QFields inject the following functions (where key is the QField key, e.g. rank);

Target objects and function prefixes can be changed by defining the target_objects and inject_args object fields according to their usage in SMODS.QuantumCardField.inject()


Card:get_[key]s(args)

This function gets all [key] values of a card, e.g. all enhancements. This works using two contexts; card_has_check and _quantum_getter, the latter of which also defines flags for every active QField, e.g. get_ranks for ranks or check_enhancement for enhancements.

The enhancement QField defines target_objects = { getter = {Card, SMODS}, ...} in its object definition, thus SMODS.get_enhancements() remains a valid function.

For the getter context, only ever check specific context flags (.get_[key]s, .check_enhancement) to respect deactivated QFields.

The card_has_check context is calculated to check whether a card counts as no_[key] or any_[key], which in turn defines the card's base [key] values.

Effects such as Jokers can return no_[key] = true or any_[key] = true here, e.g. no_suit = true to make the card count as no_suit.

To prevent lag and looping calls, this function first defines

 SMODS.qfield_cache[card] = { 
    has = { 
        [key] = { 
            no = nil, 
            any = nil
        }, 
        ... 
    }, 
    get = { 
        [key] = {
            [map of values]
        }, 
        ...
    } 
}

which it and the following has_... functions refers to while cached, and which gets updated automatically by SMODS.update_context_flags().

With the base values set in SMODS.qfield_cache[card].get[key] for all QFields, and in context.[key]s for every active QField, the general _quantum_getter context is then calculated.

Effects such as Jokers can modify values by returning [key]s = [map of values] here, e.g. ranks = {King = false} which would remove the King from the card's ranks. Additionally you can return fixed = '[key]' or fixed = {[key] = true} to override existing [key] values instead of modifying them.

If a QField defines cache_ability = true, each object config for all [key] values of a card is cached to SMODS.qfield_cache[card].abilities, a list of abilities a card possesses (or rather a list of structs {t = [ability table], key = [obj key], qfield_key = [QField key]} ). (This is then used by SMODS.CardAbilityField / general calculation)

If any ability was cached, Card:quantum_ability_set_mt() is called to prepare card.ability (see Util functions).

Finally, the function returns the value map from SMODS.qfield_cache[card].get[key].

If args.as_objs = true, the value map uses objects instead of object keys as keys, (e.g. SMODS.Ranks).


Card:set_[key](value, args, ...) (only for Edition and Seal + Enhancement, which redirects to Card:set_ability())

This function unifies Card:set_seal() and Card:set_edition(), and works just like those too (+ it is compatible with old function signatures). It also additionally calls SMODS.clear_quantum_cache(card).


SMODS.has_no_[key](card, args)

This function just calls the local function _general_quantum_getter() to set SMODS.qfield_cache[card].has[key].no, which it then returns.


SMODS.has_any_[key](card, args)

This function just calls the local function _general_quantum_getter() to set SMODS.qfield_cache[card].has[key].any, which it then returns.


Card:is_[key](value, args)

This function just converts value into a map and calls its plural variant. (see below)


Card:is_[key]s(values_map, args)

This function gets the card's [key] values and compares them with the values_map; Unless args.all = true, if any of the card's [key] values is in the values_map, returns true, else it checks all values and returns true if all match.


SMODS.get_[key]_tally(cards, args)

This function tallies all [key]s across cards, e.g. it counts all enhancements in G.hand.cards. It then returns a tally = {[value 1] = [count], ...} map and a value_to_cards = {[value 1 ] = [cards that had it], ...} map.


Card:calculate_[key](context, args)

This function gets all the card's [key] values, converts them into objects using the required QField object field g_obj_table (e.g. SMODS.Seals -> just SMODS.Seal.obj_table), sets SMODS.qfield_cache[card].active_ability to the corresponding ability and calls :calculate(context) on them if defined. The returned effects are then merged into one table using SMODS.merge_effects().

This is called for all QFields in eval_card(), where it replaces the individual calls to Card:calculate_seal(), etc.



SMODS.CardAbilityField

A CardAbilityField defines a card value which is (typically) stored in card.ability, e.g. chip_mult for mult, chip_x_mult for x_mult etc., which is automatically retrieved and evaluated in eval_card(). This is done by iterating over all SMODS.CardAbilityFields and calling :insert_value() on every appropriate one (according to CardAbilityField.scoring_card_areas).

Example SMODS.CardAbilityFields;

SMODS.CardAbilityField{
    key = "chip_x_mult",
    stacking_type = SMODS.CARD_VALUE_TYPES.MULTIPLICATIVE,
    calc_key = "x_mult",
    variant_refs = {"x_mult", "Xmult"},
}

SMODS.CardAbilityField{
    key = "chip_h_mult",
    calc_key = "h_mult",
    scoring_card_areas = {hand = true},
}

SMODS.CardAbilityField{
    key = "bonus_h_x_blind_size",
    value_ref = "h_x_blind_size",
    calc_key = "x_blind_size",
    scoring_card_areas = {hand = true},
    stacking_type = SMODS.CARD_VALUE_TYPES.MULTIPLICATIVE
}

:insert_value(card, ret_table)

This function first gets all abilitys of a card by calling SMODS.get_card_abilities(card), and then calls self:getter(abilities, card). (see below)
Then it inserts the returned sum into ret_table.playing_card[self.calc_key] = [sum].


:getter(abilities, card)

This function iterates over abilities and gets the CardAbilityField value using table_get_subfield(ability or {}, v_ref), where v_ref refers to iterating over self.variant_refs, which is mostly equivalent to {self.value_ref} (only x_mult defines variant_refs = {"x_mult", "Xmult"}).
The function then stacks values based on self.stacking_type, adding if it equals SMODS.CARD_VALUE_TYPES.ADDITIVE, or multiplying if it equals SMODS.CARD_VALUE_TYPES.MULTIPLICATIVE.
It also adds perma values according to self.perma_value_ref, which is evaluated only once on the card's actual .ability.


:check_context_criteria(context)

This function checks whether a context has the right cardarea set (in accordance with CardAbilityField.scoring_card_areas, and, if any are defined, whether all flags in CardAbilityField.context_criteria (flag map) are set in the context.

This is used by h_dollars to only reward money if context.playing_card_end_of_round is true. Alternatively, one could create a separate CardAbilityField for end-of-round/every-hand hand-dollars, e.g. eor_h_dollars/p_h_dollars (play_hand_dollars).


Quantum Rank Hand Calculation

(More in-depth details can be found in the closed Quantum Ranks PR #838)

If SMODS.optional_features.quantum_fields.rank = true is set, Hand Calc will use all rank values of a card. This may cause lag depending on the number of cards selected or in hand, mostly due to the new get_straight() implementation.


Utils

SMODS.set_quantum_cache(card)

This function just calls the local function _general_quantum_getter(card) to set SMODS.qfield_cache[card].

The _general_quantum_getter() function is also called under the hood when calling injected funcs such as Card:get_ranks().


SMODS.clear_quantum_cache(card)

This function resets a card's ability / its metatable and removes card._base_ability, and then clears the cache for the card (SMODS.qfield_cache[card] = nil). It is used by Card:set_ability() and the like.


SMODS.get_card_abilities(card)

This function calls SMODS.set_quantum_cache(card) if SMODS.qfield_cache[card].abilities is not defined, and then returns SMODS.qfield_cache[card].abilities.


SMODS.get_ability_from_obj(obj, card)

This function creates and gets an ability table from a center obj. It is used to cache QField values' abilities in SMODS.qfield_cache[card].abilities.


Card:quantum_ability_set_mt()

This function moves card.ability into card._base_ability and sets card.ability to be an empty table, with a metatable which redirects acesses to SMODS.qfield_cache[card].active_ability (which is set before quantum calculating in e.g. Card:calculate_seal()).


table_get_subfield(_table, key_string)

This function first splits key_string into multiple keys at every ., sets ret = _table and then iteratively tries to set ret = ret[key] until it fails and returns nil or until it runs out of keys and returns ret. (e.g. passing a playing card as _table and ability.base.value as key_string would return card.ability.base.value)


Card:is_parity(parity)

This function gets all of the card's ranks to check if any of their .paritys equals parity (0 for even, 1 for odd).


Card:is_royal()

This function gets all of the card's ranks to check if any of their .is_royals is true.


SMODS.all_royal(cards)

This function calls Card:is_royal() on each card in cards and returns true if they all are royal.


SMODS.lowest_and_highest_rank(cards)

This function gets all of the card's ranks and sorts them by their sort_nominal (how they are sorted in hand), and returns the first and last ranks.

Additional Info:

  • I didn't modify api's or I've made a PR to the wiki repo.
  • I didn't modify api's or I've updated lsp definitions.
  • I didn't make new lovely files or all new lovely files have appropriate priority.

AllUniversal and others added 30 commits July 25, 2025 02:28
…more

+Title, this is working with Shortcut and Four Fingers.
-> Some edge cases have already come up (like straight_edge ranks), I've fixed the ones I've found but I'm certain there will be more.

!Ironically the only parts that kept breaking stuff by the end were ""optimizations"" I thought of... maybe I should stop doing that woops.

+Card:get_ranks() and Card:is_rank() functions
-> These come with a new context type: context.get_ranks
-> It only has the parameter "other_card" for the playing card that its called from.
-> Returning "ranks" equal to a table of "SMODS.Rank"s will override the card's rank.

!All of these features require SMODS.optional_features.quantum_ranks to be true

!Documentation is lacking and there's still much to do, more commits to follow. Eventually. :)
…e optimizations

*Title, straight_pos somehow resulted in card_reps being used multiple times which greatly hurt performance and didn't return the right cards.
-> The new system seems to behave correctly and also removed any immediate performance problems with a crude wild rank implementation (-> simply returning all SMODS.Ranks in context.get_ranks)

+A new exit condition for the straight_calculation based on the amount of card_reps checked or the total size of the best_straight so far.
-> These should be edge case free (in theory) (hopefully) (please?)

*context.get_ranks' "ranks" field now has the card's rank set as default.
…vanilla "get_id() == " and fixed some bugs

+get_X_same() now works with quantum ranks too!
-> This also means the previously injected "or_more" parameter is now part of the "overrides.lua" function definition and the according patches have been removed from "playing_cards.toml".
+Added Card:is_any_rank() and SMODS.get_rank_by_id() functions.
-> is_any_rank() allows passing a table of ranks, making Hack/Fibonacci/etc. easier to patch/implement.
-> get_rank_by_id() does what its name implies.
!I considered adding a lookup table alike SMODS.Ranks but by IDs, but for now I'll leave it.
*/+Most vanilla jokers now use Card:is_rank() instead of get_id()
-> Exceptions are Even Steven, Odd Todd and Cloud Nine, due to implementation and potential performance problems.
+Added some more comments to get_straight()
-Removed semi functional implementation of 1-squashing optimization for get_straight().
-> I'll see if I'll get back to it, but it seems to run fine without.
+LSP defs for SMODS.get_rank_by_id() and get_ranks context field.
*rank.straight_edge edge case handling had a mistake (probably) where the used_c_reps where being set incorrectly for the second straight evaluation.
*c_reps which have appeared in a ret_straight are skipped as starting points.
-> As long as this doesn't cause any edge cases this may be a crucial optimization, because instead of checking every card_rep, it now only checks every branch of possible straights once.
-> Please work :) (I beggeth, I pleadeth)
…ll oversight

+´get_ranks´ context now has a ´source_context´ field, allowing more granular conditional rank changing (like, depending on what joker may have called is_rank(), though I'm unsure if that would also require a ´source_obj´ param)
*Fixed missing ´pairs()´ in ´Card:is_any_rank()´
*Title, this required all my brainpower.
->The issue is now fixed and Full House can be correctly evaluated in the following scenario:
1. Jacks are also considered Kings and Queens.
2. The selected hand is K K Q Q J, or K Q J, or Q J J, or K Q Q J J, or K Q J J,
evaluating as Full House, Pair, Three of a kind, Four of a kind and Three of a Kind respectively.
! The last hand is important; Technically there's also a Two Pair which my implementation does NOT pick up, but (barring any edgecases I'm unaware of), this will never be relevant because there will always be a Three of a kind (or better) instead.
*Also fixed another missing `pairs()` in `utils.lua`
… during hand type evaluation + `Card:is_any_rank()` fix

+Title. this default `source_context` allows you to selectively have cards only affect / not affect hand type evaluation.
*/+`Card:is_any_rank()` now works with rank ids or keys too.
…dicated `Full House` / `Two Pair` functions.

*/+Title, this was necessary because `Full House` and `Two Pair` didn't work.

-> This means `get_X_same()` is now once again always called with `or_more=true`, and no longer accounts for quantum cards appearing multiple times, instead it returns all possible 'X (or >X) same's.
-> `Full House` and `Two Pair` now call `get_quantum_full_house(_3, _2)` and `get_quantum_two_pair(_2)` respectively, these functions then check whether an unoverlapping `Pair` and `Three of a Kind` or a set of pairs exists.

!This was way harder than straight evaluation lol. (I may have overcomplicated it here and there, this solution is super simple though. I hope it works.)
…ded `Card:is_face()` compatibility (in `overrides.lua`) and added `no_mod` argument / flag to `context.get_ranks`

*Title, those functions didn't need to be part of the `card_is_rank.toml` lovely patch.
+Title, `Card:is_face()` now works with quantum ranks and uses a new source_context, `source_context.is_face_getting_ranks`, to allow exclusively returning certain ranks during / outside `is_face()` evaluation.
+Title, `context.get_ranks` now takes a new return flag, `no_mod`, to ignore future returned `ranks`.
*Title, to avoid redundant context calculation.
-Title, this now allows overriding a card's ranks to be no rank at all.
*Title, it's now `Rank.straight_edges`, a map of ranks from which going into this rank will terminate a straight.
-> I added this because I took a look at Paperback's Apostle implementation / definition and realised that its behavious wasn't currently supported that well.
In theory it should now be possible to just not add the Apostle to the Ace's `straight_edges`, which will mean the straight calculation starting from the Apostle will pass through the Ace instead of terminating at it.

! I think the vanilla implementation (which still gets run if `SMODS.optional_features.quantum_ranks = false`) might need to be updated.
…`Rank.straight_edges`

*Title, this means vanilla straight calc now also allows Paperback-Apostle-like straight rank shenanigans. (In theory)
*Title, also added `#best_straight >= #hand` to the `recursive_get_straight()` function.
…_return`

+Title, this was an optimization I considered but had discarded due to potentially returning non-longest straights.
-> If you want 20 card hands and quantum ranks you might have to toggle this on.
!Check the PR for further info.
-Title, instead `is_rank()` etc. now take a `flags` param (used by eval
 for `eval_getting_ranks`)

-> I hope the `SMODS.context_stack` PR gets merged, else I'll lose functionality here.
@AllUniversal AllUniversal requested a review from Eremel as a code owner May 1, 2026 21:06
+/*Title, includes all injected QuantumCardField functions (e.g. `is_rank`, etc.)
-Title, whoopsie
*Title, this allows Jokers to have quantum Editions/Stickers too, and technically entails compatibility with quantum Enhancements/Seals etc. on Jokers, but that would require some more changes to function correctly.
@AllUniversal
Copy link
Copy Markdown
Contributor Author

Just wrote an overview over most (I think) stuff added in this PR, edited the top message! :)

@AllUniversal
Copy link
Copy Markdown
Contributor Author

AllUniversal commented Jun 3, 2026

I did a brief performance test without any metrics; A Joker calling card:get_enhancements() on all (52) cards in its update function made the game drop to half (Edit: half to two-thirds) its normal FPS (I am running it on a decent PC though). In theory this should be the worst case possible (in regards to Quantum stuff alone), as the card qfield cache prevents more than one (or rather two) context calls per card, per frame. Five copies of the Joker did however drop the game to roughly a quarter of the original FPS.
It seems unlikely to me that any real use case would need to get every card's qfield values, every frame. (Please, do correct me if I'm wrong)
Still, I could/should probably try the QuantumEnhancements approach of having one cache table instead of per-card caches, which would remove the need for events to clear the cache.

Edit: I've now done the above ^. FPS with one Joker doesn't seem to have changed (~70, down from ~115), but it only reaches ~60, down from ~115 with five Jokers, probably because there's no more Events.

! I could still use someone testing this on a lower-end device, lmk (here or in Discord) if you want to set up a test and need help with the API.

*Title
-Removed `SMODS.clear_quantum_cache()` as the whole cache now gets cleared as a hook into `Game:update()`.
…ard:set_seal()` + WIP quantum ability refactor/rework for compat

+/*Title, lots untested here, and maybe not quite optimal either.
*Title, legacy Edition compat.
@AllUniversal
Copy link
Copy Markdown
Contributor Author

AllUniversal commented Jun 5, 2026

I've now made it so that card.ability redirects to the appropriate ability in SMODS.qfield_cache[card].abilities, meaning if e.g. a Quantum Edition scales and uses one of its ability.extra values, it will function as normal (except that a quantum Edition won't save its values ofc, so it gets reset every frame. I think it makes sense that way, lmk if you disagree). There's still some edge cases I can think of, but I doubt they'll come up much, or at all (e.g., a Quantum Edition trying to affect values of the card's real enhancement during calculation won't be able to access it via card.ability, because that would redirect to its own ability (it would have to know to use card._base_ability instead)).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants