From ded3b9ed3042cbff2f9a5adb9b195e6850e95c5c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 8 May 2026 20:31:16 +0200 Subject: [PATCH] fix: accept ProtonDB gold+silver combinations; add explicit skip reasons --- .../contracts/protondb-gold-silver-fix.json | 18 ++++++++ .../evidence/protondb-gold-silver-fix.json | 35 +++++++++++++++ python_pkg/steam_backlog_enforcer/protondb.py | 44 ++++++++++++++----- python_pkg/steam_backlog_enforcer/scanning.py | 3 +- .../tests/test_protondb.py | 18 +++++++- .../tests/test_scanning.py | 21 +++++++++ 6 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/contracts/protondb-gold-silver-fix.json create mode 100644 docs/superpowers/evidence/protondb-gold-silver-fix.json diff --git a/docs/superpowers/contracts/protondb-gold-silver-fix.json b/docs/superpowers/contracts/protondb-gold-silver-fix.json new file mode 100644 index 0000000..1ac4f71 --- /dev/null +++ b/docs/superpowers/contracts/protondb-gold-silver-fix.json @@ -0,0 +1,18 @@ +{ + "title": "ProtonDB gold+silver acceptance fix", + "objective": "Fix the is_playable rule in protondb.py so that a game with one gold rating and one silver rating (in either order) is accepted as playable. Previously both ratings had to be gold+. Add an unplayable_reason property for diagnostic logging in scanning.py.", + "acceptance_criteria": [ + "gold+silver -> accepted", + "silver+gold -> accepted", + "gold+bronze -> rejected", + "silver+silver -> rejected", + "Skip log includes the unplayable_reason string", + "All existing and new tests pass (63 total)", + "Pre-commit passes on all 4 changed files" + ], + "out_of_scope": [ + "Changes to any other enforcement logic", + "Changes to config or API clients" + ], + "verifier": "pytest python_pkg/steam_backlog_enforcer/tests/ && pre-commit run --files " +} diff --git a/docs/superpowers/evidence/protondb-gold-silver-fix.json b/docs/superpowers/evidence/protondb-gold-silver-fix.json new file mode 100644 index 0000000..159bfa3 --- /dev/null +++ b/docs/superpowers/evidence/protondb-gold-silver-fix.json @@ -0,0 +1,35 @@ +{ + "intent": "Fix ProtonDB acceptance rule so gold+silver combinations are accepted, and add explicit skip-reason logging.", + "scope": [ + "python_pkg/steam_backlog_enforcer/protondb.py", + "python_pkg/steam_backlog_enforcer/scanning.py", + "python_pkg/steam_backlog_enforcer/tests/test_protondb.py", + "python_pkg/steam_backlog_enforcer/tests/test_scanning.py", + "No changes to config, API clients, or other modules" + ], + "changes": [ + "Updated is_playable: both ratings must be >= silver AND at least one must be gold+", + "Added unplayable_reason property returning terse diagnostic string", + "Updated _pick_playable_candidate skip log to include unplayable_reason", + "Added/updated tests for new acceptance rule and unplayable_reason" + ], + "verification": [ + { + "command": "pre-commit run --files python_pkg/steam_backlog_enforcer/protondb.py python_pkg/steam_backlog_enforcer/scanning.py python_pkg/steam_backlog_enforcer/tests/test_protondb.py python_pkg/steam_backlog_enforcer/tests/test_scanning.py", + "result": "pass", + "evidence": "All hooks passed (trim whitespace, ruff, ruff-format, codespell, etc.)" + }, + { + "command": "pytest python_pkg/steam_backlog_enforcer/tests/", + "result": "pass", + "evidence": "63 passed, 0 failed" + } + ], + "risks": [ + "Games with gold+silver were previously rejected; they will now be assigned — intended behaviour change" + ], + "rollback": [ + "Revert is_playable to require both ratings to be gold+", + "Validate with pytest python_pkg/steam_backlog_enforcer/tests/" + ] +} diff --git a/python_pkg/steam_backlog_enforcer/protondb.py b/python_pkg/steam_backlog_enforcer/protondb.py index eb214aa..10d4c6a 100644 --- a/python_pkg/steam_backlog_enforcer/protondb.py +++ b/python_pkg/steam_backlog_enforcer/protondb.py @@ -59,25 +59,49 @@ class ProtonDBRating: A game is considered unplayable when: - Its tier is silver, bronze, or borked. - - Its tier is gold but trending to silver or worse. - - No data exists (unknown compatibility). + - Both reported ratings are available, but one is below silver. + - Both reported ratings are available, but neither reaches gold. + + If both ``tier`` and ``trending_tier`` exist, the acceptance rule is: + at least one rating must be gold-or-better and the other must be + silver-or-better. """ if not self.tier or self.tier == "pending": return True # No data / pending → don't block; user can skip manually. tier_rank = TIER_ORDER.get(self.tier, 99) min_rank = TIER_ORDER[MIN_PLAYABLE_TIER] + silver_rank = TIER_ORDER["silver"] - if tier_rank > min_rank: - # Silver, bronze, borked → skip. + if not self.trending_tier: + return tier_rank <= min_rank + + trend_rank = TIER_ORDER.get(self.trending_tier, 99) + if tier_rank > silver_rank or trend_rank > silver_rank: + # Bronze, borked, unknown tier in either field → skip. return False - if tier_rank == min_rank and self.trending_tier: - # Gold but trending silver/bronze/borked → skip. - trend_rank = TIER_ORDER.get(self.trending_tier, 99) - if trend_rank > min_rank: - return False + # At least one rating must still be gold-or-better. + return not (tier_rank > min_rank and trend_rank > min_rank) - return True + @property + def unplayable_reason(self) -> str: + """Return a human-readable reason when ``is_playable`` is false.""" + if self.is_playable: + return "" + + tier_rank = TIER_ORDER.get(self.tier, 99) + min_rank = TIER_ORDER[MIN_PLAYABLE_TIER] + silver_rank = TIER_ORDER["silver"] + + if not self.trending_tier: + return f"tier<{MIN_PLAYABLE_TIER} ({self.tier})" + + trend_rank = TIER_ORDER.get(self.trending_tier, 99) + if tier_rank > silver_rank or trend_rank > silver_rank: + return f"below silver ({self.tier}/{self.trending_tier})" + if tier_rank > min_rank and trend_rank > min_rank: + return f"no gold tier ({self.tier}/{self.trending_tier})" + return "fails ProtonDB rule" def _load_cache() -> dict[str, Any]: diff --git a/python_pkg/steam_backlog_enforcer/scanning.py b/python_pkg/steam_backlog_enforcer/scanning.py index 0d21e1d..cee8b95 100644 --- a/python_pkg/steam_backlog_enforcer/scanning.py +++ b/python_pkg/steam_backlog_enforcer/scanning.py @@ -151,11 +151,12 @@ def _pick_playable_candidate( ) return game logger.info( - "Skipping %s (AppID=%d): ProtonDB %s (trending %s)", + "Skipping %s (AppID=%d): ProtonDB %s (trending %s) — %s", game.name, game.app_id, rating.tier, rating.trending_tier, + rating.unplayable_reason, ) offset += _PROTONDB_BATCH_SIZE diff --git a/python_pkg/steam_backlog_enforcer/tests/test_protondb.py b/python_pkg/steam_backlog_enforcer/tests/test_protondb.py index 7c4c7bd..bd581e1 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_protondb.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_protondb.py @@ -62,12 +62,16 @@ class TestProtonDBRating: def test_gold_trending_silver(self) -> None: r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver") - assert r.is_playable is False + assert r.is_playable is True def test_gold_trending_gold(self) -> None: r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold") assert r.is_playable is True + def test_silver_trending_gold(self) -> None: + r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold") + assert r.is_playable is True + def test_gold_no_trending(self) -> None: r = ProtonDBRating(app_id=1, tier="gold", trending_tier="") assert r.is_playable is True @@ -80,10 +84,22 @@ class TestProtonDBRating: r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown") assert r.is_playable is False + def test_gold_trending_bronze(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") + assert r.is_playable is False + def test_unknown_tier(self) -> None: r = ProtonDBRating(app_id=1, tier="unknown_tier") assert r.is_playable is False + def test_unplayable_reason_for_silver_silver(self) -> None: + r = ProtonDBRating(app_id=1, tier="silver", trending_tier="silver") + assert "no gold tier" in r.unplayable_reason + + def test_unplayable_reason_for_gold_bronze(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") + assert "below silver" in r.unplayable_reason + class TestProtonDBCache: """Tests for cache I/O.""" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning.py index 98be132..de058ec 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_scanning.py @@ -215,6 +215,27 @@ class TestPickPlayableCandidate: result = _pick_playable_candidate([game]) assert result is not None + def test_logs_explicit_protondb_skip_reason(self) -> None: + """Unplayable candidate logs concrete reason, not just raw tiers.""" + bad = _game(app_id=1, name="Bad") + good = _game(app_id=2, name="Good") + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", + return_value={ + 1: ProtonDBRating(app_id=1, tier="silver", trending_tier="silver"), + 2: ProtonDBRating(app_id=2, tier="platinum"), + }, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch("python_pkg.steam_backlog_enforcer.scanning.logger.info") as mock_log, + ): + result = _pick_playable_candidate([bad, good]) + + assert result is not None + assert result.app_id == 2 + assert any("no gold tier" in str(call) for call in mock_log.call_args_list) + class TestPickNextGame: """Tests for pick_next_game."""