Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion backend/app/services/community_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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."""
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/test_community_event_options.py
Original file line number Diff line number Diff line change
@@ -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
Loading