Skip to content

Commit 1df6990

Browse files
v2.9.6 — 23 audit-driven bug fixes across data/security/UI
Integrity: - Merkle hash extended to chain relics/encounter/turns/cards_offered/ potions per floor + final deck/relics block + build_id/total_players in genesis. Relic swaps and encounter substitutions now caught. Aggregate: - Deep-copy fix: prior shallow copy corrupted in-memory aggregate via shared nested-dict references after merge. - First-import inner counters now scaled proportionally with the clamped run_count (was: only run_count clamped, subcounts uncapped). - Booleans excluded from sum-aggregation (isinstance(True, int) trap). Knowledge: - find_synergies excludes Status/Curse/Event/Token/Quest from synergy pool (was: Status cards offered as synergies for player cards). - Auto-discovered enemies get act=['1','2','3'] (was: empty list, invisible to /enemies?act=X filter). - Boss-type classification token-matches enemy ID suffix (was: substring match falsely typed SUB_BOSS_SKILLS as boss). Saves parser: - Empty card/relic/potion IDs filtered at parse (was: '' polluted analytics). - character/character_id field-name harmonization between current_run and history files. Routes: - /runs/import per-field caps (500 floors / 200 deck / 100 relics) prevent DoS via tiny-floor flood under 1MB byte limit. - /enemies/{id} encounter lookup uses suffix-equality (was: substring match collided RAT vs BIG_RAT_PACK). - SSE counter increment moved inside generator's try/finally so pre-stream client disconnect can't leak slot. App: - _get_progress cache uses cache_time==0 sentinel (was: None response from no-save-file users re-ran get_progress every request). CLI: - python -m sts2 --browser now correctly defaults to 'serve' command. Watcher: - threading.Lock added around debounce check-and-set to close TOCTOU race. Community: - __init__ duplicate __all__ consolidated (was: dropped STS2_INDICATORS). - extract_tips uses \b word boundaries; names <3 chars skipped (was: substring match flooded tips with false positives). - reddit._fetch_reddit_json explicit raise on retries exhausted (was: implicit None return crashed callers). - steam guide tier weighting uses ceil(n/2) extras for true 1.5x (was: even-index drift off target). Logparser: - Linux log dir uses Proton-prefix when present, matching save dir resolution (was: XDG path disagreed with save-dir). Templates / static: - analytics.html death-floors + floor-survival use (max or 1) divisor. - overlay.html stops full-reloading every SSE tick — new overlay.js does in-place DOM diff with 10s reload cooldown (OBS browser-source stable now). - deck.js localStorage setItem wrapped try/catch (QuotaExceededError / private browsing). Version: - pyproject + spec both 2.9.6 (verified matched).
1 parent 016522a commit 1df6990

20 files changed

Lines changed: 269 additions & 60 deletions

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Changelog
22

3+
## v2.9.6
4+
5+
### Fixed
6+
7+
- **Integrity Merkle hash incomplete**`sts2/integrity.py` chained only floor/type/card_picked/damage/hp/gold per floor. Relic swaps, encounter substitutions, and turn-count edits went undetected. Hash now binds seed+character+ascension+build_id+total_players at genesis and chains encounter+monsters+turns+hp_healed+max_hp+cards_offered+potions_used+potions_gained per floor, plus a final block over relics+deck.
8+
- **Knowledge synergy filter included Status/Curse/Token pools**`find_synergies` returned Status cards (e.g. Wound, Slimed) as synergies because the filter only excluded Status by `other.character`. Now also excludes Curse, Event, Token, Quest pools — only same-character + Colorless cards remain as synergy candidates.
9+
- **Aggregate merge shallow-copy corruption**`merge_aggregate` did `dict(existing)` (shallow), then `merged_field[key][subkey] += val` mutated the caller's in-memory aggregate. Subsequent reads doubled stats. Now deep-copies nested dicts.
10+
- **Aggregate first-import inner counters uncapped** — first-import clamped `run_count` to `_MIN_IMPORT_CAP` but left per-card/per-relic counters un-scaled. A malicious file with 10k inflated subcounts passed through. Now scales inner counters proportionally.
11+
- **Aggregate merge treats booleans as ints**`isinstance(True, int)` is True in Python; bools could be summed. Now explicit `isinstance(_, bool)` exclusion.
12+
- **Save parser empty card IDs** — malformed save entries with no `id` produced empty-string keys in deck/relics/potions, polluting `card_rankings` etc. Now filtered out at parse time in both `get_current_run` and `get_run_history`.
13+
- **Save parser character field-name mismatch**`get_run_history` only looked at `character`, missing the `character_id` field used by current_run saves. Now tries `character_id` first then falls back to `character`.
14+
- **/runs/import unbounded floor count** — 1 MB byte cap still permitted 100k tiny floor entries (template/analyzer DoS). Now also rejects runs with >500 floors / >200 deck / >100 relics.
15+
- **/enemies/{id} substring match collision**`enemy_id.split(".")[-1].lower() in enc_id.lower()` matched "RAT" against "BIG_RAT_PACK" and similar. Now exact suffix-equality.
16+
- **SSE connection counter leak on early disconnect**`_sse_active += 1` happened in the request handler before the generator ran; a client disconnect between header send and body start leaked a slot. Now the increment is inside the generator's try/finally so the decrement is guaranteed.
17+
- **_get_progress cache treated None as uncached** — users with no save file re-ran `get_progress()` on every request. Now `cache_time == 0` is the sentinel for "never populated".
18+
- **analytics.html division-by-zero on zero-only data**`death_floors` / `floor_survival` divisors could be 0. Now `(max or 1)` guard prevents 500.
19+
- **overlay.html full-page reload on every SSE tick** — overlay flickered every 3 seconds with full reload, breaking OBS browser-source captures. New `sts2/static/overlay.js` does in-place DOM diff with 10s reload cooldown.
20+
- **__main__.py treated `--browser` as a command**`python -m sts2 --browser` errored with "Unknown command". Now picks first non-flag arg as the command.
21+
- **watcher.py debounce TOCTOU**`_last_trigger` was mutated from watchdog's background thread without a lock; burst events could double-fire `call_soon_threadsafe`. Added `threading.Lock`.
22+
- **community/__init__.py duplicate __all__** — second assignment silently dropped `STS2_INDICATORS` and `SourceResult` from public re-exports. Now a single canonical list.
23+
- **community/_types extract_tips substring match pollution** — short entity names matched substrings (e.g. "rat" in "strategy"). Now anchored on `\b` word boundaries; names <3 chars skipped.
24+
- **community/reddit retry loop implicit None return** — control-flow could fall off the end of `_fetch_reddit_json` without raising or returning, crashing callers with `AttributeError`. Now raises explicitly.
25+
- **community/steam tier weight off-spec** — comment said "1.5x" but every-other-index extras averaged 0.5x only on even-length tier lists. Now `ceil(n/2)` extras for true 1.5x weighting.
26+
- **logparser.py Linux log dir mismatch**`XDG_DATA_HOME`-rooted log dir disagreed with the Proton-prefix-rooted save dir from `config.py`; Steam Deck users had save watcher and log poller looking at different game installs. Now matches the save-dir Proton resolution.
27+
- **knowledge.py auto-discovered enemies invisible to act filter** — discovered enemies had `act=[]`, hidden by every `/enemies?act=X` filter. Now `act=["1","2","3"]` so they appear under any act filter.
28+
- **knowledge.py boss-type misclassification**`"BOSS" in enemy_id.upper()` substring-matched IDs like `SUB_BOSS_SKILLS`. Now token-matches the suffix segment.
29+
- **deck.js localStorage uncaught QuotaExceededError** — private-browsing / full storage broke the deck builder silently. `setItem` now wrapped in try/catch.
30+
331
## v2.9.5
432

533
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "spirescope"
3-
version = "2.9.5"
3+
version = "2.9.6"
44
description = "Spirescope — a local web dashboard for Slay the Spire 2. Card/relic/enemy lookup, deck analysis, live run tracking, and strategy guides."
55
readme = "README.md"
66
license = {text = "MIT"}

spirescope.spec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55

66

7-
VERSION = "2.9.5"
7+
VERSION = "2.9.6"
88

99
# Windows EXE version metadata (CompanyName / ProductName / FileVersion).
1010
# The win32 import branch only loads on Windows because the underlying

sts2/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ def main():
9797
print(f"Spirescope {_get_version()}")
9898
return
9999

100-
command = args[0] if args else "serve"
100+
# Pick the first positional (non-flag) arg as the command so
101+
# `python -m sts2 --browser` correctly defaults to "serve" instead of
102+
# treating "--browser" as an unknown command.
103+
command = next((a for a in args if not a.startswith("-")), "serve")
101104

102105
if command == "update":
103106
from sts2.fetcher import run_fetcher

sts2/aggregate.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Aggregate stats: compute and merge player-sourced data."""
2+
import copy
23
import json
34
import logging
45
from pathlib import Path
@@ -78,15 +79,42 @@ def compute_aggregate_stats(runs: list[RunHistory]) -> dict:
7879
}
7980

8081

82+
def _scale_subcounts(d: dict, scale: float) -> dict:
83+
"""Scale every numeric sub-counter in a dict-of-dicts by `scale`."""
84+
out = {}
85+
for key, vals in d.items():
86+
if not isinstance(vals, dict):
87+
out[key] = vals
88+
continue
89+
scaled = {}
90+
for subkey, subval in vals.items():
91+
# Exclude bools — they're technically int but should not aggregate.
92+
if isinstance(subval, bool):
93+
scaled[subkey] = subval
94+
elif isinstance(subval, (int, float)):
95+
scaled[subkey] = int(subval * scale) if isinstance(subval, int) else subval * scale
96+
else:
97+
scaled[subkey] = subval
98+
out[key] = scaled
99+
return out
100+
101+
81102
def merge_aggregate(existing: dict, imported: dict) -> dict:
82103
"""Weighted merge with anti-manipulation cap."""
83104
if not existing or existing.get("run_count", 0) == 0:
84105
# Apply min-cap even on first import — prevents a malicious first file
85106
# from seeding massive bogus stats that then anchor the future cap.
107+
# Scale inner counters too so a 10k-run import doesn't sneak inflated
108+
# sub-counts through under a clamped run_count.
86109
imported_count = imported.get("run_count", 0)
87-
if imported_count > _MIN_IMPORT_CAP:
88-
return {**imported, "run_count": _MIN_IMPORT_CAP}
89-
return imported
110+
if imported_count > _MIN_IMPORT_CAP and imported_count > 0:
111+
scale = _MIN_IMPORT_CAP / imported_count
112+
scaled = {"run_count": _MIN_IMPORT_CAP}
113+
for field in ("card_pick_rates", "card_win_rates", "relic_win_rates",
114+
"character_stats", "ascension_stats"):
115+
scaled[field] = _scale_subcounts(imported.get(field, {}), scale)
116+
return scaled
117+
return copy.deepcopy(imported)
90118

91119
existing_count = existing.get("run_count", 0)
92120
imported_count = imported.get("run_count", 0)
@@ -96,15 +124,21 @@ def merge_aggregate(existing: dict, imported: dict) -> dict:
96124

97125
merged = {"run_count": existing_count + imported_count}
98126

99-
# Merge dict-of-dicts fields
127+
# Merge dict-of-dicts fields. Deep-copy nested dicts from `existing` so the
128+
# caller's in-memory aggregate is not mutated when we add imported counts.
100129
for field in ("card_pick_rates", "card_win_rates", "relic_win_rates",
101130
"character_stats", "ascension_stats"):
102131
ex = existing.get(field, {})
103132
im = imported.get(field, {})
104-
merged_field = dict(ex)
133+
merged_field = {k: dict(v) if isinstance(v, dict) else v for k, v in ex.items()}
105134
for key, vals in im.items():
106-
if key in merged_field:
135+
if not isinstance(vals, dict):
136+
continue
137+
if key in merged_field and isinstance(merged_field[key], dict):
107138
for subkey, subval in vals.items():
139+
# Exclude bools — isinstance(True, int) is True.
140+
if isinstance(subval, bool):
141+
continue
108142
if isinstance(subval, (int, float)):
109143
merged_field[key][subkey] = merged_field[key].get(subkey, 0) + subval
110144
else:

sts2/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ def validate_csrf_token(token: str) -> bool:
141141
async def _get_progress():
142142
global _progress_cache, _progress_cache_time
143143
now = time.monotonic()
144-
if _progress_cache is None or (now - _progress_cache_time) > _PROGRESS_CACHE_TTL:
144+
# Use cache_time==0 (never populated) rather than cache is None — get_progress
145+
# returns None for "no save file" which is a valid cached value.
146+
if _progress_cache_time == 0 or (now - _progress_cache_time) > _PROGRESS_CACHE_TTL:
145147
_progress_cache = await asyncio.to_thread(get_progress)
146148
_progress_cache_time = now
147149
return _progress_cache

sts2/community/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
STS2_INDICATORS,
1313
SourceResult,
1414
)
15-
16-
__all__ = ["STS2_INDICATORS", "SourceResult", "merge_results"]
1715
from ._types import (
1816
compute_consensus_tier as _compute_consensus_tier,
1917
)
@@ -27,8 +25,13 @@
2725

2826
log = logging.getLogger(__name__)
2927

30-
# Re-exports for backward compatibility (tests import these from sts2.community)
28+
# Re-exports for backward compatibility (tests import these from sts2.community).
29+
# Single canonical __all__ — previous code declared it twice and silently
30+
# dropped STS2_INDICATORS + SourceResult on the second assignment.
3131
__all__ = [
32+
"STS2_INDICATORS",
33+
"SourceResult",
34+
"merge_results",
3235
"run_community_scraper",
3336
"save_community_data",
3437
"apply_community_tiers",

sts2/community/_types.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,23 @@ def extract_tier_ratings(text: str, existing_names: set[str]) -> dict[str, list[
6868

6969

7070
def extract_tips(text: str, entity_names: set[str]) -> dict[str, list[str]]:
71-
"""Extract strategy tips mentioning specific cards/relics."""
71+
"""Extract strategy tips mentioning specific cards/relics.
72+
73+
Names match on word boundaries (not substring) so short names like "rat"
74+
don't collide with unrelated words like "strategy" or "fascinating".
75+
Names shorter than 3 characters are skipped entirely to avoid noise.
76+
"""
7277
tips: dict[str, list[str]] = defaultdict(list)
7378
sentences = re.split(r"[.!?\n]+", text)
79+
# Pre-compile word-boundary patterns once per call.
80+
patterns = {n: re.compile(r"\b" + re.escape(n) + r"\b") for n in entity_names if len(n) >= 3}
7481
for sentence in sentences:
7582
sentence = sentence.strip()
7683
if len(sentence) < 20 or len(sentence) > 300:
7784
continue
7885
sentence_lower = sentence.lower()
79-
for name in entity_names:
80-
if name in sentence_lower:
86+
for name, pat in patterns.items():
87+
if pat.search(sentence_lower):
8188
tips[name].append(sentence)
8289
return tips
8390

sts2/community/reddit.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def _fetch_reddit_json(url: str, retries: int = 2) -> dict | None:
5050
time.sleep(2 * (attempt + 1))
5151
else:
5252
raise
53+
# Defensive: all retries exhausted without raising or returning. This
54+
# path is unreachable under current control flow but guards against
55+
# future refactors silently returning None.
56+
raise urllib.error.URLError("Reddit fetch retries exhausted")
5357

5458

5559
def _fetch_subreddit_posts(subreddit: str, sort: str = "top",

sts2/community/steam.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,13 @@ def _scrape_guides(existing_names: set[str], result: SourceResult) -> None:
219219
# Extract tier ratings and tips from guide body
220220
ratings = extract_tier_ratings(body_text, existing_names)
221221
for name_lower, tiers in ratings.items():
222-
# Weight guide tier votes at 1.5x (curated content)
222+
# Weight guide tier votes at 1.5x (curated content): the full
223+
# list contributes 1x, plus ceil(n/2) extras contribute 0.5x.
224+
# Prior code took every-other-index which only averaged 0.5x on
225+
# even-length lists; on odd lists it drifted off the 1.5x target.
223226
result.tier_votes[name_lower].extend(tiers)
224-
# Add an extra half-vote for each (round up)
225-
extra = [t for i, t in enumerate(tiers) if i % 2 == 0]
226-
result.tier_votes[name_lower].extend(extra)
227+
half_up = (len(tiers) + 1) // 2
228+
result.tier_votes[name_lower].extend(tiers[:half_up])
227229

228230
tips = extract_tips(body_text, existing_names)
229231
for name_lower, tip_list in tips.items():

0 commit comments

Comments
 (0)