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
62 changes: 44 additions & 18 deletions backend/app/services/community_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -608,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:
Expand Down Expand Up @@ -704,20 +715,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(
{
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/run_entity_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/test_community_event_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
79 changes: 79 additions & 0 deletions backend/tests/test_community_records.py
Original file line number Diff line number Diff line change
@@ -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")
30 changes: 30 additions & 0 deletions backend/tests/test_community_rest_sites.py
Original file line number Diff line number Diff line change
@@ -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)
Loading