Daily and weekly missions — a reason to come back
Every finished game now moves a set of daily and weekly missions, awards XP the moment you complete one, and feeds a fresh weekly leaderboard.

In plain words
Until now the only progression in opolyx was a one-off reward for winning a game. There was nothing to chase day to day. Missions change that: a small set of daily and weekly goals that tick forward every time you finish a game, win or lose.
You do not claim anything and there is no button to press — the moment a game ends, the server checks your missions, credits the ones you completed, and adds the XP straight to your level. Six missions run at once:
- Daily — play 1 game, play 3 games, win 1 game.
- Weekly — play 10 games, win 3 games, win 3 Speed games.
- Daily missions reset every day; weekly missions reset every Monday.
| Mission | Cadence | Target | Reward |
|---|---|---|---|
| Play a game | Daily | 1 | +50 XP |
| Keep playing | Daily | 3 | +120 XP |
| Win a game | Daily | 1 | +150 XP |
| Regular | Weekly | 10 games | +300 XP |
| On a roll | Weekly | 3 wins | +400 XP |
| Speed demon | Weekly | 3 Speed wins | +400 XP |
Finishing games also earns weekly leaderboard points, so the more you play the higher you climb. The board resets every week, giving everyone a clean shot at the top on Monday morning.
For the technically curious
The schema for a full retention loop already existed but sat dormant and unread — missions / user_missions (cadence, target, progress, xp_reward) and weekly_top (week_start, points). Migration 0031 wired it into the existing finish-reward path instead of inventing anything new.
On a real finish the daemon loops every human player and calls a single service-role RPC. It advances every mission the finish satisfies for the current cycle, grants the XP the first time a target is crossed, and adds to the weekly points tally:
-- grant_play_rewards(p_game, p_user, p_won): security definer, service-role only
-- daily cadence = current_date; weekly = date_trunc('week', now())
-- weekly_top.points += 20 (play) + (won ? 200 : 0) + mission_xp_earned
-- idempotent per (game_id, user_id) via the game_play_grants guard; bots skipped| Event match | Advances | Points |
|---|---|---|
| {"event":"play"} | Every finish | +20 |
| {"event":"win"} | Wins only | +200 |
| {"event":"play","mode":"speed"} | Speed games only | +20 |
Idempotency is a per-(game, user) guard row, so a retried commit never double-pays. A privilege-escalation trap surfaced at apply time: Postgres default privileges grant EXECUTE on new functions to anon/authenticated, and revoke … from public does not remove that. A SECURITY DEFINER reward function callable by any logged-in user is an XP farm — fixed with revoke … from public, anon, authenticated so only {postgres, service_role} can call it, matching award_game_winner.
Rewards are XP-only for now — coins belong to the monetization track, which needs a spend-sink first. The Play-page panel and the Leaderboard both read current-cycle progress client-side, with the week anchored to the same Monday boundary Postgres uses so the two never disagree.
