From ad066077539db0ad73f76be67e4fbf180c1ad3bd Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Thu, 30 Jul 2026 19:31:58 -0700 Subject: [PATCH] Keep modded options out of the community-stats event breakdowns --- backend/app/services/community_stats.py | 33 ++++++++++- backend/tests/test_community_event_options.py | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_community_event_options.py diff --git a/backend/app/services/community_stats.py b/backend/app/services/community_stats.py index 9c5b16f1..27f5c8cc 100644 --- a/backend/app/services/community_stats.py +++ b/backend/app/services/community_stats.py @@ -458,8 +458,10 @@ def _index_with_beta(tkey: str, loader, key="id", name="name") -> dict[str, str] out["characters"] = { k.lower(): v for k, v in _index(data_service.load_characters).items() } - # Per-event option id -> title ("TAKE" -> "Take the Egg"). + # Per-event option id -> title ("TAKE" -> "Take the Egg"), plus the full + # option-id allowlist per event (see _harvest_option_ids). event_opts: dict[str, dict[str, str]] = {} + event_opt_ids: dict[str, set[str]] = {} try: for e in data_service.load_events(): eid = e.get("id") @@ -470,12 +472,33 @@ def _index_with_beta(tkey: str, loader, key="id", name="name") -> dict[str, str] if opt.get("id"): labels[opt["id"]] = opt.get("title") or _prettify(opt["id"]) event_opts[eid] = labels + event_opt_ids[eid] = _harvest_option_ids( + {"options": e.get("options"), "pages": e.get("pages")} + ) except Exception: logger.warning("community-stats event options load failed", exc_info=True) out["_event_options"] = event_opts # type: ignore[assignment] + out["_event_option_ids"] = event_opt_ids # type: ignore[assignment] return out +def _harvest_option_ids(node: Any) -> set[str]: + """Every id in an event's options/pages tree, uppercase. The top-level + options list alone misses the multi-stage picks (HOLD_ON_1..6, LINGER, + ...), so the official-option allowlist walks the whole tree.""" + ids: set[str] = set() + if isinstance(node, dict): + oid = node.get("id") + if isinstance(oid, str): + ids.add(oid.upper()) + for value in node.values(): + ids |= _harvest_option_ids(value) + elif isinstance(node, list): + for item in node: + ids |= _harvest_option_ids(item) + return ids + + def _quest_card_ids() -> frozenset[str]: """Quest items (Byrdonis Egg, Lantern Key, ...). They only enter and leave a deck through events, so counting them as removals is noise.""" @@ -549,6 +572,7 @@ def _finalize_one(acc: dict[str, Any]) -> dict[str, Any]: names = _name_maps() ev_names = names["events"] ev_opts = names["_event_options"] # type: ignore[index] + ev_opt_ids = names["_event_option_ids"] # type: ignore[index] total_runs = acc["total_runs"] total_wins = acc["total_wins"] @@ -581,6 +605,13 @@ def _finalize_one(acc: dict[str, Any]) -> dict[str, Any]: # Skip modded events (not in the official events catalog). if ev_names and eid not in ev_names: continue + # 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: + opts = {oid: n for oid, n in opts.items() if oid.upper() in allowed} total = sum(opts.values()) if total <= 0: continue diff --git a/backend/tests/test_community_event_options.py b/backend/tests/test_community_event_options.py new file mode 100644 index 00000000..7c3632ac --- /dev/null +++ b/backend/tests/test_community_event_options.py @@ -0,0 +1,56 @@ +"""Mods inject extra options into official events (every installed card-pool +mod shows up as a Colorful Philosophers pick), so the community-stats event +section only keeps options from the event's own option tree. The allowlist +must walk the whole tree: the top-level options list misses the multi-stage +picks (HOLD_ON_1..6, LINGER, ...), and dropping those would erase real data.""" + +from app.services import community_stats + + +def test_harvest_walks_nested_pages(): + event = { + "options": [{"id": "OVERCOME"}, {"id": "HOLD_ON_0"}], + "pages": [ + { + "id": "INITIAL", + "options": [ + {"id": "hold_on_1", "next": {"options": [{"id": "HOLD_ON_2"}]}} + ], + } + ], + } + ids = community_stats._harvest_option_ids(event) + assert {"OVERCOME", "HOLD_ON_0", "INITIAL", "HOLD_ON_1", "HOLD_ON_2"} <= ids + + +def test_catalog_allowlist_covers_multi_stage_options(): + ids = community_stats._name_maps()["_event_option_ids"] + assert {"HOLD_ON_1", "HOLD_ON_6"} <= ids["SLIPPERY_BRIDGE"] + assert {"LINGER", "EXIT_BATHS"} <= ids["ABYSSAL_BATHS"] + assert "CARD_POOL∴WATCHER-WATCHER_CARD_POOL" not in ids["COLORFUL_PHILOSOPHERS"] + assert "MEILIN" not in ids["COLORFUL_PHILOSOPHERS"] + + +def test_finalize_drops_modded_event_options(): + acc = community_stats._new_acc_one() + acc["events"]["COLORFUL_PHILOSOPHERS"] = { + "IRONCLAD": 8, + "SILENT": 2, + "CARD_POOL∴WATCHER-WATCHER_CARD_POOL": 3, + "MEILIN": 1, + } + out = community_stats._finalize_one(acc) + ev = next(e for e in out["events"] if e["id"] == "COLORFUL_PHILOSOPHERS") + assert {o["id"] for o in ev["options"]} == {"IRONCLAD", "SILENT"} + # Percentages are over the real options, not diluted by the dropped ones. + assert ev["total"] == 10 + assert next(o for o in ev["options"] if o["id"] == "IRONCLAD")["pct"] == 80.0 + + +def test_finalize_keeps_multi_stage_options(): + acc = community_stats._new_acc_one() + acc["events"]["SLIPPERY_BRIDGE"] = {"OVERCOME": 5, "HOLD_ON_3": 4} + out = community_stats._finalize_one(acc) + 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