Engagement features

This commit is contained in:
2026-10-21 13:10:25 -04:00
parent f4c10c9d7d
commit f9afa8ccbe
6 changed files with 405 additions and 4 deletions
+160
View File
@@ -36,6 +36,7 @@ class PersistenceTests(unittest.TestCase):
self.game.MAILING_LIST_FILE = root / "mailing.csv"
self.game.failed_logins.clear()
self.game.subscribed_emails.clear()
self.game.presence.clear()
def sample_state(self):
state = self.game.new_game()
@@ -65,6 +66,165 @@ class PersistenceTests(unittest.TestCase):
def write_legacy(self, state):
self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8")
def presence_client(self):
self.game.load_state()
self.game.state = self.sample_state()
self.game.state["players"]["alice"]["token_hashes"] = [self.game.hash_token("alice-token"), self.game.hash_token("alice-second")]
self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-token")]
self.game.save_state(self.game.state)
self.game.token_index = {
th: pid for pid, player in self.game.state["players"].items() for th in player["token_hashes"]
}
httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
self.addCleanup(httpd.server_close)
self.addCleanup(thread.join)
self.addCleanup(httpd.shutdown)
def request(method="GET", data=None, token=None, path="/api/presence"):
connection = HTTPConnection(*httpd.server_address, timeout=5)
try:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
connection.request(method, path, json.dumps(data) if data is not None else None, headers)
response = connection.getresponse()
return response.status, json.loads(response.read())
finally:
connection.close()
return request
def test_presence_auth_validation_and_default_privacy(self):
request = self.presence_client()
self.assertEqual(request(), (200, {"online_count": 0, "chat_players": []}))
update = {"tab_id": "a" * 32, "active": True, "in_chat": True}
self.assertEqual(request("POST", update)[0], 401)
self.assertEqual(request("POST", update, "not-a-token")[0], 401)
for invalid in ({}, {**update, "active": 1}, {**update, "in_chat": "true"},
{**update, "tab_id": "a" * 33}, {**update, "tab_id": "<script>"},
{**update, "tab_id": None}):
self.assertEqual(request("POST", invalid, "alice-token")[0], 400)
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
self.assertNotIn("presence", self.game.load_state())
def test_presence_deduplicates_tabs_sessions_and_updates_chat(self):
request = self.presence_client()
update = {"tab_id": "a" * 32, "active": True, "in_chat": False}
self.assertEqual(request("POST", update, "alice-token")[1], {"online_count": 1, "chat_players": []})
request("POST", {**update, "tab_id": "b" * 32, "in_chat": True}, "alice-token")
request("POST", {**update, "in_chat": True}, "alice-second")
request("POST", {**update, "in_chat": True, "name": "Imposter"}, "bob-token")
expected = {"online_count": 2, "chat_players": [self.game.state["players"]["alice"]["name"], "Bøb"]}
self.assertEqual(request()[1], expected)
request("POST", {**update, "tab_id": "b" * 32, "active": False}, "alice-token")
self.assertEqual(request()[1], expected) # other session still viewing chat
request("POST", update, "alice-second")
self.assertEqual(request()[1], {"online_count": 2, "chat_players": ["Bøb"]})
request("POST", {**update, "active": False}, "alice-token")
self.assertEqual(request()[1]["online_count"], 2) # second session still online
request("POST", {**update, "active": False}, "alice-second")
self.assertEqual(request()[1], {"online_count": 1, "chat_players": ["Bøb"]})
def test_presence_expires_and_revoked_sessions_disappear(self):
request = self.presence_client()
update = {"tab_id": "a" * 32, "active": True, "in_chat": True}
with patch.object(self.game.time, "monotonic", return_value=100):
request("POST", update, "alice-token")
with patch.object(self.game.time, "monotonic", return_value=144):
self.assertEqual(request()[1]["online_count"], 1)
request("POST", update, "alice-token") # heartbeat renews TTL
with patch.object(self.game.time, "monotonic", return_value=188):
self.assertEqual(request()[1]["online_count"], 1)
with patch.object(self.game.time, "monotonic", return_value=189):
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
request("POST", update, "alice-token")
self.game.token_index.pop(self.game.hash_token("alice-token"))
self.assertEqual(request()[1]["online_count"], 0)
self.assertEqual(self.game.presence, {})
def test_presence_bounded_tabs_and_logout_cleanup(self):
request = self.presence_client()
for index in range(self.game.MAX_PRESENCE_TABS_PER_SESSION):
update = {"tab_id": f"{index:032x}", "active": True, "in_chat": True}
self.assertEqual(request("POST", update, "alice-token")[0], 200)
update = {"tab_id": "f" * 32, "active": True}
self.assertEqual(request("POST", update, "alice-token")[0], 429)
self.assertEqual(request("POST", {**update, "tab_id": "0" * 32}, "alice-token")[0], 200)
self.assertEqual(request("POST", {}, "alice-token", "/api/logout")[0], 200)
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
self.assertEqual(self.game.presence, {})
def test_personal_bests_use_only_own_presses_and_existing_history(self):
self.game.state = self.sample_state()
summary = self.game.player_summary("alice")
self.assertEqual(summary["personal_bests"]["closest_call"], {
"at": 100, "remaining_ms": 20, "zone": "white", "points": 32,
})
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 32)
bob = self.game.player_summary("bob")
self.assertEqual(bob["personal_bests"]["closest_call"]["remaining_ms"], 50)
self.assertEqual(bob["personal_bests"]["highest_scoring_press"]["points"], 16)
self.assertEqual(bob["ranking"], {
"position": 2, "total_players": 2, "tied_for_lead": False,
"rival": {"name": summary["name"], "total_points": 40, "points_to_tie": 24, "points_to_pass": 25},
})
self.assertIsNone(summary["ranking"]["rival"])
self.assertFalse(summary["ranking"]["tied_for_lead"])
self.game.load_state()
self.game.save_state(self.game.state)
self.game.state = self.game.load_state()
self.assertEqual(self.game.player_summary("alice")["personal_bests"], summary["personal_bests"])
def test_personal_records_empty_zero_points_and_deterministic_ties(self):
self.game.state = self.game.new_game()
self.game.state["players"] = {"solo": {"name": "Solo"}}
summary = self.game.player_summary("solo")
self.assertEqual(summary["personal_bests"], {"closest_call": None, "highest_scoring_press": None})
self.assertEqual(summary["ranking"], {"position": 1, "total_players": 1, "tied_for_lead": False, "rival": None})
self.game.state["presses"] = [
{"player_id": "solo", "at": at, "remaining_ms": remaining, "zone": "green", "points": 0}
for at, remaining in [(100, 6000), (300, 5000), (200, 5000)]
]
records = self.game.player_summary("solo")["personal_bests"]
self.assertEqual(records["highest_scoring_press"]["points"], 0)
self.assertEqual(records["highest_scoring_press"]["at"], 200)
self.assertEqual(records["closest_call"]["at"], 200)
def test_rivals_follow_alphabetical_ties_and_shared_lead(self):
self.game.state = self.game.new_game()
self.game.state["players"] = {"bob": {"name": "Bob"}, "alice": {"name": "alice"}, "charlie": {"name": "Charlie"}}
alice = self.game.player_summary("alice")["ranking"]
bob = self.game.player_summary("bob")["ranking"]
charlie = self.game.player_summary("charlie")["ranking"]
self.assertEqual(alice["position"], 1)
self.assertTrue(alice["tied_for_lead"])
self.assertIsNone(alice["rival"])
self.assertEqual(bob["position"], 2)
self.assertEqual(bob["rival"], {"name": "alice", "total_points": 0, "points_to_tie": 0, "points_to_pass": 1})
self.assertEqual(charlie["rival"]["name"], "Bob")
self.game.state["players"]["bob"]["name"] = "alice" # legacy duplicate names remain distinct IDs
self.assertNotEqual(self.game.player_summary("bob")["ranking"]["position"], self.game.player_summary("alice")["ranking"]["position"])
def test_rival_changes_when_another_player_overtakes(self):
self.game.state = self.sample_state()
self.game.state["presses"].append({"player_id": "bob", "at": 300, "remaining_ms": 1, "zone": "white", "points": 32})
summary = self.game.player_summary("alice")
self.assertEqual(summary["ranking"]["rival"], {"name": "Bøb", "total_points": 48, "points_to_tie": 8, "points_to_pass": 9})
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 32)
public = self.game.public_leaderboard()
for row in public:
self.assertEqual(set(row), {"name", "total_points", "press_count"})
def test_personal_records_endpoint_requires_authentication(self):
request = self.presence_client()
self.assertEqual(request(path="/api/me")[0], 401)
status, summary = request(token="bob-token", path="/api/me")
self.assertEqual(status, 200)
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 16)
self.assertEqual(set(summary["ranking"]["rival"]), {"name", "total_points", "points_to_tie", "points_to_pass"})
def test_fresh_game_survives_restart_without_creating_json(self):
state = self.game.load_state()
self.assertEqual(state, self.game.load_state())