From 0ff9e4855da8dfb7df7891f8f8775832a50a5435 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Sat, 1 Aug 2026 15:47:26 -0700 Subject: [PATCH 1/3] Keep custom-game runs out of the community records --- backend/app/services/community_stats.py | 29 +++++---- backend/app/services/run_entity_stats.py | 5 +- backend/tests/test_community_records.py | 79 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_community_records.py diff --git a/backend/app/services/community_stats.py b/backend/app/services/community_stats.py index 27f5c8cc..bd782088 100644 --- a/backend/app/services/community_stats.py +++ b/backend/app/services/community_stats.py @@ -263,17 +263,24 @@ def _accumulate_one( if kev: _bump(acc["deaths_event"], kev) - # Records. - run_time = blob.get("run_time") - if isinstance(run_time, (int, float)) and run_time > 0: - if is_win and (acc["fastest_win"] is None or run_time < acc["fastest_win"][0]): - acc["fastest_win"] = (int(run_time), run_hash) - if acc["longest_run"] is None or run_time > acc["longest_run"][0]: - acc["longest_run"] = (int(run_time), run_hash) - for player in blob.get("players") or []: - size = len(player.get("deck") or []) - if size and (acc["biggest_deck"] is None or size > acc["biggest_deck"][0]): - acc["biggest_deck"] = (size, run_hash) + # Records. Standard, modifier-free runs only: custom games (Sealed Deck, + # Hoarder, ...) produce absurd decks and times that would hold the + # record forever. + if (blob.get("game_mode") or "standard").lower() == "standard" and not blob.get( + "modifiers" + ): + run_time = blob.get("run_time") + if isinstance(run_time, (int, float)) and run_time > 0: + if is_win and ( + acc["fastest_win"] is None or run_time < acc["fastest_win"][0] + ): + acc["fastest_win"] = (int(run_time), run_hash) + if acc["longest_run"] is None or run_time > acc["longest_run"][0]: + acc["longest_run"] = (int(run_time), run_hash) + for player in blob.get("players") or []: + size = len(player.get("deck") or []) + if size and (acc["biggest_deck"] is None or size > acc["biggest_deck"][0]): + acc["biggest_deck"] = (size, run_hash) # Map danger: per (act, node type), tally visits, HP% lost, and deaths. The death is # attributed to the run's final visited node, but only when the blob says the player diff --git a/backend/app/services/run_entity_stats.py b/backend/app/services/run_entity_stats.py index fb75a81a..4a825e96 100644 --- a/backend/app/services/run_entity_stats.py +++ b/backend/app/services/run_entity_stats.py @@ -290,7 +290,10 @@ def get(self, run_hash: str) -> dict | None: # the skill tiers), so ?character= combines with any bracket on the metrics # endpoint — e.g. bracket=solo:a10&character=IRONCLAD (additive; the bump # forces the rebuild). -SNAPSHOT_VERSION = 20 +# Version 21: community records (fastest win / longest run / biggest deck) only +# count standard, modifier-free runs, so custom games can't hold them (the bump +# forces the rebuild that drops the already-baked custom-run records). +SNAPSHOT_VERSION = 21 # Serialized-byte budget per persisted chunk doc. With version-composable # brackets a popular entity carries hundreds of per-bracket blocks and # entity sizes vary wildly (a card dwarfs an affliction), so chunks are diff --git a/backend/tests/test_community_records.py b/backend/tests/test_community_records.py new file mode 100644 index 00000000..8dc658d4 --- /dev/null +++ b/backend/tests/test_community_records.py @@ -0,0 +1,79 @@ +"""Community records (fastest win / longest run / biggest deck) must come +from standard, modifier-free runs: a custom game stacked with Sealed Deck / +Hoarder produces a multi-thousand-card deck that would hold the record +forever and read as modded data on /community-stats.""" + +from app.services import community_stats + + +def _fold(acc, blob, *, is_win=True, run_hash="r1"): + community_stats._accumulate_one( + acc, + blob, + run_hash=run_hash, + is_win=is_win, + character="ironclad", + ascension=10, + ) + + +def test_custom_mode_runs_set_no_records(): + acc = community_stats._new_acc_one() + _fold( + acc, + { + "game_mode": "custom", + "modifiers": [{"id": "MODIFIER.HOARDER"}], + "run_time": 100, + "players": [{"deck": [{}] * 2364}], + }, + ) + assert acc["fastest_win"] is None + assert acc["longest_run"] is None + assert acc["biggest_deck"] is None + + +def test_modifiers_disqualify_even_standard_mode(): + acc = community_stats._new_acc_one() + _fold( + acc, + { + "game_mode": "standard", + "modifiers": [{"id": "MODIFIER.SEALED_DECK"}], + "run_time": 100, + "players": [{"deck": [{}] * 999}], + }, + ) + assert acc["biggest_deck"] is None + + +def test_standard_runs_still_hold_records(): + acc = community_stats._new_acc_one() + _fold( + acc, + {"game_mode": "standard", "run_time": 1200, "players": [{"deck": [{}] * 40}]}, + ) + # Missing game_mode counts as standard (old blobs predate the field). + _fold( + acc, + {"run_time": 900, "players": [{"deck": [{}] * 55}]}, + run_hash="r2", + ) + assert acc["fastest_win"] == (900, "r2") + assert acc["longest_run"] == (1200, "r1") + assert acc["biggest_deck"] == (55, "r2") + + +def test_custom_run_cannot_beat_a_standard_record(): + acc = community_stats._new_acc_one() + _fold( + acc, + {"game_mode": "standard", "run_time": 1200, "players": [{"deck": [{}] * 40}]}, + ) + _fold( + acc, + {"game_mode": "custom", "run_time": 60, "players": [{"deck": [{}] * 500}]}, + run_hash="r2", + ) + assert acc["fastest_win"] == (1200, "r1") + assert acc["biggest_deck"] == (40, "r1") From 762a0f3362f2d99c139c4920dc2d30aa24509661 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Sat, 1 Aug 2026 15:56:50 -0700 Subject: [PATCH 2/3] Keep only the game's own campfire options in rest-site stats --- backend/app/services/community_stats.py | 23 ++++++++++++++--- backend/tests/test_community_rest_sites.py | 30 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_community_rest_sites.py diff --git a/backend/app/services/community_stats.py b/backend/app/services/community_stats.py index bd782088..125dcd7c 100644 --- a/backend/app/services/community_stats.py +++ b/backend/app/services/community_stats.py @@ -711,20 +711,35 @@ def _rec(rec, value_key): } +# The game's campfire options, one per *RestSiteOption class in the decompiled +# MegaCrit.Sts2.Core.Entities.RestSite namespace (identical on main and beta as +# of v0.110.0). Mods register their own options (AUTOTHESPIRE-MERGE, TRADE, +# GODREMOVE, ...) which land in run files like any other choice, so anything +# outside this set must be dropped at finalize. +_OFFICIAL_REST_OPTIONS = frozenset( + ("SMITH", "HEAL", "MEND", "DIG", "CLONE", "COOK", "LIFT", "HATCH", "KINDLE") +) + + def _rest_sites(acc: dict[str, Any]) -> list[dict]: """Campfire choices with win correlation and HP-band shares. pct = share of all campfire decisions; pct_low_hp / pct_high_hp = share of decisions made while below / at-or-above 50% max HP (walking in); win_rate = how often runs that made this choice won. Keeps the original - id/label/count/pct keys so the site page is unaffected. + id/label/count/pct keys so the site page is unaffected. Official options + only, and the percentages are over the official total so dropped modded + picks don't dilute them. """ - total = sum(rec[0] for rec in acc["rest"].values()) - low_total = sum(rec[2] for rec in acc["rest"].values()) + rest = { + c: rec for c, rec in acc["rest"].items() if c.upper() in _OFFICIAL_REST_OPTIONS + } + total = sum(rec[0] for rec in rest.values()) + low_total = sum(rec[2] for rec in rest.values()) high_total = total - low_total out = [] for c, (count, wins, low) in sorted( - acc["rest"].items(), key=lambda kv: kv[1][0], reverse=True + rest.items(), key=lambda kv: kv[1][0], reverse=True ): out.append( { diff --git a/backend/tests/test_community_rest_sites.py b/backend/tests/test_community_rest_sites.py new file mode 100644 index 00000000..b3ddbc8d --- /dev/null +++ b/backend/tests/test_community_rest_sites.py @@ -0,0 +1,30 @@ +"""Mods register their own campfire options (AUTOTHESPIRE-MERGE, TRADE, +GODREMOVE, HealSherry, ...) which land in run files like real choices, so the +community-stats rest-site table must keep only the game's own *RestSiteOption +ids and compute shares over the official total.""" + +from app.services import community_stats + + +def test_rest_sites_drop_modded_options(): + acc = community_stats._new_acc_one() + acc["rest"] = { + "SMITH": [6, 3, 1], + "HEAL": [2, 1, 2], + "AUTOTHESPIRE-MERGE": [92, 92, 0], + "TRADE": [4, 0, 0], + "HealSherry": [3, 0, 0], + } + rows = community_stats._rest_sites(acc) + assert [r["id"] for r in rows] == ["SMITH", "HEAL"] + # Shares are over the official picks (8), not diluted by the dropped 99. + assert rows[0]["pct"] == 75.0 + assert rows[1]["pct"] == 25.0 + assert rows[0]["win_rate"] == 50.0 + + +def test_rest_sites_keep_every_official_option(): + acc = community_stats._new_acc_one() + acc["rest"] = {c: [1, 0, 0] for c in community_stats._OFFICIAL_REST_OPTIONS} + rows = community_stats._rest_sites(acc) + assert {r["id"] for r in rows} == set(community_stats._OFFICIAL_REST_OPTIONS) From 63a982821e70771a558ffda1ab4676f0636c107e Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Sat, 1 Aug 2026 16:01:15 -0700 Subject: [PATCH 3/3] Fail closed on events with no catalog option tree --- backend/app/services/community_stats.py | 10 +++++++--- backend/tests/test_community_event_options.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/app/services/community_stats.py b/backend/app/services/community_stats.py index 125dcd7c..3eb66625 100644 --- a/backend/app/services/community_stats.py +++ b/backend/app/services/community_stats.py @@ -615,9 +615,13 @@ def _finalize_one(acc: dict[str, Any]) -> dict[str, Any]: # Official options only: mods inject extra picks into official events # (every installed card-pool mod shows up as a Colorful Philosophers # option), so anything outside the event's own option tree is dropped - # and the percentages are computed over the real options. - allowed = ev_opt_ids.get(eid) or set() - if allowed: + # and the percentages are computed over the real options. Events whose + # catalog tree has no options at all (the Ancient dialogues, Neow, ...) + # never record choices in vanilla runs, so an empty allowlist drops + # everything rather than letting a modded response through unfiltered. + # Only skipped when the catalog itself failed to load. + if ev_opt_ids: + allowed = ev_opt_ids.get(eid) or set() opts = {oid: n for oid, n in opts.items() if oid.upper() in allowed} total = sum(opts.values()) if total <= 0: diff --git a/backend/tests/test_community_event_options.py b/backend/tests/test_community_event_options.py index 7c3632ac..15e9c362 100644 --- a/backend/tests/test_community_event_options.py +++ b/backend/tests/test_community_event_options.py @@ -54,3 +54,13 @@ def test_finalize_keeps_multi_stage_options(): ev = next(e for e in out["events"] if e["id"] == "SLIPPERY_BRIDGE") assert {o["id"] for o in ev["options"]} == {"OVERCOME", "HOLD_ON_3"} assert ev["total"] == 9 + + +def test_empty_allowlist_fails_closed(): + # The Ancient dialogues (NEOW, DARV, ...) have no options in the catalog + # tree and vanilla runs never record choices for them, so anything that + # shows up there is a modded response and the whole event must drop. + acc = community_stats._new_acc_one() + acc["events"]["NEOW"] = {"MOD_BLESSING_OF_SPEED": 7} + out = community_stats._finalize_one(acc) + assert not any(e["id"] == "NEOW" for e in out["events"])