mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:07:05 +01:00
_web_dataset.py 436 -> 178 + _web_models, _web_games, _web_pace The six dataclasses (WebGame, WebStateInfo, WebDefaults, DefaultSummary, PaceVsHLTB, WebDataset) are used by BOTH the parent and the helpers, so they go to their own leaf model module that both import. Leaving them in the parent is what created a circular import and broke ./run.sh on the previous attempt. All public names plus the seven private helpers tests reach for are re-exported from _web_dataset via __all__, so import paths are unchanged. Test patch targets split per class: TestBuildWebDataset, TestDatasetToPayload and TestDatasetEndpoint still patch load_snapshot on _web_dataset (that function did not move) while their cache patches move to _web_games. `bash scripts/check_file_length.sh --all` now exits 0 — no file in the repo is over 250 lines. 1344 tests, 100.00% coverage. Verified live: ./run.sh status works and build_web_dataset projects all 958 games with pace and summary sections intact; service at 0 restarts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
105 lines
3.6 KiB
Python
105 lines
3.6 KiB
Python
"""Pace-vs-HLTB calibration for the web dataset.
|
|
|
|
Split out of :mod:`steam_backlog_enforcer._web_dataset` to keep both files
|
|
under the 250-line cap. Leaf helpers: nothing here calls back into it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from steam_backlog_enforcer._web_models import PaceVsHLTB
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer.steam_api import GameInfo
|
|
|
|
|
|
def _collect_calibration_pairs(
|
|
raw_games: list[GameInfo],
|
|
raw_cache: dict[int, dict[str, Any]],
|
|
) -> tuple[list[tuple[float, float]], list[tuple[float, float, float]]]:
|
|
"""Separate complete games into rush-only and rush+leisure sample sets."""
|
|
rush_pairs: list[tuple[float, float]] = []
|
|
both_pairs: list[tuple[float, float, float]] = []
|
|
for game in raw_games:
|
|
if not game.is_complete or game.playtime_minutes <= 0:
|
|
continue
|
|
entry = raw_cache.get(game.app_id, {})
|
|
rush = float(entry.get("rush_hours", -1))
|
|
leisure = float(entry.get("leisure_100h", -1))
|
|
actual = game.playtime_minutes / 60.0
|
|
if rush > 0:
|
|
rush_pairs.append((actual, rush))
|
|
if rush > 0 and leisure > 0:
|
|
both_pairs.append((actual, rush, leisure))
|
|
return rush_pairs, both_pairs
|
|
|
|
|
|
def _interpolate_from_both(
|
|
both_pairs: list[tuple[float, float, float]],
|
|
) -> tuple[float, float]:
|
|
"""Return (ratio_vs_leisure, interpolation_t) from (actual, rush, leisure) triples.
|
|
|
|
Returns -1.0 for interpolation_t when leisure <= rush (degenerate data).
|
|
"""
|
|
sum_actual = sum(p[0] for p in both_pairs)
|
|
sum_rush = sum(p[1] for p in both_pairs)
|
|
sum_leisure = sum(p[2] for p in both_pairs)
|
|
ratio_vs_leisure = round(sum_actual / sum_leisure, 3)
|
|
if sum_leisure > sum_rush:
|
|
t = round((sum_actual - sum_rush) / (sum_leisure - sum_rush), 3)
|
|
else:
|
|
t = -1.0
|
|
return ratio_vs_leisure, t
|
|
|
|
|
|
def _classify_player_style(interpolation_t: float, ratio_vs_rush: float) -> str:
|
|
"""Map calibration metrics to a player-style label."""
|
|
if interpolation_t != -1.0:
|
|
if interpolation_t < 0:
|
|
return "faster_than_rush"
|
|
if interpolation_t <= 1.0:
|
|
return "rush_to_leisure"
|
|
return "slower_than_leisure"
|
|
return "faster_than_rush" if ratio_vs_rush < 1.0 else "unknown"
|
|
|
|
|
|
def compute_pace_vs_hltb(
|
|
raw_games: list[GameInfo],
|
|
raw_cache: dict[int, dict[str, Any]],
|
|
) -> PaceVsHLTB | None:
|
|
"""Compute player pace relative to HLTB rush/leisure averages.
|
|
|
|
Uses completed games (100 % achievements, positive playtime) as calibration
|
|
samples. Steam playtime includes idle time, so ratios > 1 are expected for
|
|
most players.
|
|
|
|
Args:
|
|
raw_games: All games from the snapshot (completed + incomplete).
|
|
raw_cache: The full HLTB cache (from ``_read_raw_cache()``).
|
|
|
|
Returns:
|
|
A ``PaceVsHLTB`` when at least one completed game has rush data,
|
|
``None`` when there is no calibration data at all.
|
|
"""
|
|
rush_pairs, both_pairs = _collect_calibration_pairs(raw_games, raw_cache)
|
|
if not rush_pairs:
|
|
return None
|
|
|
|
ratio_vs_rush = round(
|
|
sum(p[0] for p in rush_pairs) / sum(p[1] for p in rush_pairs), 3
|
|
)
|
|
if both_pairs:
|
|
ratio_vs_leisure, interpolation_t = _interpolate_from_both(both_pairs)
|
|
else:
|
|
ratio_vs_leisure = -1.0
|
|
interpolation_t = -1.0
|
|
|
|
return PaceVsHLTB(
|
|
calibration_count=len(rush_pairs),
|
|
ratio_vs_rush=ratio_vs_rush,
|
|
ratio_vs_leisure=ratio_vs_leisure,
|
|
interpolation_t=interpolation_t,
|
|
player_style=_classify_player_style(interpolation_t, ratio_vs_rush),
|
|
)
|