|
| 1 | +# Achievement System |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +Players currently have no progression system beyond raw game counts on their profile. An achievement system gives players incentives to play more, try different roles, be reliable (join servers quickly, don't disconnect), and take substitute spots. Achievements are public on profiles and players get a toast notification on unlock. |
| 6 | + |
| 7 | +## Achievement Definitions |
| 8 | + |
| 9 | +Static array in code. Each has: `id`, `name`, `description`, `tier` (bronze/silver/gold/australium). |
| 10 | + |
| 11 | +### Games played |
| 12 | + |
| 13 | +| ID | Name | Description | Tier | |
| 14 | +| ------------------- | ----------------- | ------------------ | ---------- | |
| 15 | +| `first-blood` | First Blood | Play your first game | bronze | |
| 16 | +| `mercenary` | Mercenary | Play 100 games | bronze | |
| 17 | +| `grizzled-veteran` | Grizzled Veteran | Play 250 games | silver | |
| 18 | +| `f2p-no-more` | F2P No More | Play 1000 games | gold | |
| 19 | +| `australium-legend` | Australium Legend | Play 5000 games | australium | |
| 20 | + |
| 21 | +### Class-specific |
| 22 | + |
| 23 | +| ID | Name | Description | Tier | |
| 24 | +| -------------- | --------------------------------------------- | -------------------------- | ------ | |
| 25 | +| `ze-healing` | Ze Healing Is Not As Rewarding As Ze Hurting | Play 100 games as medic | bronze | |
| 26 | +| `ubermensch` | Übermensch | Play 500 games as medic | silver | |
| 27 | +| `grasshopper` | Grasshopper | Play 500 games as scout | silver | |
| 28 | +| `maggots` | Maggots! | Play 500 games as soldier | silver | |
| 29 | +| `kabooom` | Kabooom! | Play 500 games as demoman | silver | |
| 30 | + |
| 31 | +### Substitute |
| 32 | + |
| 33 | +| ID | Name | Description | Tier | |
| 34 | +| ------------------- | --------------------------- | ------------------------------ | ------ | |
| 35 | +| `reinforcements` | Reinforcements Have Arrived | Join a game as a substitute | bronze | |
| 36 | +| `mann-co-reserve` | Mann Co. Reserve | Join 10 games as a substitute | silver | |
| 37 | + |
| 38 | +### Server join speed |
| 39 | + |
| 40 | +| ID | Name | Description | Tier | |
| 41 | +| ------------------------ | --------------------- | ------------------------------------------------------------ | ------ | |
| 42 | +| `need-a-dispenser-here` | Need A Dispenser Here | Join the game server within 1 minute of it being ready 50 times | silver | |
| 43 | + |
| 44 | +### No disconnects |
| 45 | + |
| 46 | +| ID | Name | Description | Tier | |
| 47 | +| -------------- | ------------- | -------------------------------------------------- | ------ | |
| 48 | +| `iron-mann` | Iron Mann | Complete 10 games without disconnecting from the server | silver | |
| 49 | +| `mann-of-steel`| Mann of Steel | Complete 50 games without disconnecting | gold | |
| 50 | + |
| 51 | +### Top DPM (logs.tf stats) |
| 52 | + |
| 53 | +| ID | Name | Description | Tier | |
| 54 | +| -------------------- | ------------------------ | ------------------------------------------ | ---------- | |
| 55 | +| `top-damage-dealer` | Top Damage Dealer | Have the highest DPM in a game 10 times | bronze | |
| 56 | +| `pain-train` | Pain Train | Have the highest DPM in a game 100 times | silver | |
| 57 | +| `australium-rl` | Australium Rocket Launcher | Have the highest DPM in a game 1000 times | australium | |
| 58 | + |
| 59 | +### High HPM (logs.tf stats) |
| 60 | + |
| 61 | +| ID | Name | Description | Tier | |
| 62 | +| ------------------ | --------------------- | ---------------------------------------------------- | ---------- | |
| 63 | +| `quick-fix` | Quick-Fix | Heal more than 1200 HPM in a game | bronze | |
| 64 | +| `miracle-worker` | Miracle Worker | Heal more than 1200 HPM in 10 games | silver | |
| 65 | +| `mannpower-medic` | Mannpower Medic | Heal more than 1200 HPM in 100 games | australium | |
| 66 | + |
| 67 | +## Database Model |
| 68 | + |
| 69 | +New collection: `playerachievements` (single document per player) |
| 70 | + |
| 71 | +```ts |
| 72 | +// src/database/models/player-achievement.model.ts |
| 73 | +interface PlayerAchievementModel { |
| 74 | + player: SteamId64 |
| 75 | + achievements: PlayerAchievement[] // unlocked achievements |
| 76 | + progress: AchievementProgress // counters for multi-game tracking |
| 77 | +} |
| 78 | + |
| 79 | +interface PlayerAchievement { |
| 80 | + achievementId: string |
| 81 | + unlockedAt: Date |
| 82 | +} |
| 83 | + |
| 84 | +interface AchievementProgress { |
| 85 | + substituteGames: number |
| 86 | + quickJoins: number |
| 87 | + gamesWithoutDisconnect: number |
| 88 | + topDpmGames: number |
| 89 | + highHpmGames: number |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +Index: `{ player: 1 }` unique. |
| 94 | + |
| 95 | +## Logs.tf Stats Fetching |
| 96 | + |
| 97 | +The app already uploads game logs to logs.tf and stores the resulting URL in `game.logsUrl` (see `src/logs-tf/`). However, the app does **not** currently fetch stats back from logs.tf. For the DPM and HPM achievements we need to parse the logs.tf JSON API response. |
| 98 | + |
| 99 | +### logs.tf JSON API |
| 100 | + |
| 101 | +Each uploaded log has a JSON endpoint at `https://logs.tf/json/<log_id>`. The response includes per-player stats keyed by SteamID3. Relevant fields: |
| 102 | + |
| 103 | +``` |
| 104 | +GET https://logs.tf/json/1234567 |
| 105 | +{ |
| 106 | + "length": 1800, // match duration in seconds |
| 107 | + "players": { |
| 108 | + "[U:1:12345]": { // SteamID3 format |
| 109 | + "dmg": 54000, // total damage dealt |
| 110 | + "heal": 36000, // total healing done (only relevant for medics) |
| 111 | + ... |
| 112 | + }, |
| 113 | + ... |
| 114 | + } |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +- **DPM** = `player.dmg / (length / 60)` |
| 119 | +- **HPM** = `player.heal / (length / 60)` |
| 120 | +- The player with the highest DPM across all players in the match gets the "top DPM" credit. |
| 121 | +- Any medic with HPM > 1200 gets the "high HPM" credit. |
| 122 | + |
| 123 | +### Implementation: `src/logs-tf/fetch-logs-tf-stats.ts` |
| 124 | + |
| 125 | +New file to fetch and parse the logs.tf JSON response: |
| 126 | + |
| 127 | +- Extract log ID from the stored `logsUrl` (e.g. `https://logs.tf/1234567` → `1234567`) |
| 128 | +- Fetch `https://logs.tf/json/{logId}` |
| 129 | +- Validate response with a Zod schema (at minimum: `length`, `players` map with `dmg` and `heal` per player) |
| 130 | +- Convert SteamID3 keys (`[U:1:12345]`) to SteamId64 to match our player model |
| 131 | +- Return a typed result with per-player DPM and HPM values |
| 132 | + |
| 133 | +### Integration with achievement checking |
| 134 | + |
| 135 | +The `award-achievements` plugin (step 3 below) calls this fetch function after `game:ended` for games that have a `logsUrl`. Since logs are uploaded asynchronously after match end, the achievement check for logs.tf-based achievements should be scheduled with a delay (or triggered after the `logsUrl` is set on the game document). |
| 136 | + |
| 137 | +**Option:** listen for when `logsUrl` is written to the game (a new event `game:logsUploaded` emitted from the logs-tf plugin after successful upload) and run the logs.tf achievement checks at that point, separately from the main `game:ended` achievement check. |
| 138 | + |
| 139 | +## File Structure |
| 140 | + |
| 141 | +``` |
| 142 | +src/achievements/ |
| 143 | + achievement.ts # Achievement type + AchievementTier enum |
| 144 | + achievements.ts # Static array of all achievement definitions |
| 145 | + index.ts # Module exports (byPlayer, etc.) |
| 146 | + plugins/ |
| 147 | + award-achievements.ts # Listens to game:ended, checks & awards |
| 148 | + award-logs-achievements.ts # Listens to game:logsUploaded, checks DPM/HPM achievements |
| 149 | + views/html/ |
| 150 | + player-achievements.tsx # Profile section component |
| 151 | + achievement-badge.tsx # Single badge component |
| 152 | +src/logs-tf/ |
| 153 | + fetch-logs-tf-stats.ts # Fetch & parse logs.tf JSON API |
| 154 | +``` |
| 155 | + |
| 156 | +## Implementation Steps |
| 157 | + |
| 158 | +### 1. Types & definitions |
| 159 | + |
| 160 | +- `src/achievements/achievement.ts` — `Achievement` interface, `AchievementTier` enum |
| 161 | +- `src/achievements/achievements.ts` — static array of all 20 achievements |
| 162 | + |
| 163 | +### 2. Database model & collection |
| 164 | + |
| 165 | +- `src/database/models/player-achievement.model.ts` — model interfaces |
| 166 | +- Add to `src/database/collections.ts` — `playerAchievements` collection |
| 167 | +- Add to `src/database/ensure-indexes.ts` — unique index on `player` |
| 168 | + |
| 169 | +### 3. Achievement checking plugin (game-based) |
| 170 | + |
| 171 | +`src/achievements/plugins/award-achievements.ts` |
| 172 | + |
| 173 | +- Listens to `game:ended` (guarded by `game.state === GameState.ended`) |
| 174 | +- For each active slot player: |
| 175 | + - Read player stats (compute `totalGames + 1` to avoid race with `update-player-stats`) |
| 176 | + - Read/upsert player achievement document |
| 177 | + - Check each achievement's criteria: |
| 178 | + - **Games played:** `totalGames + 1 >= threshold` |
| 179 | + - **Class-specific:** `gamesByClass[class] + 1 >= threshold` (if current game class matches) |
| 180 | + - **Substitute:** check `PlayerReplaced` events where `replacement === player`; increment `progress.substituteGames` |
| 181 | + - **Quick join:** find `GameServerInitialized` and `PlayerJoinedGameServer` events, compare timestamps (delta < 60s) |
| 182 | + - **No disconnect:** check no `PlayerLeftGameServer` for this player without a subsequent `PlayerJoinedGameServer`; update `progress.gamesWithoutDisconnect` |
| 183 | + - Push newly unlocked achievements via `$push` + update `$set` for progress |
| 184 | + |
| 185 | +### 4. Logs.tf stats fetching |
| 186 | + |
| 187 | +- `src/logs-tf/fetch-logs-tf-stats.ts` — fetch JSON from logs.tf, parse with Zod, convert SteamID3 → SteamId64, return per-player DPM/HPM |
| 188 | +- Add `game:logsUploaded` event to `src/events.ts` — emitted from `src/logs-tf/plugins/index.ts` after successful upload |
| 189 | +- SteamID3 conversion utility (or use existing library if available) |
| 190 | + |
| 191 | +### 5. Achievement checking plugin (logs.tf-based) |
| 192 | + |
| 193 | +`src/achievements/plugins/award-logs-achievements.ts` |
| 194 | + |
| 195 | +- Listens to `game:logsUploaded` |
| 196 | +- Fetches logs.tf stats for the game |
| 197 | +- For each player in the game: |
| 198 | + - **Top DPM:** determine which player had the highest DPM; increment that player's `progress.topDpmGames` |
| 199 | + - **High HPM:** check if the player's HPM > 1200; if so increment `progress.highHpmGames` |
| 200 | +- Award corresponding achievements when thresholds are met |
| 201 | + |
| 202 | +### 6. Toast notification on unlock |
| 203 | + |
| 204 | +- Use the existing WebSocket/event system to push a notification when new achievements are awarded |
| 205 | +- The `game:ended` / `game:logsUploaded` handlers, after computing new achievements, emit an event (or directly push via SSE/WS) for each player with new unlocks |
| 206 | +- Client-side toast component in `src/html/@client/` to display the notification |
| 207 | + |
| 208 | +### 7. Profile page display |
| 209 | + |
| 210 | +- `src/achievements/views/html/player-achievements.tsx` — fetches player achievements, renders grid of badges |
| 211 | +- `src/achievements/views/html/achievement-badge.tsx` — individual badge with tier-colored styling, name, tooltip with description + unlock date |
| 212 | +- Integrate into `src/players/views/html/player.page.tsx` — add `<PlayerAchievements>` between AdminToolbox and gameList div |
| 213 | + |
| 214 | +### 8. Module index |
| 215 | + |
| 216 | +- `src/achievements/index.ts` — exports `byPlayer` function for fetching a player's achievements |
| 217 | + |
| 218 | +### 9. Migration: backfill existing players |
| 219 | + |
| 220 | +`src/migrations/015-backfill-player-achievements.ts` |
| 221 | + |
| 222 | +- Iterate all players, query their game history, compute achievements and progress counters |
| 223 | +- For logs.tf-based achievements: fetch stats from logs.tf for all games that have a `logsUrl` (rate-limit API calls) |
| 224 | +- Upsert into `playerachievements` collection |
| 225 | + |
| 226 | +## Critical Files to Modify |
| 227 | + |
| 228 | +- `src/database/collections.ts` — add collection |
| 229 | +- `src/database/ensure-indexes.ts` — add index |
| 230 | +- `src/events.ts` — add `game:logsUploaded` event |
| 231 | +- `src/logs-tf/plugins/index.ts` — emit `game:logsUploaded` after successful upload |
| 232 | +- `src/players/views/html/player.page.tsx` — integrate achievements section |
| 233 | + |
| 234 | +## Verification |
| 235 | + |
| 236 | +- Run `pnpm test` after writing unit tests for achievement checking logic |
| 237 | +- Start dev server with `docker-compose up -d mongo && pnpm dev` |
| 238 | +- Play through game lifecycle and verify achievements appear on profile |
| 239 | +- Check toast notification appears on achievement unlock |
| 240 | +- Verify logs.tf-based achievements trigger correctly after log upload |
0 commit comments