More achievements

This commit is contained in:
2026-10-22 12:25:21 -04:00
parent 3b81cae024
commit 84b363b863
4 changed files with 115 additions and 16 deletions
+16 -4
View File
@@ -73,7 +73,7 @@ Chat supports emoji autocomplete: type `:` followed by a name (for example `:sm`
Seasons begin at **00:00 UTC on the UTC date of each astronomical equinox or solstice**, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as **Season I, Season II, Season III**, etc., including in the subtitle under **THE BUTTON**. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101.
At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, achievements, and milestone progress reset. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat.
At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, timing achievements, and milestone progress reset. The four lifetime season awards persist. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat.
A season finishes when the countdown expires **or** the next seasonal boundary is reached. Pressing cannot extend the timer beyond that boundary. If the timer expires early, pressing and new registrations remain unavailable until the next season; existing players can still log in and chat. The watcher continues running and also catches up boundaries after downtime. A missed season starts at its scheduled midnight, not when the server restarts: without presses its countdown may already be over. Missed seasons have no invented winners.
@@ -90,7 +90,7 @@ The goal is to make waiting social and consequential without rewarding button sp
1. **Optional live presence — implemented.** Show participating players watching the clock and who is viewing Chat. On by default, with a remembered opt-out; ephemeral heartbeats, automatic expiry, and no activity history. See the details below.
2. **Personal bests and rivalries — implemented.** Your score shows closest call, highest-scoring press, leaderboard position, and the player immediately ahead with points needed to catch up. Existing history is credited automatically; no data migration is needed.
3. **Achievements — implemented.** Close call, Full spectrum, and Keeping us alive appear under Your score with unlock dates and progress. Existing press history counts, with no bonus points or press-volume rewards.
4. **Community milestones — implemented.** Eight one-time shared goals post gold announcements in chat. Unlocks persist across restarts, and historical milestones are silently credited on upgrade.
4. **Community milestones — implemented.** Twelve one-time shared goals post gold announcements in chat. Unlocks persist across restarts, and historical milestones are silently credited on upgrade.
5. **Optional zone alerts — implemented.** Choose a scoring zone and opt into a chime, browser notifications, or both in Alerts. Off by default; notifications require explicit permission. Works while the page remains open, not after closing it.
6. **Separate one-life mode — exploratory.** Allow one press per player per round in a separate mode with its own rounds and leaderboard. Never silently change the current game or reset its data.
@@ -104,7 +104,7 @@ Leaderboard positions match the public ordering (points descending, then name al
### Achievements
Your score includes eight private achievements, derived from the game's existing press history:
Your score includes twelve private achievements: eight current-season timing badges and four lifetime awards based on completed seasons. All are credited from existing history:
| Achievement | Requirement |
|-------------|-------------|
@@ -115,6 +115,10 @@ Your score includes eight private achievements, derived from the game's existing
| White knuckle | Press in the white zone. |
| Photo finish | Press with strictly less than ten seconds remaining, and more than zero. |
| Last second | Press with strictly less than one second remaining, and more than zero. |
| Part of the story | Make at least one press in a completed season. |
| Back for another | Participate in two completed seasons. |
| Podium finish | Finish a completed season with competition rank 1, 2, or 3 by points. |
| Season champion | Finish a completed season with the highest score; joint winners qualify. |
| Keeping us alive | Press after a global quiet stretch of at least half the saved game countdown: 12 hours for the standard 24-hour game. Scales with shorter test games. |
Quiet time is measured from the previous press by **any** player, or from game start if there was no previous press. It is not the time since your own last press. The earliest qualifying press determines each unlock date. Progress is based on completed presses, not idle time accumulating while the page is open. These are badges only: they do not change scores, publish chat announcements, or reward repeated resets.
@@ -123,7 +127,11 @@ Historical JSON imports and existing SQLite history are credited automatically.
### Community milestones
There are **eight community milestones**, matching the eight personal achievements. Each posts once to the shared chat, with distinct gold styling:
Lifetime season awards use archived presses and stable player IDs, not display names. They are awarded only after timeout or seasonal rollover, including existing archives. One press (even zero points) qualifies as participation; idle accounts do not. Podium uses competition ranking: rank is one plus the number of participating players with strictly higher points, so tied scores share rank and may expand the podium. Joint top scores all earn champion. Awards retain their first qualifying season's finish timestamp and never change scores. An ongoing leaderboard lead does not unlock a final-result award.
New shared-event definitions already met on upgrade are silently credited individually; unmet goals still announce when reached. Existing milestone unlocks are not reset by adding new definitions.
There are **twelve community milestones**, matching the twelve personal achievements. Each posts once per season to the shared chat, with distinct gold styling:
| Milestone | Community goal |
|-----------|----------------|
@@ -135,6 +143,10 @@ There are **eight community milestones**, matching the eight personal achievemen
| A hundred second chances | Reach 100 presses. |
| A thousand moments | Reach 1,000 presses. |
| Our first photo finish | First successful press with more than zero but less than 10 seconds left. |
| Every shade together | The community visits all six scoring zones this season. |
| A race worth watching | Ten distinct players earn positive points this season. |
| A thousand points together | Reach 1,000 combined seasonal points. |
| A season to remember | Reach 10,000 combined seasonal points. |
Survival milestones use real elapsed time since game start, not scaled countdown durations, and never count time after the game deadline/end. They are checked by the server watcher and normal state requests, even without presses. The photo finish is a single community first, not an announcement for every improved record. No milestone gives bonus points.
+48 -4
View File
@@ -265,11 +265,16 @@ def write_state(connection, state):
def milestone_status(game_state, at):
"""Eight fixed community goals; elapsed survival stops at the deadline."""
"""Twelve fixed community goals; elapsed survival stops at the deadline."""
until = min(at, game_state["ended_at"] if game_state["ended_at"] is not None else game_state["deadline"])
elapsed = max(0, until - game_state["started_at"])
players = len(game_state["players"])
presses = len(game_state["presses"])
scores = {}
visited = set()
for press in game_state["presses"]:
scores[press["player_id"]] = scores.get(press["player_id"], 0) + press["points"]
visited.add(press["zone"])
goals = [
("day_one", "One day together", "The community kept the game alive for one day!", elapsed, 86400000),
("one_week", "A week of nerve", "Seven days together. The clock is still part of our story!", elapsed, 7 * 86400000),
@@ -279,6 +284,10 @@ def milestone_status(game_state, at):
("hundred_presses", "A hundred second chances", "Together we've pressed the button 100 times!", presses, 100),
("thousand_presses", "A thousand moments", "1,000 presses. A thousand decisions to keep us going!", presses, 1000),
("first_photo_finish", "Our first photo finish", "A community first: someone pressed with less than ten seconds left!", int(any(0 < p["remaining_ms"] < 10000 for p in game_state["presses"])), 1),
("shared_spectrum", "Every shade together", "The community has pressed in every scoring zone this season!", len(visited & {z["name"] for z in ZONES}), len(ZONES)),
("ten_contenders", "A race worth watching", "Ten players have earned points on this season's leaderboard!", sum(points > 0 for points in scores.values()), 10),
("thousand_points", "A thousand points together", "The seasonal leaderboard has reached 1,000 combined points!", sum(scores.values()), 1000),
("ten_thousand_points", "A season to remember", "The community has earned 10,000 points this season!", sum(scores.values()), 10000),
]
return [{"id": key, "name": name, "message": message, "progress": min(progress, target), "target": target}
for key, name, message, progress, target in goals]
@@ -287,14 +296,14 @@ def milestone_status(game_state, at):
def sync_milestones(connection, game_state, at=None):
"""Commit unlocks with game changes. Existing goals are silently baselined once."""
at = now_ms() if at is None else at
initialized = connection.execute("SELECT 1 FROM community_milestones LIMIT 1").fetchone() is not None
for goal in milestone_status(game_state, at):
existed = connection.execute("SELECT 1 FROM community_milestones WHERE id = ?", (goal["id"],)).fetchone() is not None
connection.execute("INSERT OR IGNORE INTO community_milestones (id) VALUES (?)", (goal["id"],))
if goal["progress"] >= goal["target"]:
connection.execute(
"""UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ?
WHERE id = ? AND reached_at IS NULL""",
(at, at if initialized else None, f"Milestone: {goal['name']}. {goal['message']}", goal["id"]),
(at, at if existed else None, f"Milestone: {goal['name']}. {goal['message']}", goal["id"]),
)
@@ -614,6 +623,41 @@ def empty_zone_counts():
return {z["name"]: 0 for z in ZONES}
def seasonal_achievements(pid):
"""Lifetime badges from immutable archived presses, keyed by player ID, not names."""
with database() as connection:
exists = connection.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'season_results'").fetchone()
rows = connection.execute("SELECT finished_at, presses FROM season_results ORDER BY finished_at, starts_at").fetchall() if exists else []
finished = 0
finisher_at = veteran_at = podium_at = champion_at = None
for row in rows:
scores = {}
for press in json.loads(row["presses"]):
scores[press["player_id"]] = scores.get(press["player_id"], 0) + press["points"]
if pid not in scores:
continue
finished += 1
if finisher_at is None:
finisher_at = row["finished_at"]
if finished >= 2 and veteran_at is None:
veteran_at = row["finished_at"]
# Competition ranks: tied scores share rank; never use alphabetical order for awards.
rank = 1 + sum(points > scores[pid] for points in scores.values())
if rank <= 3 and podium_at is None:
podium_at = row["finished_at"]
if rank == 1 and champion_at is None:
champion_at = row["finished_at"]
rules = [
("season_finisher", "Part of the story", "Participate with a press in a completed season.", finisher_at, min(finished, 1), 1),
("season_veteran", "Back for another", "Participate in two completed seasons.", veteran_at, min(finished, 2), 2),
("season_podium", "Podium finish", "Finish a season in the top three by points. Ties share rank.", podium_at, int(podium_at is not None), 1),
("season_champion", "Season champion", "Finish a season with the highest score. Joint winners qualify.", champion_at, int(champion_at is not None), 1),
]
return [{"id": key, "name": name, "description": description, "unlocked": at is not None,
"unlocked_at": at, "progress": progress, "target": target, "scope": "lifetime"}
for key, name, description, at, progress, target in rules]
def player_achievements(pid):
"""Derive private achievements from recorded history, including legacy games."""
quiet_target = max(1, state["duration_ms"] // 2)
@@ -667,7 +711,7 @@ def player_achievements(pid):
"unlocked_at": extra_unlocks[achievement_id],
"progress": int(extra_unlocks[achievement_id] is not None), "target": 1}
for achievement_id, name, description, _, _ in extra_rules
]
] + seasonal_achievements(pid)
def player_summary(pid):
+4 -1
View File
@@ -476,12 +476,15 @@ function renderAchievements(achievements) {
progressText = `${achievement.progress} / ${achievement.target} zones visited`;
} else if (achievement.id === "keeping_us_alive") {
progressText = `Longest quiet stretch before your press: ${formatPrecise(achievement.progress)} / ${formatPrecise(achievement.target)} needed`;
} else if (achievement.scope === "lifetime") {
progressText = achievement.id === "season_veteran"
? `${achievement.progress} / ${achievement.target} completed seasons played` : "Awarded when a season finishes. Kept across seasons.";
}
if (achievement.unlocked) {
progressText = `Unlocked ${new Date(achievement.unlocked_at).toLocaleString()}`;
}
content.append(el("small", progressText));
if (["full_spectrum", "keeping_us_alive"].includes(achievement.id) && !achievement.unlocked) {
if (["full_spectrum", "keeping_us_alive", "season_veteran"].includes(achievement.id) && !achievement.unlocked) {
const progress = el("progress");
progress.max = achievement.target;
progress.value = achievement.progress;
+47 -7
View File
@@ -681,7 +681,7 @@ class PersistenceTests(unittest.TestCase):
def test_eight_milestones_match_achievement_count_and_start_unreached(self):
self.game.state = self.game.load_state()
goals = self.game.milestone_status(self.game.state, self.game.now_ms())
self.assertEqual(len(goals), 8)
self.assertEqual(len(goals), 12)
self.assertEqual(len(goals), len(self.game.player_achievements("nobody")))
self.assertTrue(all(goal["progress"] < goal["target"] for goal in goals))
self.assertEqual(self.game.chat_messages(), [])
@@ -689,7 +689,7 @@ class PersistenceTests(unittest.TestCase):
def test_milestone_thresholds_for_counts_and_precision(self):
game = self.game.new_game()
press = {"player_id": "p", "at": game["started_at"], "remaining_ms": 10000, "zone": "green", "points": 0}
for count in (9, 10, 49, 50):
for count in (9, 10, 49, 50, 51):
game["players"] = {str(i): {} for i in range(count)}
goals = {g["id"]: g for g in self.game.milestone_status(game, game["started_at"])}
self.assertEqual(goals["ten_players"]["progress"] == goals["ten_players"]["target"], count >= 10)
@@ -701,7 +701,7 @@ class PersistenceTests(unittest.TestCase):
self.assertEqual(goals["thousand_presses"]["progress"] == 1000, count >= 1000)
for remaining, qualifies in ((10000, False), (9999, True), (0, False), (-1, False)):
game["presses"] = [dict(press, remaining_ms=remaining)]
self.assertEqual(self.game.milestone_status(game, game["started_at"])[-1]["progress"], int(qualifies))
self.assertEqual(next(g for g in self.game.milestone_status(game, game["started_at"]) if g["id"] == "first_photo_finish")["progress"], int(qualifies))
def test_survival_milestones_boundaries_and_game_end_cap(self):
game = self.game.new_game()
@@ -728,7 +728,7 @@ class PersistenceTests(unittest.TestCase):
self.game.state = self.game.load_state()
self.assertEqual(self.game.chat_messages(), [])
with self.game.database() as connection:
self.assertEqual(connection.execute("SELECT COUNT(*) FROM community_milestones WHERE reached_at IS NOT NULL").fetchone()[0], 8)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM community_milestones WHERE reached_at IS NOT NULL").fetchone()[0], 10)
self.game.load_state()
self.assertEqual(self.game.chat_messages(), [])
# Existing SQLite database without the new table follows the same baseline.
@@ -792,6 +792,46 @@ class PersistenceTests(unittest.TestCase):
).fetchone()
self.assertEqual(latest_player["at"], at - 896)
def test_archived_season_awards_and_shared_ranks(self):
self.game.state = self.game.load_state()
scores = {"a": 32, "b": 32, "c": 16, "d": 8}
for number in (1, 2):
presses = [{"player_id": pid, "points": points} for pid, points in scores.items()]
with self.game.database() as connection:
connection.execute("""INSERT INTO season_results (id,name,starts_at,ends_at,played_from,finished_at,reason,leaderboard,presses)
VALUES (?,?,?,?,?,?,?,?,?)""", (str(number), "Same name", number*1000, number*1000+100, number*1000, number*1000+50, "countdown", "[]", json.dumps(presses)))
badges = {a["id"]: a for a in self.game.seasonal_achievements("a")}
self.assertTrue(badges["season_champion"]["unlocked"])
self.assertEqual(badges["season_veteran"]["progress"], number)
self.assertEqual(badges["season_veteran"]["unlocked"], number == 2)
self.assertTrue(self.game.seasonal_achievements("b")[-1]["unlocked"])
self.assertTrue(self.game.seasonal_achievements("c")[-2]["unlocked"])
self.assertFalse(self.game.seasonal_achievements("d")[-2]["unlocked"])
self.assertFalse(any(a["unlocked"] for a in self.game.seasonal_achievements("idle")))
self.game.load_state()
self.assertEqual(self.game.seasonal_achievements("a")[0]["unlocked_at"], 1050)
def test_new_events_upgrade_silently_and_announce_future_threshold_once(self):
self.game.state = self.game.load_state()
at = self.game.now_ms()
presses = [{"player_id": str(i), "points": 100, "zone": self.game.ZONES[i % 6]["name"], "remaining_ms": 60000, "at": at} for i in range(10)]
self.game.state["presses"] = presses
goals = {g["id"]: g for g in self.game.milestone_status(self.game.state, at)}
self.assertEqual(goals["ten_contenders"]["progress"], 10)
self.assertEqual(goals["shared_spectrum"]["progress"], 6)
self.assertEqual(goals["thousand_points"]["progress"], 1000)
with self.game.database() as connection:
connection.executemany("DELETE FROM community_milestones WHERE id = ?", [(key,) for key in ("shared_spectrum", "ten_contenders", "thousand_points", "ten_thousand_points")])
self.game.sync_milestones(connection, self.game.state, at)
self.assertEqual(self.game.chat_messages(), [])
self.game.state["presses"] = presses * 10
with self.game.database() as connection:
self.game.sync_milestones(connection, self.game.state, at+1)
self.game.sync_milestones(connection, self.game.state, at+2)
messages = [m for m in self.game.chat_messages() if m["id"] == "milestone:ten_thousand_points"]
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["at"], at+1)
def achievement_game(self):
self.game.state = self.sample_state()
self.game.state.update(duration_ms=120000, started_at=1000, presses=[])
@@ -802,7 +842,7 @@ class PersistenceTests(unittest.TestCase):
def test_achievements_start_locked_without_presses(self):
self.achievement_game()
achievements = self.achievement_map()
self.assertEqual(set(achievements), {"close_call", "full_spectrum", "keeping_us_alive", "red_alert", "into_the_dark", "white_knuckle", "photo_finish", "last_second"})
self.assertEqual(len(achievements), 12)
for achievement in achievements.values():
self.assertFalse(achievement["unlocked"])
self.assertIsNone(achievement["unlocked_at"])
@@ -869,7 +909,7 @@ class PersistenceTests(unittest.TestCase):
for index, zone in enumerate(self.game.ZONES):
self.game.state["presses"].append({"player_id": "alice", "at": 61000 + index * 1000, "remaining_ms": 999, "zone": zone["name"], "points": zone["points"]})
expected = self.game.player_achievements("alice")
self.assertTrue(all(a["unlocked"] for a in expected))
self.assertTrue(all(a["unlocked"] for a in expected if a.get("scope") != "lifetime"))
self.write_legacy(self.game.state)
self.game.state = self.game.load_state()
self.assertEqual(self.game.player_achievements("alice"), expected)
@@ -884,7 +924,7 @@ class PersistenceTests(unittest.TestCase):
before = copy.deepcopy(self.game.state)
status, summary = request(token="alice-token", path="/api/me")
self.assertEqual(status, 200)
self.assertEqual(len(summary["achievements"]), 8)
self.assertEqual(len(summary["achievements"]), 12)
self.assertEqual(summary["total_points"], 40)
self.assertEqual(self.game.state, before)
self.assertNotIn("achievements", self.game.load_state())