From 16f4c071fe51910445ea0e02c3775f5eb3288db1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:32:19 +0200 Subject: [PATCH] fix: arbitrate slots on the strongest profile proof --- scripts/slots.py | 30 ++++++++++++++++++++++++++---- tests/test_slots.py | 20 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/scripts/slots.py b/scripts/slots.py index d6bea225..8e336db7 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -319,6 +319,15 @@ def pack_overrides( return overrides +def _strongest(claims: list[Claim]) -> Claim: + """The profile claim with the strongest proof; the first emulator breaks ties. + + yaps2 pins its GameIndex.yaml by sha1 while armsx2 and lrps2 only match a + path: alphabetical order served armsx2's copy to all three. + """ + return max(claims, key=lambda claim: (_claim_rank(claim), -claims.index(claim))) + + def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Decision: """Decide a contested destination for the pack being built. @@ -333,7 +342,7 @@ def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Dec addressee decides; the loss is reported rather than absorbed, because the cause is an upstream declaration that needs fixing at its source. """ - profile = conflict.profile_claims[0] + profile = _strongest(conflict.profile_claims) if addressee == "emulator": return Decision(conflict, profile, ADDRESSEE) if not nativemode.reads_file_contents(mode): @@ -345,11 +354,24 @@ def format_decision(decision: Decision) -> str: """One line naming the contested slot, the winner and the ground for it.""" conflict = decision.conflict if decision.serves_both: - return ( - f"{conflict.destination}: serve {decision.winner.local_path} " - f"({', '.join(conflict.emulators) or 'profile'}); the frontend only " + winner = decision.winner.local_path + served = sorted({ + c.emulator for c in conflict.profile_claims + if c.emulator and c.local_path == winner + }) + others = sorted({ + c.emulator for c in conflict.profile_claims + if c.emulator and c.local_path != winner + }) + line = ( + f"{conflict.destination}: serve {winner} " + f"({', '.join(served) or 'profile'}); the frontend only " "checks the path, so both are satisfied" ) + if others: + # One path holds one file: an emulator pinning another is not served. + line += f"; {', '.join(others)} expected another file" + return line if decision.reason == ADDRESSEE: return ( f"{conflict.destination}: serve {decision.winner.local_path}, " diff --git a/tests/test_slots.py b/tests/test_slots.py index a6bc7ba0..11878bc2 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -555,5 +555,25 @@ class ManifestFollowsTheArbitration(unittest.TestCase): self.assertEqual(by_dest[dest]["repo_path"], path) +class StrongestProfileClaimWins(unittest.TestCase): + """yaps2 pins GameIndex.yaml by sha1; armsx2 matched a path only.""" + + def test_hash_proof_beats_path_proof(self): + from slots import Claim, Conflict, arbitrate, format_decision + + def claim(emu, path, status): + return Claim("profile", "pcsx2/resources/GameIndex.yaml", "GameIndex.yaml", + emulator=emu, local_path=path, status=status) + + conflict = Conflict( + "pcsx2/resources/GameIndex.yaml", + Claim("platform", "pcsx2/resources/GameIndex.yaml", "GameIndex.yaml", + local_path="p", status="path_exact"), + [claim("armsx2", "a", "path_exact"), claim("yaps2", "y", "sha1_exact")], + ) + decision = arbitrate(conflict, "existence") + self.assertEqual(decision.winner.emulator, "yaps2") + self.assertIn("armsx2 expected another file", format_decision(decision)) + if __name__ == "__main__": unittest.main()