Skip to content

Commit 6c997d4

Browse files
garrappachcclaude
andcommitted
feat: add achievement system plan and UI mockup
Add feature plan for a player achievement system with 20 achievements across 7 categories (games played, class-specific, substitute, server join speed, no disconnects, top DPM, high HPM). The DPM and HPM achievements require fetching stats from the logs.tf JSON API. Include a working UI mockup directly in the player profile page with fake data to visualize the two-column layout: achievements on the left (scrollable with fade mask, collapsible locked section) and game history on the right. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b3d88d6 commit 6c997d4

3 files changed

Lines changed: 543 additions & 3 deletions

File tree

achievements.plan.md

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

src/players/views/html/player.page.tsx

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { queue } from '../../../queue'
88
import { GameClassIcon } from '../../../html/components/game-class-icon'
99
import {
1010
IconAlignBoxBottomRight,
11+
IconAwardFilled,
1112
IconBrandSteam,
1213
IconBrandTwitch,
1314
IconClover,
@@ -64,8 +65,14 @@ export async function PlayerPage(props: { steamId: SteamId64; page: number }) {
6465

6566
{user?.player.roles.includes(PlayerRole.admin) && <AdminToolbox player={player} />}
6667

67-
<div id="gameList" class="contents">
68-
<PlayerGameList steamId={player.steamId} page={props.page} />
68+
<div class="player-content-columns">
69+
<div class="player-content-left">
70+
<PlayerAchievements />
71+
</div>
72+
73+
<div class="player-content-right" id="gameList">
74+
<PlayerGameList steamId={player.steamId} page={props.page} />
75+
</div>
6976
</div>
7077
</div>
7178
</Page>
@@ -106,7 +113,7 @@ export async function PlayerGameList(props: { steamId: SteamId64; page: number }
106113
<div class="text-abru-light-75 text-center text-2xl font-bold md:text-start">
107114
Game history
108115
</div>
109-
<div class="game-list col-span-2">
116+
<div class="game-list">
110117
{games.map(game => (
111118
<GameListItem
112119
game={game}
@@ -253,3 +260,110 @@ function PlayerPresentation(props: {
253260
</div>
254261
)
255262
}
263+
264+
// ── MOCKUP: Achievement data & component (fake data for visual preview) ──
265+
266+
type AchievementTier = 'bronze' | 'silver' | 'gold' | 'australium'
267+
268+
interface MockAchievement {
269+
name: string
270+
description: string
271+
tier: AchievementTier
272+
unlocked: boolean
273+
unlockedAt?: string
274+
progress?: { current: number; target: number }
275+
}
276+
277+
const tierColors: Record<AchievementTier, string> = {
278+
bronze: '#cd7f32',
279+
silver: '#bbbbbb',
280+
gold: '#e3c392',
281+
australium: '#e3b63a',
282+
}
283+
284+
const mockAchievements: MockAchievement[] = [
285+
{ name: 'First Blood', description: 'Play your first game', tier: 'bronze', unlocked: true, unlockedAt: 'Jan 15, 2025' },
286+
{ name: 'Mercenary', description: 'Play 100 games', tier: 'bronze', unlocked: true, unlockedAt: 'Mar 22, 2025' },
287+
{ name: 'Ze Healing Is Not As Rewarding As Ze Hurting', description: 'Play 100 games as medic', tier: 'bronze', unlocked: true, unlockedAt: 'May 10, 2025' },
288+
{ name: 'Reinforcements Have Arrived', description: 'Join a game as a substitute', tier: 'bronze', unlocked: true, unlockedAt: 'Feb 3, 2025' },
289+
{ name: 'Top Damage Dealer', description: 'Have the highest DPM in a game 10 times', tier: 'bronze', unlocked: true, unlockedAt: 'Apr 8, 2025' },
290+
{ name: 'Quick-Fix', description: 'Heal more than 1200 HPM in a game', tier: 'bronze', unlocked: true, unlockedAt: 'Jun 1, 2025' },
291+
{ name: 'Grizzled Veteran', description: 'Play 250 games', tier: 'silver', unlocked: true, unlockedAt: 'Jul 14, 2025' },
292+
{ name: 'Übermensch', description: 'Play 500 games as medic', tier: 'silver', unlocked: true, unlockedAt: 'Nov 2, 2025' },
293+
{ name: 'Iron Mann', description: 'Complete 10 games without disconnecting', tier: 'silver', unlocked: true, unlockedAt: 'Feb 28, 2025' },
294+
{ name: 'Need A Dispenser Here', description: 'Join the server within 1 min 50 times', tier: 'silver', unlocked: true, unlockedAt: 'Sep 5, 2025' },
295+
{ name: 'F2P No More', description: 'Play 1000 games', tier: 'gold', unlocked: true, unlockedAt: 'Dec 20, 2025' },
296+
{ name: 'Mann of Steel', description: 'Complete 50 games without disconnecting', tier: 'gold', unlocked: false, progress: { current: 30, target: 50 } },
297+
{ name: 'Pain Train', description: 'Have the highest DPM in a game 100 times', tier: 'silver', unlocked: false, progress: { current: 42, target: 100 } },
298+
{ name: 'Miracle Worker', description: 'Heal more than 1200 HPM in 10 games', tier: 'silver', unlocked: false, progress: { current: 7, target: 10 } },
299+
{ name: 'Australium Legend', description: 'Play 5000 games', tier: 'australium', unlocked: false, progress: { current: 1100, target: 5000 } },
300+
{ name: 'Australium Rocket Launcher', description: 'Have the highest DPM 1000 times', tier: 'australium', unlocked: false, progress: { current: 42, target: 1000 } },
301+
{ name: 'Mannpower Medic', description: 'Heal more than 1200 HPM in 100 games', tier: 'australium', unlocked: false, progress: { current: 7, target: 100 } },
302+
]
303+
304+
function AchievementBadge(props: { achievement: MockAchievement }) {
305+
const { achievement: a } = props
306+
const color = tierColors[a.tier]
307+
const progressPct = a.progress ? Math.round((a.progress.current / a.progress.target) * 100) : 0
308+
309+
return (
310+
<div class={['achievement-badge', `tier-${a.tier}`, !a.unlocked && 'locked']}>
311+
<div class="achievement-icon">
312+
<IconAwardFilled size={28} />
313+
</div>
314+
<div class="achievement-name" safe>{a.name}</div>
315+
<div class="achievement-tier" style={`color: ${color}`}>
316+
{a.tier}
317+
</div>
318+
{!a.unlocked && a.progress && (
319+
<div class="achievement-progress">
320+
<div
321+
class="achievement-progress-bar"
322+
style={`width: ${String(progressPct)}%; background-color: ${color}`}
323+
></div>
324+
</div>
325+
)}
326+
<div class="tooltip">
327+
<div class="tooltip-desc" safe>{a.description}</div>
328+
<div class="tooltip-date" safe>
329+
{a.unlocked ? `Unlocked ${a.unlockedAt}` : `${String(a.progress?.current ?? 0)} / ${String(a.progress?.target ?? '?')}`}
330+
</div>
331+
</div>
332+
</div>
333+
)
334+
}
335+
336+
function PlayerAchievements() {
337+
const unlocked = mockAchievements.filter(a => a.unlocked)
338+
const locked = mockAchievements.filter(a => !a.unlocked)
339+
340+
return (
341+
<>
342+
<div class="text-abru-light-75 text-center text-2xl font-bold md:text-start">
343+
Achievements
344+
<span class="text-abru-light-50 text-base font-normal ml-2">
345+
{unlocked.length}/{mockAchievements.length}
346+
</span>
347+
</div>
348+
<div class="achievements-scroll" data-fade-scroll>
349+
<div class="achievements-grid">
350+
{unlocked.map(a => (
351+
<AchievementBadge achievement={a} />
352+
))}
353+
</div>
354+
</div>
355+
{locked.length > 0 && (
356+
<details class="achievements-locked-group">
357+
<summary class="achievements-locked-toggle">
358+
<span>{locked.length} locked achievement{locked.length !== 1 ? 's' : ''}</span>
359+
</summary>
360+
<div class="achievements-grid mt-3">
361+
{locked.map(a => (
362+
<AchievementBadge achievement={a} />
363+
))}
364+
</div>
365+
</details>
366+
)}
367+
</>
368+
)
369+
}

0 commit comments

Comments
 (0)