Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions achievements.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# Achievement System

## Context

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.

## Achievement Definitions

Static array in code. Each has: `id`, `name`, `description`, `tier` (bronze/silver/gold/australium).

### Games played

| ID | Name | Description | Tier |
| ------------------- | ----------------- | ------------------ | ---------- |
| `first-blood` | First Blood | Play your first game | bronze |
| `mercenary` | Mercenary | Play 100 games | bronze |
| `grizzled-veteran` | Grizzled Veteran | Play 250 games | silver |
| `f2p-no-more` | F2P No More | Play 1000 games | gold |
| `australium-legend` | Australium Legend | Play 5000 games | australium |

### Class-specific

| ID | Name | Description | Tier |
| -------------- | --------------------------------------------- | -------------------------- | ------ |
| `ze-healing` | Ze Healing Is Not As Rewarding As Ze Hurting | Play 100 games as medic | bronze |
| `ubermensch` | Übermensch | Play 500 games as medic | silver |
| `grasshopper` | Grasshopper | Play 500 games as scout | silver |
| `maggots` | Maggots! | Play 500 games as soldier | silver |
| `kabooom` | Kabooom! | Play 500 games as demoman | silver |

### Substitute

| ID | Name | Description | Tier |
| ------------------- | --------------------------- | ------------------------------ | ------ |
| `reinforcements` | Reinforcements Have Arrived | Join a game as a substitute | bronze |
| `mann-co-reserve` | Mann Co. Reserve | Join 10 games as a substitute | silver |

### Server join speed

| ID | Name | Description | Tier |
| ------------------------ | --------------------- | ------------------------------------------------------------ | ------ |
| `need-a-dispenser-here` | Need A Dispenser Here | Join the game server within 1 minute of it being ready 50 times | silver |

### No disconnects

| ID | Name | Description | Tier |
| -------------- | ------------- | -------------------------------------------------- | ------ |
| `iron-mann` | Iron Mann | Complete 10 games without disconnecting from the server | silver |
| `mann-of-steel`| Mann of Steel | Complete 50 games without disconnecting | gold |

### Top DPM (logs.tf stats)

| ID | Name | Description | Tier |
| -------------------- | ------------------------ | ------------------------------------------ | ---------- |
| `top-damage-dealer` | Top Damage Dealer | Have the highest DPM in a game 10 times | bronze |
| `pain-train` | Pain Train | Have the highest DPM in a game 100 times | silver |
| `australium-rl` | Australium Rocket Launcher | Have the highest DPM in a game 1000 times | australium |

### High HPM (logs.tf stats)

| ID | Name | Description | Tier |
| ------------------ | --------------------- | ---------------------------------------------------- | ---------- |
| `quick-fix` | Quick-Fix | Heal more than 1200 HPM in a game | bronze |
| `miracle-worker` | Miracle Worker | Heal more than 1200 HPM in 10 games | silver |
| `mannpower-medic` | Mannpower Medic | Heal more than 1200 HPM in 100 games | australium |

## Database Model

New collection: `playerachievements` (single document per player)

```ts
// src/database/models/player-achievement.model.ts
interface PlayerAchievementModel {
player: SteamId64
achievements: PlayerAchievement[] // unlocked achievements
progress: AchievementProgress // counters for multi-game tracking
}

interface PlayerAchievement {
achievementId: string
unlockedAt: Date
}

interface AchievementProgress {
substituteGames: number
quickJoins: number
gamesWithoutDisconnect: number
topDpmGames: number
highHpmGames: number
}
```

Index: `{ player: 1 }` unique.

## Logs.tf Stats Fetching

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.

### logs.tf JSON API

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:

```
GET https://logs.tf/json/1234567
{
"length": 1800, // match duration in seconds
"players": {
"[U:1:12345]": { // SteamID3 format
"dmg": 54000, // total damage dealt
"heal": 36000, // total healing done (only relevant for medics)
...
},
...
}
}
```

- **DPM** = `player.dmg / (length / 60)`
- **HPM** = `player.heal / (length / 60)`
- The player with the highest DPM across all players in the match gets the "top DPM" credit.
- Any medic with HPM > 1200 gets the "high HPM" credit.

### Implementation: `src/logs-tf/fetch-logs-tf-stats.ts`

New file to fetch and parse the logs.tf JSON response:

- Extract log ID from the stored `logsUrl` (e.g. `https://logs.tf/1234567` → `1234567`)
- Fetch `https://logs.tf/json/{logId}`
- Validate response with a Zod schema (at minimum: `length`, `players` map with `dmg` and `heal` per player)
- Convert SteamID3 keys (`[U:1:12345]`) to SteamId64 to match our player model
- Return a typed result with per-player DPM and HPM values

### Integration with achievement checking

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).

**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.

## File Structure

```
src/achievements/
achievement.ts # Achievement type + AchievementTier enum
achievements.ts # Static array of all achievement definitions
index.ts # Module exports (byPlayer, etc.)
plugins/
award-achievements.ts # Listens to game:ended, checks & awards
award-logs-achievements.ts # Listens to game:logsUploaded, checks DPM/HPM achievements
views/html/
player-achievements.tsx # Profile section component
achievement-badge.tsx # Single badge component
src/logs-tf/
fetch-logs-tf-stats.ts # Fetch & parse logs.tf JSON API
```

## Implementation Steps

### 1. Types & definitions

- `src/achievements/achievement.ts` — `Achievement` interface, `AchievementTier` enum
- `src/achievements/achievements.ts` — static array of all 20 achievements

### 2. Database model & collection

- `src/database/models/player-achievement.model.ts` — model interfaces
- Add to `src/database/collections.ts` — `playerAchievements` collection
- Add to `src/database/ensure-indexes.ts` — unique index on `player`

### 3. Achievement checking plugin (game-based)

`src/achievements/plugins/award-achievements.ts`

- Listens to `game:ended` (guarded by `game.state === GameState.ended`)
- For each active slot player:
- Read player stats (compute `totalGames + 1` to avoid race with `update-player-stats`)
- Read/upsert player achievement document
- Check each achievement's criteria:
- **Games played:** `totalGames + 1 >= threshold`
- **Class-specific:** `gamesByClass[class] + 1 >= threshold` (if current game class matches)
- **Substitute:** check `PlayerReplaced` events where `replacement === player`; increment `progress.substituteGames`
- **Quick join:** find `GameServerInitialized` and `PlayerJoinedGameServer` events, compare timestamps (delta < 60s)
- **No disconnect:** check no `PlayerLeftGameServer` for this player without a subsequent `PlayerJoinedGameServer`; update `progress.gamesWithoutDisconnect`
- Push newly unlocked achievements via `$push` + update `$set` for progress

### 4. Logs.tf stats fetching

- `src/logs-tf/fetch-logs-tf-stats.ts` — fetch JSON from logs.tf, parse with Zod, convert SteamID3 → SteamId64, return per-player DPM/HPM
- Add `game:logsUploaded` event to `src/events.ts` — emitted from `src/logs-tf/plugins/index.ts` after successful upload
- SteamID3 conversion utility (or use existing library if available)

### 5. Achievement checking plugin (logs.tf-based)

`src/achievements/plugins/award-logs-achievements.ts`

- Listens to `game:logsUploaded`
- Fetches logs.tf stats for the game
- For each player in the game:
- **Top DPM:** determine which player had the highest DPM; increment that player's `progress.topDpmGames`
- **High HPM:** check if the player's HPM > 1200; if so increment `progress.highHpmGames`
- Award corresponding achievements when thresholds are met

### 6. Toast notification on unlock

- Use the existing WebSocket/event system to push a notification when new achievements are awarded
- 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
- Client-side toast component in `src/html/@client/` to display the notification

### 7. Profile page display

- `src/achievements/views/html/player-achievements.tsx` — fetches player achievements, renders grid of badges
- `src/achievements/views/html/achievement-badge.tsx` — individual badge with tier-colored styling, name, tooltip with description + unlock date
- Integrate into `src/players/views/html/player.page.tsx` — add `<PlayerAchievements>` between AdminToolbox and gameList div

### 8. Module index

- `src/achievements/index.ts` — exports `byPlayer` function for fetching a player's achievements

### 9. Migration: backfill existing players

`src/migrations/015-backfill-player-achievements.ts`

- Iterate all players, query their game history, compute achievements and progress counters
- For logs.tf-based achievements: fetch stats from logs.tf for all games that have a `logsUrl` (rate-limit API calls)
- Upsert into `playerachievements` collection

## Critical Files to Modify

- `src/database/collections.ts` — add collection
- `src/database/ensure-indexes.ts` — add index
- `src/events.ts` — add `game:logsUploaded` event
- `src/logs-tf/plugins/index.ts` — emit `game:logsUploaded` after successful upload
- `src/players/views/html/player.page.tsx` — integrate achievements section

## Verification

- Run `pnpm test` after writing unit tests for achievement checking logic
- Start dev server with `docker-compose up -d mongo && pnpm dev`
- Play through game lifecycle and verify achievements appear on profile
- Check toast notification appears on achievement unlock
- Verify logs.tf-based achievements trigger correctly after log upload
120 changes: 117 additions & 3 deletions src/players/views/html/player.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { queue } from '../../../queue'
import { GameClassIcon } from '../../../html/components/game-class-icon'
import {
IconAlignBoxBottomRight,
IconAwardFilled,
IconBrandSteam,
IconBrandTwitch,
IconClover,
Expand Down Expand Up @@ -64,8 +65,14 @@ export async function PlayerPage(props: { steamId: SteamId64; page: number }) {

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

<div id="gameList" class="contents">
<PlayerGameList steamId={player.steamId} page={props.page} />
<div class="player-content-columns">
<div class="player-content-left">
<PlayerAchievements />
</div>

<div class="player-content-right" id="gameList">
<PlayerGameList steamId={player.steamId} page={props.page} />
</div>
</div>
</div>
</Page>
Expand Down Expand Up @@ -106,7 +113,7 @@ export async function PlayerGameList(props: { steamId: SteamId64; page: number }
<div class="text-abru-light-75 text-center text-2xl font-bold md:text-start">
Game history
</div>
<div class="game-list col-span-2">
<div class="game-list">
{games.map(game => (
<GameListItem
game={game}
Expand Down Expand Up @@ -253,3 +260,110 @@ function PlayerPresentation(props: {
</div>
)
}

// ── MOCKUP: Achievement data & component (fake data for visual preview) ──

type AchievementTier = 'bronze' | 'silver' | 'gold' | 'australium'

interface MockAchievement {
name: string
description: string
tier: AchievementTier
unlocked: boolean
unlockedAt?: string
progress?: { current: number; target: number }
}

const tierColors: Record<AchievementTier, string> = {
bronze: '#cd7f32',
silver: '#bbbbbb',
gold: '#e3c392',
australium: '#e3b63a',
}

const mockAchievements: MockAchievement[] = [
{ name: 'First Blood', description: 'Play your first game', tier: 'bronze', unlocked: true, unlockedAt: 'Jan 15, 2025' },
{ name: 'Mercenary', description: 'Play 100 games', tier: 'bronze', unlocked: true, unlockedAt: 'Mar 22, 2025' },
{ name: 'Ze Healing Is Not As Rewarding As Ze Hurting', description: 'Play 100 games as medic', tier: 'bronze', unlocked: true, unlockedAt: 'May 10, 2025' },
{ name: 'Reinforcements Have Arrived', description: 'Join a game as a substitute', tier: 'bronze', unlocked: true, unlockedAt: 'Feb 3, 2025' },
{ name: 'Top Damage Dealer', description: 'Have the highest DPM in a game 10 times', tier: 'bronze', unlocked: true, unlockedAt: 'Apr 8, 2025' },
{ name: 'Quick-Fix', description: 'Heal more than 1200 HPM in a game', tier: 'bronze', unlocked: true, unlockedAt: 'Jun 1, 2025' },
{ name: 'Grizzled Veteran', description: 'Play 250 games', tier: 'silver', unlocked: true, unlockedAt: 'Jul 14, 2025' },
{ name: 'Übermensch', description: 'Play 500 games as medic', tier: 'silver', unlocked: true, unlockedAt: 'Nov 2, 2025' },
{ name: 'Iron Mann', description: 'Complete 10 games without disconnecting', tier: 'silver', unlocked: true, unlockedAt: 'Feb 28, 2025' },
{ name: 'Need A Dispenser Here', description: 'Join the server within 1 min 50 times', tier: 'silver', unlocked: true, unlockedAt: 'Sep 5, 2025' },
{ name: 'F2P No More', description: 'Play 1000 games', tier: 'gold', unlocked: true, unlockedAt: 'Dec 20, 2025' },
{ name: 'Mann of Steel', description: 'Complete 50 games without disconnecting', tier: 'gold', unlocked: false, progress: { current: 30, target: 50 } },
{ name: 'Pain Train', description: 'Have the highest DPM in a game 100 times', tier: 'silver', unlocked: false, progress: { current: 42, target: 100 } },
{ name: 'Miracle Worker', description: 'Heal more than 1200 HPM in 10 games', tier: 'silver', unlocked: false, progress: { current: 7, target: 10 } },
{ name: 'Australium Legend', description: 'Play 5000 games', tier: 'australium', unlocked: false, progress: { current: 1100, target: 5000 } },
{ name: 'Australium Rocket Launcher', description: 'Have the highest DPM 1000 times', tier: 'australium', unlocked: false, progress: { current: 42, target: 1000 } },
{ name: 'Mannpower Medic', description: 'Heal more than 1200 HPM in 100 games', tier: 'australium', unlocked: false, progress: { current: 7, target: 100 } },
]

function AchievementBadge(props: { achievement: MockAchievement }) {
const { achievement: a } = props
const color = tierColors[a.tier]
const progressPct = a.progress ? Math.round((a.progress.current / a.progress.target) * 100) : 0

return (
<div class={['achievement-badge', `tier-${a.tier}`, !a.unlocked && 'locked']}>
<div class="achievement-icon">
<IconAwardFilled size={28} />
</div>
<div class="achievement-name" safe>{a.name}</div>
<div class="achievement-tier" style={`color: ${color}`}>
{a.tier}
</div>
{!a.unlocked && a.progress && (
<div class="achievement-progress">
<div
class="achievement-progress-bar"
style={`width: ${String(progressPct)}%; background-color: ${color}`}
></div>
</div>
)}
<div class="tooltip">
<div class="tooltip-desc" safe>{a.description}</div>
<div class="tooltip-date" safe>
{a.unlocked ? `Unlocked ${a.unlockedAt}` : `${String(a.progress?.current ?? 0)} / ${String(a.progress?.target ?? '?')}`}
</div>
</div>
</div>
)
}

function PlayerAchievements() {
const unlocked = mockAchievements.filter(a => a.unlocked)
const locked = mockAchievements.filter(a => !a.unlocked)

return (
<>
<div class="text-abru-light-75 text-center text-2xl font-bold md:text-start">
Achievements
<span class="text-abru-light-50 text-base font-normal ml-2">
{unlocked.length}/{mockAchievements.length}
</span>
</div>
<div class="achievements-scroll" data-fade-scroll>
<div class="achievements-grid">
{unlocked.map(a => (
<AchievementBadge achievement={a} />
))}
</div>
</div>
{locked.length > 0 && (
<details class="achievements-locked-group">
<summary class="achievements-locked-toggle">
<span>{locked.length} locked achievement{locked.length !== 1 ? 's' : ''}</span>
</summary>
<div class="achievements-grid mt-3">
{locked.map(a => (
<AchievementBadge achievement={a} />
))}
</div>
</details>
)}
</>
)
}
Loading
Loading